diff --git a/.cargo/config.toml b/.cargo/config.toml index db4621b45..f41d29f92 100644 --- a/.cargo/config.toml +++ b/.cargo/config.toml @@ -1,30 +1,37 @@ # No global [build] target — the workspace contains adapters for multiple targets: -# trusted-server-adapter-fastly → wasm32-wasip1 (Fastly Compute) -# trusted-server-adapter-axum → native (dev server) -# Future: trusted-server-adapter-cloudflare → wasm32-unknown-unknown +# trusted-server-adapter-fastly → wasm32-wasip1 (Fastly Compute) +# trusted-server-adapter-axum → native (dev server) +# trusted-server-adapter-cloudflare → wasm32-unknown-unknown (Cloudflare Workers) # -# Both adapters are workspace members so `-p` resolves both. +# All adapters are workspace members so `-p` resolves each. # default-members = [fastly] — required so Viceroy can locate the binary via `cargo run --bin`. # Use the aliases below to target each adapter with the correct toolchain. [alias] # Fastly adapter + shared crates (wasm32-wasip1 via Viceroy) -test-fastly = ["test", "--workspace", "--exclude", "trusted-server-adapter-axum", "--target", "wasm32-wasip1"] +# Excludes Axum (native-only) and Cloudflare (wasm32-unknown-unknown, separate job) +test-fastly = ["test", "--workspace", "--exclude", "trusted-server-adapter-axum", "--exclude", "trusted-server-adapter-cloudflare", "--target", "wasm32-wasip1"] # Axum dev server adapter (native) test-axum = ["test", "-p", "trusted-server-adapter-axum"] -# CI convenience — runs both in sequence (shell aliases can't chain; use a script or CI steps) -# cargo test-fastly && cargo test-axum +# Cloudflare adapter (native host; WASM target checked separately in CI) +test-cloudflare = ["test", "-p", "trusted-server-adapter-cloudflare"] +# Cloudflare adapter WASM target check (wasm32-unknown-unknown requires --features cloudflare) +check-cloudflare = ["check", "-p", "trusted-server-adapter-cloudflare", "--target", "wasm32-unknown-unknown", "--features", "cloudflare"] # Clippy — target-matched to avoid cross-target compile failures -clippy-fastly = ["clippy", "--workspace", "--exclude", "trusted-server-adapter-axum", "--all-targets", "--all-features", "--target", "wasm32-wasip1", "--", "-D", "warnings"] +clippy-fastly = ["clippy", "--workspace", "--exclude", "trusted-server-adapter-axum", "--exclude", "trusted-server-adapter-cloudflare", "--all-targets", "--all-features", "--target", "wasm32-wasip1", "--", "-D", "warnings"] clippy-axum = ["clippy", "-p", "trusted-server-adapter-axum", "--all-targets", "--all-features", "--", "-D", "warnings"] +# No --all-features: the `cloudflare` feature has a compile_error! guard on +# non-wasm32 targets. WASM-feature coverage comes from `cargo check-cloudflare`. +clippy-cloudflare = ["clippy", "-p", "trusted-server-adapter-cloudflare", "--all-targets", "--", "-D", "warnings"] # Build — target-matched, same split as test/clippy -build-fastly = ["build", "--workspace", "--exclude", "trusted-server-adapter-axum", "--target", "wasm32-wasip1"] +build-fastly = ["build", "--workspace", "--exclude", "trusted-server-adapter-axum", "--exclude", "trusted-server-adapter-cloudflare", "--target", "wasm32-wasip1"] build-axum = ["build", "-p", "trusted-server-adapter-axum"] +build-cloudflare = ["build", "-p", "trusted-server-adapter-cloudflare", "--target", "wasm32-unknown-unknown", "--features", "cloudflare"] # Check — fast compile validation, same split -check-fastly = ["check", "--workspace", "--exclude", "trusted-server-adapter-axum", "--target", "wasm32-wasip1"] +check-fastly = ["check", "--workspace", "--exclude", "trusted-server-adapter-axum", "--exclude", "trusted-server-adapter-cloudflare", "--target", "wasm32-wasip1"] check-axum = ["check", "-p", "trusted-server-adapter-axum"] [target.'cfg(all(target_arch = "wasm32"))'] diff --git a/.claude/agents/build-validator.md b/.claude/agents/build-validator.md index b5382c0c5..45b001143 100644 --- a/.claude/agents/build-validator.md +++ b/.claude/agents/build-validator.md @@ -8,10 +8,11 @@ Validate that the project builds correctly across all targets. ## Steps -1. **Per-target builds** (no global target — fastly is wasm-only, axum is native) +1. **Per-target builds** (no global target — fastly is wasm32-wasip1, axum is + native, cloudflare is wasm32-unknown-unknown) ```bash - cargo build-fastly && cargo build-axum + cargo build-fastly && cargo build-axum && cargo build-cloudflare ``` 2. **WASM build** (production target) @@ -23,7 +24,7 @@ Validate that the project builds correctly across all targets. 3. **Clippy** ```bash - cargo clippy-fastly && cargo clippy-axum + cargo clippy-fastly && cargo clippy-axum && cargo clippy-cloudflare ``` 4. **Format check** diff --git a/.claude/commands/check-ci.md b/.claude/commands/check-ci.md index 98a1948be..a16458cf2 100644 --- a/.claude/commands/check-ci.md +++ b/.claude/commands/check-ci.md @@ -1,10 +1,11 @@ Run all CI checks locally, in order. Stop and report if any step fails. 1. `cargo fmt --all -- --check` -2. `cargo clippy-fastly && cargo clippy-axum` -3. `cargo test-fastly && cargo test-axum` -4. `cd crates/js/lib && npx vitest run` -5. `cd crates/js/lib && npm run format` -6. `cd docs && npm run format` +2. `cargo clippy-fastly && cargo clippy-axum && cargo clippy-cloudflare` +3. `cargo test-fastly && cargo test-axum && cargo test-cloudflare` +4. `cargo check-cloudflare` (wasm32-unknown-unknown target check, mirrors CI) +5. `cd crates/js/lib && npx vitest run` +6. `cd crates/js/lib && npm run format` +7. `cd docs && npm run format` Report a summary of all results when done. diff --git a/.claude/commands/test-all.md b/.claude/commands/test-all.md index 428911a8a..06d06dd5c 100644 --- a/.claude/commands/test-all.md +++ b/.claude/commands/test-all.md @@ -1,7 +1,7 @@ Run the full test suite for both Rust and JavaScript. ```bash -cargo test-fastly && cargo test-axum +cargo test-fastly && cargo test-axum && cargo test-cloudflare ``` Then run JS tests: diff --git a/.claude/commands/verify.md b/.claude/commands/verify.md index ef67732d8..94f6372b0 100644 --- a/.claude/commands/verify.md +++ b/.claude/commands/verify.md @@ -1,10 +1,10 @@ Full verification: build, test, and lint the entire project. -1. `cargo build-fastly && cargo build-axum` +1. `cargo build-fastly && cargo build-axum && cargo build-cloudflare` 2. `cargo build --package trusted-server-adapter-fastly --release --target wasm32-wasip1` 3. `cargo fmt --all -- --check` -4. `cargo clippy-fastly && cargo clippy-axum` -5. `cargo test-fastly && cargo test-axum` +4. `cargo clippy-fastly && cargo clippy-axum && cargo clippy-cloudflare` +5. `cargo test-fastly && cargo test-axum && cargo test-cloudflare` 6. `cd crates/js/lib && npx vitest run` 7. `cd crates/js/lib && npm run format` 8. `cd docs && npm run format` diff --git a/.github/actions/setup-integration-test-env/action.yml b/.github/actions/setup-integration-test-env/action.yml index 7d8a8338c..b86962618 100644 --- a/.github/actions/setup-integration-test-env/action.yml +++ b/.github/actions/setup-integration-test-env/action.yml @@ -25,6 +25,10 @@ inputs: description: Build the framework Docker images used by integration tests. required: false default: "true" + build-cloudflare: + description: Build the Cloudflare Workers bundle (wasm32-unknown-unknown) for integration tests. + required: false + default: "false" outputs: node-version: @@ -115,3 +119,18 @@ runs: --build-arg NODE_VERSION=${{ steps.node-version.outputs.node-version }} \ -t test-nextjs:latest \ crates/integration-tests/fixtures/frameworks/nextjs/ + + - name: Add wasm32-unknown-unknown target for Cloudflare build + if: ${{ inputs.build-cloudflare == 'true' }} + shell: bash + run: rustup target add wasm32-unknown-unknown + + - name: Build Cloudflare Workers bundle + if: ${{ inputs.build-cloudflare == 'true' }} + shell: bash + env: + TRUSTED_SERVER__PUBLISHER__ORIGIN_URL: http://127.0.0.1:${{ inputs.origin-port }} + TRUSTED_SERVER__PUBLISHER__PROXY_SECRET: integration-test-proxy-secret + TRUSTED_SERVER__SYNTHETIC__SECRET_KEY: integration-test-secret-key + TRUSTED_SERVER__PROXY__CERTIFICATE_CHECK: "false" + run: bash crates/trusted-server-adapter-cloudflare/build.sh diff --git a/.github/workflows/format.yml b/.github/workflows/format.yml index f4334f478..18b220978 100644 --- a/.github/workflows/format.yml +++ b/.github/workflows/format.yml @@ -4,7 +4,7 @@ on: push: branches: [main] pull_request: - branches: [main] + branches: [main, "feature/**"] permissions: contents: read diff --git a/.github/workflows/integration-tests.yml b/.github/workflows/integration-tests.yml index 2c570ef31..ae13cb910 100644 --- a/.github/workflows/integration-tests.yml +++ b/.github/workflows/integration-tests.yml @@ -16,6 +16,7 @@ env: WASM_ARTIFACT_PATH: /tmp/integration-test-artifacts/wasm/trusted-server-adapter-fastly.wasm AXUM_ARTIFACT_PATH: /tmp/integration-test-artifacts/axum/trusted-server-axum DOCKER_ARTIFACT_PATH: /tmp/integration-test-artifacts/docker/test-images.tar + CF_BUILD_ARTIFACT_PATH: /tmp/integration-test-artifacts/cloudflare/build jobs: prepare-artifacts: @@ -30,12 +31,14 @@ jobs: with: origin-port: ${{ env.ORIGIN_PORT }} install-viceroy: "false" + build-cloudflare: "true" - name: Package integration test artifacts run: | - mkdir -p "$(dirname "$WASM_ARTIFACT_PATH")" "$(dirname "$AXUM_ARTIFACT_PATH")" "$(dirname "$DOCKER_ARTIFACT_PATH")" + mkdir -p "$(dirname "$WASM_ARTIFACT_PATH")" "$(dirname "$AXUM_ARTIFACT_PATH")" "$(dirname "$DOCKER_ARTIFACT_PATH")" "$CF_BUILD_ARTIFACT_PATH" cp target/wasm32-wasip1/release/trusted-server-adapter-fastly.wasm "$WASM_ARTIFACT_PATH" cp target/debug/trusted-server-axum "$AXUM_ARTIFACT_PATH" + cp -r crates/trusted-server-adapter-cloudflare/build/. "$CF_BUILD_ARTIFACT_PATH/" docker save \ --output "$DOCKER_ARTIFACT_PATH" \ test-wordpress:latest test-nextjs:latest @@ -51,7 +54,7 @@ jobs: name: integration tests needs: prepare-artifacts runs-on: ubuntu-latest - timeout-minutes: 15 + timeout-minutes: 20 steps: - uses: actions/checkout@v4 @@ -63,6 +66,7 @@ jobs: check-dependency-versions: "false" install-viceroy: "true" build-wasm: "false" + build-axum: "false" build-test-images: "false" - name: Download integration test artifacts @@ -74,18 +78,34 @@ jobs: - name: Make binaries executable run: chmod +x "$AXUM_ARTIFACT_PATH" + - name: Restore Cloudflare Workers bundle + run: | + mkdir -p crates/trusted-server-adapter-cloudflare/build + cp -r "$CF_BUILD_ARTIFACT_PATH/." crates/trusted-server-adapter-cloudflare/build/ + - name: Load integration test Docker images run: docker load --input "$DOCKER_ARTIFACT_PATH" + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: ${{ steps.shared-setup.outputs.node-version }} + + - name: Install wrangler + run: npm install -g wrangler + - name: Run integration tests run: >- cargo test --manifest-path crates/integration-tests/Cargo.toml --target x86_64-unknown-linux-gnu - -- --include-ignored --skip test_wordpress_fastly --skip test_nextjs_fastly --test-threads=1 + -- --include-ignored + --skip test_wordpress_fastly --skip test_nextjs_fastly + --test-threads=1 env: WASM_BINARY_PATH: ${{ env.WASM_ARTIFACT_PATH }} AXUM_BINARY_PATH: ${{ env.AXUM_ARTIFACT_PATH }} + CLOUDFLARE_WRANGLER_DIR: ${{ github.workspace }}/crates/trusted-server-adapter-cloudflare INTEGRATION_ORIGIN_PORT: ${{ env.ORIGIN_PORT }} RUST_LOG: info diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 950aee406..1c88a751b 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -7,7 +7,7 @@ on: push: branches: [main] pull_request: - branches: [main] + branches: [main, "feature/**"] jobs: test-rust: @@ -85,6 +85,33 @@ jobs: TRUSTED_SERVER__PROXY__CERTIFICATE_CHECK: "false" run: cargo build --package trusted-server-adapter-fastly --release --target wasm32-wasip1 + test-cloudflare: + name: cargo check (cloudflare native + wasm32-unknown-unknown) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Retrieve Rust version + id: rust-version + run: echo "rust-version=$(grep '^rust ' .tool-versions | awk '{print $2}')" >> $GITHUB_OUTPUT + shell: bash + + - name: Set up Rust toolchain (native + wasm32-unknown-unknown) + uses: actions-rust-lang/setup-rust-toolchain@v1 + with: + toolchain: ${{ steps.rust-version.outputs.rust-version }} + target: wasm32-unknown-unknown + cache-shared-key: cargo-${{ runner.os }} + + - name: Check Cloudflare adapter (native host) + run: cargo check -p trusted-server-adapter-cloudflare + + - name: Check Cloudflare adapter (wasm32-unknown-unknown) + run: cargo check -p trusted-server-adapter-cloudflare --target wasm32-unknown-unknown --features cloudflare + + - name: Run Cloudflare adapter tests (native host) + run: cargo test-cloudflare + test-typescript: name: vitest runs-on: ubuntu-latest diff --git a/AGENTS.md b/AGENTS.md index 8aaf5cf7c..5218060f7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -20,8 +20,9 @@ If you cannot read `CLAUDE.md`, follow these rules: 3. Run tests after every code change — use the workspace aliases defined in `.cargo/config.toml`: - `cargo test-fastly` — Fastly adapter + core (wasm32-wasip1 via Viceroy) - `cargo test-axum` — Axum dev server adapter (native) + - `cargo test-cloudflare` — Cloudflare Workers adapter (native host) Do NOT use bare `cargo test --workspace` — it will attempt to compile the Fastly adapter for the host target. -4. Run `cargo fmt --all -- --check` and `cargo clippy-fastly && cargo clippy-axum`. +4. Run `cargo fmt --all -- --check` and `cargo clippy-fastly && cargo clippy-axum && cargo clippy-cloudflare`. 5. Run JS tests with `cd crates/js/lib && npx vitest run` when touching JS/TS code. 6. Use `error-stack` (`Report`) for error handling — not anyhow, eyre, or thiserror. 7. Use `log` macros (not `println!`) and `expect("should ...")` (not `unwrap()`). diff --git a/CLAUDE.md b/CLAUDE.md index db8585e93..bc3c08428 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -16,6 +16,7 @@ crates/ trusted-server-core/ # Core library — shared logic, integrations, HTML processing trusted-server-adapter-fastly/ # Fastly Compute entry point (wasm32-wasip1 binary) trusted-server-adapter-axum/ # Axum dev server entry point (native binary) + trusted-server-adapter-cloudflare/ # Cloudflare Workers entry point (wasm32-unknown-unknown binary) js/ # TypeScript/JS build — per-integration IIFE bundles lib/ # TS source, Vitest tests, esbuild pipeline ``` @@ -40,7 +41,7 @@ Supporting files: `fastly.toml`, `trusted-server.toml`, `.env.dev`, ```bash # Build (per-target aliases — bare `cargo build` fails at the workspace root) -cargo build-fastly && cargo build-axum +cargo build-fastly && cargo build-axum && cargo build-cloudflare # Production build for Fastly cargo build --package trusted-server-adapter-fastly --release --target wasm32-wasip1 @@ -56,6 +57,15 @@ cargo run -p trusted-server-adapter-axum # Test Axum adapter only cargo test-axum + +# Check Cloudflare adapter (native) +cargo check -p trusted-server-adapter-cloudflare + +# Check Cloudflare adapter (WASM target — alias for the full command) +cargo check-cloudflare + +# Test Cloudflare adapter (native host) +cargo test-cloudflare ``` ### Testing & Quality @@ -63,17 +73,18 @@ cargo test-axum ```bash # Run all Rust tests — use workspace aliases (see .cargo/config.toml) # default-members = [fastly] so Viceroy can locate the binary via `cargo run --bin`. -cargo test-fastly # Fastly adapter + core (wasm32-wasip1 via Viceroy) -cargo test-axum # Axum dev server adapter (native) +cargo test-fastly # Fastly adapter + core (wasm32-wasip1 via Viceroy) +cargo test-axum # Axum dev server adapter (native) +cargo test-cloudflare # Cloudflare Workers adapter (native host) # Format cargo fmt --all -- --check # Lint -cargo clippy-fastly && cargo clippy-axum +cargo clippy-fastly && cargo clippy-axum && cargo clippy-cloudflare # Check compilation (per-target aliases — bare `cargo check` fails at the workspace root) -cargo check-fastly && cargo check-axum +cargo check-fastly && cargo check-axum && cargo check-cloudflare # JS tests cd crates/js/lib && npx vitest run diff --git a/Cargo.lock b/Cargo.lock index c40c463ae..18e79cf67 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -902,6 +902,24 @@ dependencies = [ "tracing", ] +[[package]] +name = "edgezero-adapter-cloudflare" +version = "0.1.0" +source = "git+https://github.com/stackpop/edgezero?rev=38198f9839b70aef03ab971ae5876982773fc2a1#38198f9839b70aef03ab971ae5876982773fc2a1" +dependencies = [ + "anyhow", + "async-trait", + "brotli", + "bytes", + "edgezero-core", + "flate2", + "futures", + "futures-util", + "log", + "serde_json", + "worker", +] + [[package]] name = "edgezero-adapter-fastly" version = "0.1.0" @@ -1958,6 +1976,12 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" +[[package]] +name = "matchit" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94" + [[package]] name = "matchit" version = "0.8.4" @@ -2937,6 +2961,17 @@ dependencies = [ "typeid", ] +[[package]] +name = "serde-wasm-bindgen" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8302e169f0eddcc139c70f139d19d6467353af16f9fce27e8c30158036a1e16b" +dependencies = [ + "js-sys", + "serde", + "wasm-bindgen", +] + [[package]] name = "serde_core" version = "1.0.228" @@ -3536,6 +3571,23 @@ dependencies = [ "trusted-server-core", ] +[[package]] +name = "trusted-server-adapter-cloudflare" +version = "0.1.0" +dependencies = [ + "async-trait", + "bytes", + "edgezero-adapter-cloudflare", + "edgezero-core", + "error-stack", + "js-sys", + "log", + "tokio", + "trusted-server-core", + "trusted-server-js", + "worker", +] + [[package]] name = "trusted-server-adapter-fastly" version = "0.1.0" @@ -3602,6 +3654,7 @@ dependencies = [ "urlencoding", "uuid", "validator", + "web-time", ] [[package]] @@ -3872,6 +3925,19 @@ dependencies = [ "wasmparser", ] +[[package]] +name = "wasm-streams" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1ec4f6517c9e11ae630e200b2b65d193279042e28edd4a2cda233e46670bbb" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + [[package]] name = "wasmparser" version = "0.244.0" @@ -4270,6 +4336,64 @@ dependencies = [ "wasmparser", ] +[[package]] +name = "worker" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7267f3baa986254a8dace6f6a7c6ab88aef59f00c03aaad6749e048b5faaf6f6" +dependencies = [ + "async-trait", + "bytes", + "chrono", + "futures-channel", + "futures-util", + "http", + "http-body", + "js-sys", + "matchit 0.7.3", + "pin-project", + "serde", + "serde-wasm-bindgen", + "serde_json", + "serde_urlencoded", + "tokio", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams", + "web-sys", + "worker-macros", + "worker-sys", +] + +[[package]] +name = "worker-macros" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7410081121531ec2fa111ab17b911efc601d7b6d590c0a92b847874ebeff0030" +dependencies = [ + "async-trait", + "proc-macro2", + "quote", + "syn 2.0.117", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-bindgen-macro-support", + "worker-sys", +] + +[[package]] +name = "worker-sys" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4777582bf8a04174a034cb336f3702eb0e5cb444a67fdaa4fd44454ff7e2dd95" +dependencies = [ + "cfg-if", + "js-sys", + "wasm-bindgen", + "web-sys", +] + [[package]] name = "writeable" version = "0.6.3" diff --git a/Cargo.toml b/Cargo.toml index 0fa1a82a7..26780e6c7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,6 +4,7 @@ members = [ "crates/trusted-server-core", "crates/trusted-server-adapter-fastly", "crates/trusted-server-adapter-axum", + "crates/trusted-server-adapter-cloudflare", "crates/js", "crates/openrtb", ] @@ -75,6 +76,7 @@ hmac = "0.12.1" http = "1.4.0" iab_gpp = "0.1" jose-jwk = "0.1.2" +js-sys = "0.3" log = "0.4.29" log-fastly = "0.11.12" lol_html = "2.7.2" @@ -97,5 +99,6 @@ url = "2.5.8" urlencoding = "2.1" uuid = { version = "1.18", features = ["v4"] } validator = { version = "0.20", features = ["derive"] } +web-time = "1" which = "8" criterion = { version = "0.5", default-features = false, features = ["cargo_bench_support"] } diff --git a/README.md b/README.md index 0c58d173b..5e66dae0c 100644 --- a/README.md +++ b/README.md @@ -22,11 +22,10 @@ The guide in `docs/guide/` (published at the link below) is the source of truth See the [Getting Started guide](https://iabtechlab.github.io/trusted-server/guide/getting-started) for installation and setup instructions. ```bash -# Build Axum dev server (native) -cargo build -p trusted-server-adapter-axum - -# Build Fastly adapter (WASM) -cargo build -p trusted-server-adapter-fastly --target wasm32-wasip1 +# Build per adapter (target-matched aliases from .cargo/config.toml) +cargo build-fastly # Fastly adapter + core (wasm32-wasip1) +cargo build-axum # Axum dev server (native) +cargo build-cloudflare # Cloudflare Workers (wasm32-unknown-unknown) # Run tests (Fastly/WASM crates — requires Viceroy) cargo test-fastly @@ -34,6 +33,9 @@ cargo test-fastly # Run tests (Axum native adapter) cargo test-axum +# Run tests (Cloudflare Workers adapter — native host) +cargo test-cloudflare + # Start local server — Axum (no Fastly CLI or Viceroy required) cargo run -p trusted-server-adapter-axum @@ -48,11 +50,12 @@ fastly compute serve cargo fmt # Lint -cargo clippy-fastly && cargo clippy-axum +cargo clippy-fastly && cargo clippy-axum && cargo clippy-cloudflare # Run all tests -cargo test-fastly # Fastly/WASM (requires Viceroy) -cargo test-axum # Axum native adapter +cargo test-fastly # Fastly/WASM (requires Viceroy) +cargo test-axum # Axum native adapter +cargo test-cloudflare # Cloudflare Workers adapter (native host) ``` See [CONTRIBUTING.md](CONTRIBUTING.md) for contribution guidelines. diff --git a/crates/integration-tests/Cargo.lock b/crates/integration-tests/Cargo.lock index 74aeba233..e5631f64e 100644 --- a/crates/integration-tests/Cargo.lock +++ b/crates/integration-tests/Cargo.lock @@ -1868,6 +1868,7 @@ dependencies = [ "derive_more 2.1.1", "env_logger", "error-stack", + "libc", "log", "reqwest", "scraper", @@ -4010,6 +4011,7 @@ dependencies = [ "urlencoding", "uuid", "validator", + "web-time", ] [[package]] diff --git a/crates/integration-tests/Cargo.toml b/crates/integration-tests/Cargo.toml index fe393d3b9..5ac378ca6 100644 --- a/crates/integration-tests/Cargo.toml +++ b/crates/integration-tests/Cargo.toml @@ -19,4 +19,5 @@ serde_json = "1.0.149" error-stack = "0.6" derive_more = { version = "2.0", features = ["display"] } env_logger = "0.11" +libc = "0.2" urlencoding = "2.1" diff --git a/crates/integration-tests/tests/environments/cloudflare.rs b/crates/integration-tests/tests/environments/cloudflare.rs new file mode 100644 index 000000000..c1cc85def --- /dev/null +++ b/crates/integration-tests/tests/environments/cloudflare.rs @@ -0,0 +1,158 @@ +use crate::common::runtime::{ + RuntimeEnvironment, RuntimeProcess, RuntimeProcessHandle, TestError, TestResult, +}; +use error_stack::ResultExt as _; +use std::io::{BufRead as _, BufReader}; +use std::path::{Path, PathBuf}; +use std::process::{Child, Command, Stdio}; + +/// Cloudflare Workers runtime via `wrangler dev`. +/// +/// In CI the bundle is pre-built and restored from artifacts; wrangler is +/// installed in the job. Locally, build the bundle first: +/// +/// ```sh +/// cd crates/trusted-server-adapter-cloudflare && bash build.sh +/// ``` +/// +/// Then run the ignored tests with `-- --ignored test_wordpress_cloudflare`. +/// +/// Set `CLOUDFLARE_WRANGLER_DIR` to override the default crate root path. +pub struct CloudflareWorkers; + +/// Fallback port when dynamic allocation fails. +const CLOUDFLARE_DEFAULT_PORT: u16 = 8787; + +impl RuntimeEnvironment for CloudflareWorkers { + fn id(&self) -> &'static str { + "cloudflare" + } + + fn spawn(&self, _wasm_path: &Path) -> TestResult { + let wrangler_dir = self.wrangler_dir(); + let config = if std::env::var("CI").is_ok() { + "wrangler.ci.toml" + } else { + "wrangler.toml" + }; + + let port = super::find_available_port().unwrap_or(CLOUDFLARE_DEFAULT_PORT); + + #[cfg(unix)] + let child = { + use std::os::unix::process::CommandExt as _; + Command::new("wrangler") + .args([ + "dev", + "--config", + config, + "--port", + &port.to_string(), + "--ip", + "127.0.0.1", + ]) + .current_dir(&wrangler_dir) + .stdout(Stdio::null()) + .stderr(Stdio::piped()) + .process_group(0) + .spawn() + .change_context(TestError::RuntimeSpawn) + .attach(format!( + "Failed to spawn `wrangler dev` in {}. \ + Ensure wrangler is installed (`npm install -g wrangler`) \ + and the bundle is pre-built (`bash build.sh` in that directory).", + wrangler_dir.display() + ))? + }; + + #[cfg(not(unix))] + let child = Command::new("wrangler") + .args([ + "dev", + "--config", + config, + "--port", + &port.to_string(), + "--ip", + "127.0.0.1", + ]) + .current_dir(&wrangler_dir) + .stdout(Stdio::null()) + .stderr(Stdio::piped()) + .spawn() + .change_context(TestError::RuntimeSpawn) + .attach(format!( + "Failed to spawn `wrangler dev` in {}. \ + Ensure wrangler is installed (`npm install -g wrangler`) \ + and the bundle is pre-built (`bash build.sh` in that directory).", + wrangler_dir.display() + ))?; + + let mut child = child; + if let Some(stderr) = child.stderr.take() { + std::thread::spawn(move || { + let reader = BufReader::new(stderr); + for line in reader.lines().map_while(Result::ok) { + if !line.is_empty() { + log::debug!("cloudflare: {line}"); + } + } + }); + } + + let handle = CloudflareHandle { child }; + let base_url = format!("http://127.0.0.1:{port}"); + + super::wait_for_ready(&base_url, self.health_check_path(), true)?; + + Ok(RuntimeProcess { + inner: Box::new(handle), + base_url, + }) + } + + fn health_check_path(&self) -> &str { + "/.well-known/trusted-server.json" + } +} + +impl CloudflareWorkers { + /// Resolve the Cloudflare adapter crate root. + /// + /// Respects `CLOUDFLARE_WRANGLER_DIR` for CI overrides; falls back to + /// the path relative to this crate's `CARGO_MANIFEST_DIR`. + fn wrangler_dir(&self) -> PathBuf { + if let Ok(dir) = std::env::var("CLOUDFLARE_WRANGLER_DIR") { + return PathBuf::from(dir); + } + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("../../crates/trusted-server-adapter-cloudflare") + } +} + +struct CloudflareHandle { + child: Child, +} + +impl RuntimeProcessHandle for CloudflareHandle {} + +impl Drop for CloudflareHandle { + fn drop(&mut self) { + #[cfg(unix)] + { + // wrangler dev spawns workerd as a grandchild. Killing only the + // parent leaves workerd orphaned, holding the port and fds until + // the OS runner cleanup pass. Signal the whole process group so + // both wrangler and workerd are terminated together. + let pgid = self.child.id() as libc::pid_t; + unsafe { + libc::killpg(pgid, libc::SIGTERM); + } + } + #[cfg(not(unix))] + { + let _ = self.child.kill(); + } + let _ = self.child.wait(); + } +} diff --git a/crates/integration-tests/tests/environments/mod.rs b/crates/integration-tests/tests/environments/mod.rs index c3797e20c..41b3d69c0 100644 --- a/crates/integration-tests/tests/environments/mod.rs +++ b/crates/integration-tests/tests/environments/mod.rs @@ -1,4 +1,5 @@ pub mod axum; +pub mod cloudflare; pub mod fastly; use crate::common::runtime::{RuntimeEnvironment, TestError, TestResult}; @@ -22,6 +23,7 @@ type RuntimeFactory = fn() -> Box; pub static RUNTIME_ENVIRONMENTS: &[RuntimeFactory] = &[ || Box::new(fastly::FastlyViceroy), || Box::new(axum::AxumDevServer), + || Box::new(cloudflare::CloudflareWorkers), ]; /// Readiness polling configuration for runtimes and frontend containers. diff --git a/crates/integration-tests/tests/integration.rs b/crates/integration-tests/tests/integration.rs index 95e2abf4c..a56e540db 100644 --- a/crates/integration-tests/tests/integration.rs +++ b/crates/integration-tests/tests/integration.rs @@ -136,6 +136,22 @@ fn test_nextjs_fastly() { test_combination(&runtime, &framework).expect("should pass Next.js on Fastly"); } +#[test] +#[ignore = "requires Docker, the `wrangler` CLI in $PATH, and a prebuilt Cloudflare Workers bundle (run build.sh first); the test starts `wrangler dev` automatically"] +fn test_wordpress_cloudflare() { + let runtime = environments::cloudflare::CloudflareWorkers; + let framework = frameworks::wordpress::WordPress; + test_combination(&runtime, &framework).expect("should pass WordPress on Cloudflare Workers"); +} + +#[test] +#[ignore = "requires Docker, the `wrangler` CLI in $PATH, and a prebuilt Cloudflare Workers bundle (run build.sh first); the test starts `wrangler dev` automatically"] +fn test_nextjs_cloudflare() { + let runtime = environments::cloudflare::CloudflareWorkers; + let framework = frameworks::nextjs::NextJs; + test_combination(&runtime, &framework).expect("should pass Next.js on Cloudflare Workers"); +} + #[test] #[ignore = "requires Docker and pre-built trusted-server-axum binary"] fn test_wordpress_axum() { diff --git a/crates/trusted-server-adapter-cloudflare/.gitignore b/crates/trusted-server-adapter-cloudflare/.gitignore new file mode 100644 index 000000000..f3cade266 --- /dev/null +++ b/crates/trusted-server-adapter-cloudflare/.gitignore @@ -0,0 +1,6 @@ +target/ +build/ +.edgezero/ +.wrangler/ +.env.cloudflare.dev +.dev.vars diff --git a/crates/trusted-server-adapter-cloudflare/Cargo.toml b/crates/trusted-server-adapter-cloudflare/Cargo.toml new file mode 100644 index 000000000..0cc3a0c41 --- /dev/null +++ b/crates/trusted-server-adapter-cloudflare/Cargo.toml @@ -0,0 +1,38 @@ +[package] +name = "trusted-server-adapter-cloudflare" +version = "0.1.0" +edition = "2024" +publish = false + +[lints] +workspace = true + +[lib] +name = "trusted_server_adapter_cloudflare" +path = "src/lib.rs" +crate-type = ["cdylib", "rlib"] + +[features] +default = [] +# Keep for explicit `cargo check --features cloudflare --target wasm32-unknown-unknown` +cloudflare = ["edgezero-adapter-cloudflare/cloudflare", "dep:worker"] + +[dependencies] +async-trait = { workspace = true } +bytes = { workspace = true } +edgezero-adapter-cloudflare = { workspace = true } +edgezero-core = { workspace = true } +error-stack = { workspace = true } +log = { workspace = true } +trusted-server-core = { path = "../trusted-server-core" } +trusted-server-js = { path = "../js" } +worker = { version = "0.7", default-features = false, features = ["http"], optional = true } + +[target.'cfg(target_arch = "wasm32")'.dependencies] +edgezero-adapter-cloudflare = { workspace = true, features = ["cloudflare"] } +js-sys = { workspace = true } +worker = { version = "0.7", default-features = false, features = ["http"] } + +[dev-dependencies] +edgezero-core = { workspace = true } +tokio = { workspace = true, features = ["rt-multi-thread", "macros"] } diff --git a/crates/trusted-server-adapter-cloudflare/build.sh b/crates/trusted-server-adapter-cloudflare/build.sh new file mode 100644 index 000000000..bede195ce --- /dev/null +++ b/crates/trusted-server-adapter-cloudflare/build.sh @@ -0,0 +1,21 @@ +#!/usr/bin/env bash +# Source nvm so cargo's build.rs subprocess inherits the native arm64 Node. +# Without this, bash -lc tasks find the Rosetta x64 system node, which causes +# rollup to look for @rollup/rollup-darwin-x64 instead of the arm64 binary. +export NVM_DIR="$HOME/.nvm" +[ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh" + +# Source the root .env (same values used by the Fastly and Axum dev tasks). +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +ROOT_ENV="$SCRIPT_DIR/../../.env" +[ -f "$ROOT_ENV" ] && set -a && source "$ROOT_ENV" && set +a + +# Allow cloudflare-specific overrides on top (not committed). +# Copy .env.cloudflare.dev.example → .env.cloudflare.dev to customise. +[ -f "$SCRIPT_DIR/.env.cloudflare.dev" ] && . "$SCRIPT_DIR/.env.cloudflare.dev" + +# worker-build must run from the crate root (where Cargo.toml lives) regardless +# of which directory wrangler was invoked from. +cd "$SCRIPT_DIR" +command -v worker-build >/dev/null 2>&1 || cargo install -q --version '^0.7' worker-build +worker-build --release diff --git a/crates/trusted-server-adapter-cloudflare/cloudflare.toml b/crates/trusted-server-adapter-cloudflare/cloudflare.toml new file mode 100644 index 000000000..f505e6b2f --- /dev/null +++ b/crates/trusted-server-adapter-cloudflare/cloudflare.toml @@ -0,0 +1,23 @@ +[app] +name = "trusted-server" +version = "0.1.0" +kind = "http" + +[adapters.cloudflare] + +[stores.kv] +name = "trusted_server_kv" + +[stores.kv.adapters.cloudflare] +name = "TRUSTED_SERVER_KV" + +[stores.config] +name = "trusted_server_config" + +[stores.config.adapters.cloudflare] +name = "TRUSTED_SERVER_CONFIG" + +[stores.secrets] +name = "trusted_server_secrets" +[stores.secrets.adapters.cloudflare] +enabled = true diff --git a/crates/trusted-server-adapter-cloudflare/src/app.rs b/crates/trusted-server-adapter-cloudflare/src/app.rs new file mode 100644 index 000000000..567e14dae --- /dev/null +++ b/crates/trusted-server-adapter-cloudflare/src/app.rs @@ -0,0 +1,329 @@ +use core::future::Future; +use core::pin::Pin; +use std::sync::Arc; + +use edgezero_core::app::Hooks; +use edgezero_core::context::RequestContext; +use edgezero_core::error::EdgeError; +use edgezero_core::http::{HeaderValue, Request, Response, header}; +use edgezero_core::router::RouterService; +use error_stack::Report; +use trusted_server_core::auction::endpoints::handle_auction; +use trusted_server_core::auction::{AuctionOrchestrator, build_orchestrator}; +use trusted_server_core::ec::EcContext; +use trusted_server_core::error::{IntoHttpResponse as _, TrustedServerError}; +use trusted_server_core::integrations::{IntegrationRegistry, ProxyDispatchInput}; +use trusted_server_core::platform::RuntimeServices; +use trusted_server_core::proxy::{ + handle_first_party_click, handle_first_party_proxy, handle_first_party_proxy_rebuild, + handle_first_party_proxy_sign, +}; +use trusted_server_core::publisher::{ + PublisherResponse, buffer_publisher_response, handle_publisher_request, handle_tsjs_dynamic, +}; +use trusted_server_core::request_signing::{ + handle_deactivate_key, handle_rotate_key, handle_trusted_server_discovery, + handle_verify_signature, +}; +use trusted_server_core::settings::Settings; +use trusted_server_core::settings_data::get_settings; + +use crate::middleware::{AuthMiddleware, FinalizeResponseMiddleware}; +use crate::platform::build_runtime_services; + +// --------------------------------------------------------------------------- +// AppState +// --------------------------------------------------------------------------- + +/// Application state built once at startup and shared across all requests. +pub struct AppState { + settings: Arc, + orchestrator: Arc, + registry: Arc, +} + +/// Build the application state, loading settings and constructing all per-application components. +/// +/// # Errors +/// +/// Returns an error when settings, the auction orchestrator, or the integration +/// registry fail to initialise. +fn build_state() -> Result, Report> { + let settings = get_settings()?; + let orchestrator = build_orchestrator(&settings)?; + let registry = IntegrationRegistry::new(&settings)?; + + Ok(Arc::new(AppState { + settings: Arc::new(settings), + orchestrator: Arc::new(orchestrator), + registry: Arc::new(registry), + })) +} + +// --------------------------------------------------------------------------- +// Per-request RuntimeServices +// --------------------------------------------------------------------------- + +fn build_per_request_services(ctx: &RequestContext) -> RuntimeServices { + build_runtime_services(ctx) +} + +// --------------------------------------------------------------------------- +// Handler factory +// --------------------------------------------------------------------------- + +/// Wraps a core handler function in the standard request-scoped boilerplate: +/// build `RuntimeServices`, extract the `Request`, invoke the handler, and +/// convert any error into an HTTP error response. +/// +/// Accepts both sync (`|s, svc, req| { ... }`) and async +/// (`|s, svc, req| async move { ... }`) closures. +type BoxedHandlerFuture = Pin>>>; + +fn make_handler( + state: Arc, + f: F, +) -> impl Fn(RequestContext) -> BoxedHandlerFuture + Clone + 'static +where + F: Fn(Arc, RuntimeServices, Request) -> Fut + Clone + 'static, + Fut: Future>> + 'static, +{ + move |ctx: RequestContext| { + let s = Arc::clone(&state); + let f = f.clone(); + Box::pin(async move { + let services = build_per_request_services(&ctx); + let req = ctx.into_request(); + Ok(f(s, services, req).await.unwrap_or_else(|e| http_error(&e))) + }) + } +} + +// --------------------------------------------------------------------------- +// Publisher response helper +// --------------------------------------------------------------------------- + +/// Collapse a [`PublisherResponse`] into a plain [`Response`]. +/// +/// Delegates to the shared [`buffer_publisher_response`], which enforces +/// `settings.publisher.max_buffered_body_bytes`, then removes any +/// `Transfer-Encoding` header since the buffered body is no longer chunked. +fn resolve_publisher_response( + publisher_response: PublisherResponse, + settings: &Settings, + registry: &IntegrationRegistry, +) -> Result> { + let mut response = buffer_publisher_response(publisher_response, settings, registry)?; + response.headers_mut().remove(header::TRANSFER_ENCODING); + Ok(response) +} + +// --------------------------------------------------------------------------- +// Error helper +// --------------------------------------------------------------------------- + +/// Convert a [`Report`] into an HTTP [`Response`]. +pub(crate) fn http_error(report: &Report) -> Response { + let root_error = report.current_context(); + log::error!("Error occurred: {:?}", report); + + let body = edgezero_core::body::Body::from(format!("{}\n", root_error.user_message())); + let mut response = Response::new(body); + *response.status_mut() = root_error.status_code(); + response.headers_mut().insert( + header::CONTENT_TYPE, + HeaderValue::from_static("text/plain; charset=utf-8"), + ); + response +} + +// --------------------------------------------------------------------------- +// Startup error fallback +// --------------------------------------------------------------------------- + +/// Returns a [`RouterService`] that responds to every route with the startup error. +fn startup_error_router(e: &Report) -> RouterService { + let message = Arc::new(format!("{}\n", e.current_context().user_message())); + let status = e.current_context().status_code(); + + let make = move |msg: Arc| { + move |_ctx: RequestContext| { + let body = edgezero_core::body::Body::from((*msg).clone()); + let mut resp = Response::new(body); + *resp.status_mut() = status; + resp.headers_mut().insert( + header::CONTENT_TYPE, + HeaderValue::from_static("text/plain; charset=utf-8"), + ); + async move { Ok::(resp) } + } + }; + + RouterService::builder() + .middleware(FinalizeResponseMiddleware::new(Arc::new( + Settings::default(), + ))) + .get("/", make(Arc::clone(&message))) + .post("/", make(Arc::clone(&message))) + .get("/{*rest}", make(Arc::clone(&message))) + .post("/{*rest}", make(Arc::clone(&message))) + .build() +} + +// --------------------------------------------------------------------------- +// TrustedServerApp +// --------------------------------------------------------------------------- + +/// `EdgeZero` [`Hooks`] implementation for the Trusted Server application. +pub struct TrustedServerApp; + +impl Hooks for TrustedServerApp { + fn name() -> &'static str { + "TrustedServer" + } + + fn routes() -> RouterService { + let state = match build_state() { + Ok(s) => s, + Err(ref e) => { + log::error!("failed to build application state: {:?}", e); + return startup_error_router(e); + } + }; + + // Shared fallback dispatch: routes to tsjs (GET only), integration proxy, or publisher. + async fn dispatch( + state: Arc, + ctx: RequestContext, + allow_tsjs: bool, + ) -> Result { + let services = build_per_request_services(&ctx); + let req = ctx.into_request(); + let path = req.uri().path().to_owned(); + let method = req.method().clone(); + + let result = if allow_tsjs && path.starts_with("/static/tsjs=") { + handle_tsjs_dynamic(&req, &state.registry) + } else if state.registry.has_route(&method, &path) { + let mut ec_context = EcContext::default(); + state + .registry + .handle_proxy(ProxyDispatchInput { + method: &method, + path: &path, + settings: &state.settings, + kv: None, + ec_context: &mut ec_context, + services: &services, + req, + }) + .await + .unwrap_or_else(|| { + Err(Report::new(TrustedServerError::BadRequest { + message: format!("Unknown integration route: {path}"), + })) + }) + } else { + handle_publisher_request(&state.settings, &state.registry, &services, req) + .await + .and_then(|pr| resolve_publisher_response(pr, &state.settings, &state.registry)) + }; + + Ok(result.unwrap_or_else(|e| http_error(&e))) + } + + let get_fallback = { + let s = Arc::clone(&state); + move |ctx: RequestContext| { + let s = Arc::clone(&s); + dispatch(s, ctx, true) + } + }; + let post_fallback = { + let s = Arc::clone(&state); + move |ctx: RequestContext| { + let s = Arc::clone(&s); + dispatch(s, ctx, false) + } + }; + + RouterService::builder() + .middleware(FinalizeResponseMiddleware::new(Arc::clone(&state.settings))) + .middleware(AuthMiddleware::new(Arc::clone(&state.settings))) + .get( + "/.well-known/trusted-server.json", + make_handler(Arc::clone(&state), |s, services, req| async move { + handle_trusted_server_discovery(&s.settings, &services, req) + }), + ) + .post( + "/verify-signature", + make_handler(Arc::clone(&state), |s, services, req| async move { + handle_verify_signature(&s.settings, &services, req) + }), + ) + .post( + "/admin/keys/rotate", + make_handler(Arc::clone(&state), |s, services, req| async move { + handle_rotate_key(&s.settings, &services, req) + }), + ) + .post( + "/admin/keys/deactivate", + make_handler(Arc::clone(&state), |s, services, req| async move { + handle_deactivate_key(&s.settings, &services, req) + }), + ) + .post( + "/auction", + make_handler(Arc::clone(&state), |s, services, req| async move { + let ec_context = EcContext::default(); + handle_auction( + &s.settings, + &s.orchestrator, + None, + None, + &ec_context, + &services, + req, + ) + .await + }), + ) + .get( + "/first-party/proxy", + make_handler(Arc::clone(&state), |s, services, req| async move { + handle_first_party_proxy(&s.settings, &services, req).await + }), + ) + .get( + "/first-party/click", + make_handler(Arc::clone(&state), |s, services, req| async move { + handle_first_party_click(&s.settings, &services, req).await + }), + ) + .get( + "/first-party/sign", + make_handler(Arc::clone(&state), |s, services, req| async move { + handle_first_party_proxy_sign(&s.settings, &services, req).await + }), + ) + .post( + "/first-party/sign", + make_handler(Arc::clone(&state), |s, services, req| async move { + handle_first_party_proxy_sign(&s.settings, &services, req).await + }), + ) + .post( + "/first-party/proxy-rebuild", + make_handler(Arc::clone(&state), |s, services, req| async move { + handle_first_party_proxy_rebuild(&s.settings, &services, req).await + }), + ) + .get("/", get_fallback.clone()) + .post("/", post_fallback.clone()) + .get("/{*rest}", get_fallback) + .post("/{*rest}", post_fallback) + .build() + } +} diff --git a/crates/trusted-server-adapter-cloudflare/src/lib.rs b/crates/trusted-server-adapter-cloudflare/src/lib.rs new file mode 100644 index 000000000..4f1d33c03 --- /dev/null +++ b/crates/trusted-server-adapter-cloudflare/src/lib.rs @@ -0,0 +1,35 @@ +// The `cloudflare` feature activates the `worker` crate which requires +// wasm-bindgen and only compiles for `wasm32-unknown-unknown`. Enabling it on +// a native target produces cryptic linker errors — catch it early instead. +#[cfg(all(feature = "cloudflare", not(target_arch = "wasm32")))] +compile_error!( + "The `cloudflare` feature requires `--target wasm32-unknown-unknown`. \ + Run: cargo check -p trusted-server-adapter-cloudflare \ + --features cloudflare --target wasm32-unknown-unknown" +); + +pub mod app; +pub mod middleware; +pub mod platform; + +#[cfg(target_arch = "wasm32")] +use worker::{Context, Env, Request, Response, Result, event}; + +#[cfg(target_arch = "wasm32")] +#[event(fetch)] +pub async fn main(req: Request, env: Env, ctx: Context) -> Result { + match edgezero_adapter_cloudflare::run_app::( + include_str!("../cloudflare.toml"), + req, + env, + ctx, + ) + .await + { + Ok(resp) => Ok(resp), + Err(e) => { + log::error!("worker dispatch error: {e:?}"); + Response::error("internal server error", 500) + } + } +} diff --git a/crates/trusted-server-adapter-cloudflare/src/middleware.rs b/crates/trusted-server-adapter-cloudflare/src/middleware.rs new file mode 100644 index 000000000..3b60cae3f --- /dev/null +++ b/crates/trusted-server-adapter-cloudflare/src/middleware.rs @@ -0,0 +1,239 @@ +use std::sync::Arc; + +use async_trait::async_trait; +use edgezero_core::context::RequestContext; +use edgezero_core::error::EdgeError; +use edgezero_core::http::{HeaderName, HeaderValue, Response}; +use edgezero_core::middleware::{Middleware, Next}; +use trusted_server_core::auth::enforce_basic_auth; +use trusted_server_core::constants::HEADER_X_GEO_INFO_AVAILABLE; +use trusted_server_core::settings::Settings; + +// --------------------------------------------------------------------------- +// FinalizeResponseMiddleware +// --------------------------------------------------------------------------- + +/// Outermost middleware: injects all standard TS response headers. +/// +/// Geo availability is determined by the presence of the `cf-ipcountry` header +/// (injected by the Cloudflare Workers runtime). On the native host target the +/// header is absent, so `X-Geo-Info-Available: false` is emitted. +/// +/// Registered first in the middleware chain so that every outgoing response — +/// including auth-rejected ones — carries a consistent set of headers. +pub struct FinalizeResponseMiddleware { + settings: Arc, +} + +impl FinalizeResponseMiddleware { + /// Creates a new [`FinalizeResponseMiddleware`] with the given settings. + #[must_use] + pub fn new(settings: Arc) -> Self { + Self { settings } + } +} + +#[async_trait(?Send)] +impl Middleware for FinalizeResponseMiddleware { + async fn handle(&self, ctx: RequestContext, next: Next<'_>) -> Result { + let geo_available = ctx + .request() + .headers() + .get("cf-ipcountry") + .and_then(|v| v.to_str().ok()) + .filter(|s| !s.is_empty() && *s != "XX") + .is_some(); + + let mut response = next.run(ctx).await?; + apply_finalize_headers(&self.settings, geo_available, &mut response); + Ok(response) + } +} + +// --------------------------------------------------------------------------- +// AuthMiddleware +// --------------------------------------------------------------------------- + +/// Inner middleware: enforces basic-auth before the handler runs. +/// +/// - `Ok(Some(response))` from [`enforce_basic_auth`] → auth failed; return the +/// challenge response (bubbles through [`FinalizeResponseMiddleware`] for header injection). +/// - `Ok(None)` → no auth required or credentials accepted; continue the chain. +/// - `Err(report)` → internal error; log and convert to a 500 HTTP response. +pub struct AuthMiddleware { + settings: Arc, +} + +impl AuthMiddleware { + /// Creates a new [`AuthMiddleware`] with the given settings. + #[must_use] + pub fn new(settings: Arc) -> Self { + Self { settings } + } +} + +#[async_trait(?Send)] +impl Middleware for AuthMiddleware { + async fn handle(&self, ctx: RequestContext, next: Next<'_>) -> Result { + match enforce_basic_auth(&self.settings, ctx.request()) { + Ok(Some(response)) => return Ok(response), + Ok(None) => {} + Err(report) => { + log::error!("auth check failed: {:?}", report); + return Ok(crate::app::http_error(&report)); + } + } + + next.run(ctx).await + } +} + +// --------------------------------------------------------------------------- +// apply_finalize_headers — extracted for unit testing +// --------------------------------------------------------------------------- + +/// Applies standard Trusted Server response headers to the given response. +/// +/// `geo_available` controls `X-Geo-Info-Available`; pass `true` when +/// `cf-ipcountry` was present and non-`XX` in the incoming request. +/// Operator-configured `settings.response_headers` are applied last and can +/// override any managed header. +pub(crate) fn apply_finalize_headers( + settings: &Settings, + geo_available: bool, + response: &mut Response, +) { + response.headers_mut().insert( + HEADER_X_GEO_INFO_AVAILABLE, + HeaderValue::from_static(if geo_available { "true" } else { "false" }), + ); + + for (key, value) in &settings.response_headers { + let header_name = HeaderName::from_bytes(key.as_bytes()); + let header_value = HeaderValue::from_str(value); + if let (Ok(header_name), Ok(header_value)) = (header_name, header_value) { + response.headers_mut().insert(header_name, header_value); + } else { + log::warn!( + "Skipping invalid configured response header value for {}", + key + ); + } + } +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + + use edgezero_core::body::Body; + use edgezero_core::http::response_builder; + + fn empty_response() -> Response { + response_builder() + .body(Body::empty()) + .expect("should build empty test response") + } + + fn settings_with_response_headers(headers: Vec<(&str, &str)>) -> Settings { + // Build from explicit test settings: the settings baked into the + // binary contain placeholder secrets that `get_settings()` rejects + // by design. + let mut s = Settings::from_toml( + r#" + [[handlers]] + path = "^/_ts/admin" + username = "admin" + password = "admin-pass" + + [publisher] + domain = "test-publisher.example.com" + cookie_domain = ".test-publisher.example.com" + origin_url = "https://origin.test-publisher.example.com" + proxy_secret = "unit-test-proxy-secret" + + [ec] + passphrase = "test-secret-key-32-bytes-minimum" + "#, + ) + .expect("should load test settings"); + s.response_headers = headers + .into_iter() + .map(|(k, v)| (k.to_string(), v.to_string())) + .collect(); + s + } + + #[test] + fn sets_geo_available_false_when_no_country_header() { + let settings = settings_with_response_headers(vec![]); + let mut response = empty_response(); + + apply_finalize_headers(&settings, false, &mut response); + + assert_eq!( + response + .headers() + .get("x-geo-info-available") + .and_then(|v| v.to_str().ok()), + Some("false"), + "should set X-Geo-Info-Available: false when geo is unavailable" + ); + } + + #[test] + fn sets_geo_available_true_when_country_header_present() { + let settings = settings_with_response_headers(vec![]); + let mut response = empty_response(); + + apply_finalize_headers(&settings, true, &mut response); + + assert_eq!( + response + .headers() + .get("x-geo-info-available") + .and_then(|v| v.to_str().ok()), + Some("true"), + "should set X-Geo-Info-Available: true when cf-ipcountry is present" + ); + } + + #[test] + fn operator_response_headers_override_geo_header() { + let settings = + settings_with_response_headers(vec![("X-Geo-Info-Available", "operator-override")]); + let mut response = empty_response(); + + apply_finalize_headers(&settings, false, &mut response); + + assert_eq!( + response + .headers() + .get("x-geo-info-available") + .and_then(|v| v.to_str().ok()), + Some("operator-override"), + "should override the managed geo header with the operator-configured value" + ); + } + + #[test] + fn applies_custom_operator_headers() { + let settings = settings_with_response_headers(vec![("X-Custom-Header", "custom-value")]); + let mut response = empty_response(); + + apply_finalize_headers(&settings, false, &mut response); + + assert_eq!( + response + .headers() + .get("x-custom-header") + .and_then(|v| v.to_str().ok()), + Some("custom-value"), + "should apply operator-configured response headers" + ); + } +} diff --git a/crates/trusted-server-adapter-cloudflare/src/platform.rs b/crates/trusted-server-adapter-cloudflare/src/platform.rs new file mode 100644 index 000000000..c4f8e2d74 --- /dev/null +++ b/crates/trusted-server-adapter-cloudflare/src/platform.rs @@ -0,0 +1,751 @@ +use std::net::IpAddr; +use std::sync::Arc; +use std::time::Duration; + +use bytes::Bytes; +use edgezero_core::{ConfigStoreHandle, KvHandle, KvPage, KvStore}; +use error_stack::Report; +use trusted_server_core::platform::{ + ClientInfo, GeoInfo, KvError, PlatformBackend, PlatformBackendSpec, PlatformConfigStore, + PlatformError, PlatformGeo, PlatformHttpClient, PlatformKvStore, PlatformSecretStore, + RuntimeServices, StoreId, StoreName, UnavailableKvStore, +}; + +#[cfg(not(target_arch = "wasm32"))] +use trusted_server_core::platform::UnavailableHttpClient; + +#[cfg(target_arch = "wasm32")] +use error_stack::ResultExt as _; +#[cfg(target_arch = "wasm32")] +use trusted_server_core::platform::{ + PlatformHttpRequest, PlatformPendingRequest, PlatformResponse, PlatformSelectResult, +}; + +// --------------------------------------------------------------------------- +// Noop stubs — used when a handle is absent (native CI, missing binding) +// --------------------------------------------------------------------------- + +struct NoopConfigStore; + +impl PlatformConfigStore for NoopConfigStore { + fn get(&self, _: &StoreName, _: &str) -> Result> { + Err(Report::new(PlatformError::ConfigStore).attach("config store not available")) + } + + fn put(&self, _: &StoreId, _: &str, _: &str) -> Result<(), Report> { + Err(Report::new(PlatformError::ConfigStore).attach("config store not available")) + } + + fn delete(&self, _: &StoreId, _: &str) -> Result<(), Report> { + Err(Report::new(PlatformError::ConfigStore).attach("config store not available")) + } +} + +struct NoopSecretStore; + +impl PlatformSecretStore for NoopSecretStore { + fn get_bytes(&self, _: &StoreName, _: &str) -> Result, Report> { + Err(Report::new(PlatformError::SecretStore).attach("secret store not available")) + } + + fn create(&self, _: &StoreId, _: &str, _: &str) -> Result<(), Report> { + Err(Report::new(PlatformError::SecretStore).attach("secret store not available")) + } + + fn delete(&self, _: &StoreId, _: &str) -> Result<(), Report> { + Err(Report::new(PlatformError::SecretStore).attach("secret store not available")) + } +} + +struct NoopBackend; + +impl PlatformBackend for NoopBackend { + fn predict_name(&self, spec: &PlatformBackendSpec) -> Result> { + let port = spec + .port + .unwrap_or(if spec.scheme == "https" { 443 } else { 80 }); + let timeout_ms = spec.first_byte_timeout.as_millis(); + let cert_suffix = if spec.certificate_check { + "" + } else { + "_nocert" + }; + Ok(format!( + "{}_{}_{}_{timeout_ms}ms{cert_suffix}", + spec.scheme, spec.host, port + )) + } + + fn ensure(&self, spec: &PlatformBackendSpec) -> Result> { + self.predict_name(spec) + } +} + +// --------------------------------------------------------------------------- +// edgezero handle adapters — no #[cfg] needed; platform-specific store +// construction is handled by edgezero's run_app before we receive the ctx. +// --------------------------------------------------------------------------- + +/// Bridges edgezero's [`ConfigStoreHandle`] (injected by `run_app` from the +/// `TRUSTED_SERVER_CONFIG` env-var binding) to [`PlatformConfigStore`]. +/// +/// Reads delegate through the handle. Writes are unsupported on all current +/// adapter targets and return errors. +/// +/// Note: Cloudflare config is a single flat JSON env-var binding — all keys +/// live in one namespace. The `store_name` argument is intentionally ignored; +/// callers cannot route to a different store by passing a different name. +struct ConfigStoreHandleAdapter(ConfigStoreHandle); + +impl PlatformConfigStore for ConfigStoreHandleAdapter { + fn get(&self, _store_name: &StoreName, key: &str) -> Result> { + self.0 + .get(key) + .map_err(|e| { + Report::new(PlatformError::ConfigStore) + .attach(format!("config store lookup failed: {e}")) + })? + .ok_or_else(|| { + Report::new(PlatformError::ConfigStore).attach(format!("key not found: {key}")) + }) + } + + fn put(&self, _: &StoreId, _: &str, _: &str) -> Result<(), Report> { + Err(Report::new(PlatformError::ConfigStore).attach("config store writes are not supported")) + } + + fn delete(&self, _: &StoreId, _: &str) -> Result<(), Report> { + Err(Report::new(PlatformError::ConfigStore).attach("config store writes are not supported")) + } +} + +/// Bridges edgezero's [`KvHandle`] (injected by `run_app` from the +/// `TRUSTED_SERVER_KV` KV namespace binding) to [`PlatformKvStore`]. +/// +/// Delegates all operations through `KvHandle`'s raw-bytes API, which includes +/// key/value validation before forwarding to the underlying store. +/// +/// Note: key/value validation runs twice — once inside this `KvHandle` and once +/// inside the `KvHandle` that `RuntimeServices::kv_handle()` constructs from +/// this adapter. The overhead is negligible (string length checks only) and +/// avoided by the fact that we reuse the already-opened `env.kv()` handle from +/// `run_app` rather than opening a new one. +struct KvHandleAdapter(KvHandle); + +#[async_trait::async_trait(?Send)] +impl KvStore for KvHandleAdapter { + async fn get_bytes(&self, key: &str) -> Result, KvError> { + self.0.get_bytes(key).await + } + + async fn put_bytes(&self, key: &str, value: Bytes) -> Result<(), KvError> { + self.0.put_bytes(key, value).await + } + + async fn put_bytes_with_ttl( + &self, + key: &str, + value: Bytes, + ttl: Duration, + ) -> Result<(), KvError> { + self.0.put_bytes_with_ttl(key, value, ttl).await + } + + async fn delete(&self, key: &str) -> Result<(), KvError> { + self.0.delete(key).await + } + + async fn list_keys_page( + &self, + prefix: &str, + cursor: Option<&str>, + limit: usize, + ) -> Result { + self.0.list_keys_page(prefix, cursor, limit).await + } +} + +// --------------------------------------------------------------------------- +// CloudflareHttpClient — WASM target only +// --------------------------------------------------------------------------- + +/// Carries a completed response through `send_async` → `select`. +/// +/// Same pattern as `AxumPendingResponse`: stores raw parts because `Body::Stream` +/// is `!Send`, which is incompatible with `Box` inside +/// [`PlatformPendingRequest`]. +#[cfg(target_arch = "wasm32")] +struct CloudflarePendingResponse { + backend_name: String, + status: u16, + headers: Vec<(String, Vec)>, + body: Vec, +} + +/// [`worker::Fetch`]-backed HTTP client for the Cloudflare Workers runtime. +/// +/// # Multi-provider auction limitation +/// +/// `send_async` eagerly awaits each request before returning, so +/// [`PlatformHttpClient::supports_concurrent_fanout`] reports `false` and the +/// auction orchestrator rejects multi-provider configurations before any +/// request launches. `select` keeps a defense-in-depth rejection for more +/// than one pending request. Configure a single auction provider for +/// Cloudflare Workers, or use the Fastly adapter for parallel DSP fan-out. +/// +/// Per-provider timeouts baked into the backend name are not enforced at the +/// fetch layer; the Workers runtime's global CPU budget (~30 s on paid plans) +/// is the only implicit deadline. +#[cfg(target_arch = "wasm32")] +pub struct CloudflareHttpClient; + +#[cfg(target_arch = "wasm32")] +impl CloudflareHttpClient { + async fn execute( + &self, + request: PlatformHttpRequest, + ) -> Result> { + use worker::{Fetch, Headers, Method, Request, RequestInit}; + + let uri = request.request.uri().to_string(); + // http::Method always stores uppercase; worker 0.7 implements From only. + let method = Method::from(request.request.method().to_string()); + + let headers = Headers::new(); + for (name, value) in request.request.headers() { + let value_str = std::str::from_utf8(value.as_bytes()) + .change_context(PlatformError::HttpClient) + .attach_with(|| { + format!("non-UTF-8 bytes in outbound header `{name}` — value dropped") + })?; + // `append` rather than `set`: a request carrying the same header + // name more than once must forward every value, matching the + // response path which appends via `edge_builder.header(...)`. + headers + .append(name.as_str(), value_str) + .change_context(PlatformError::HttpClient)?; + } + + let (_, body) = request.request.into_parts(); + let body_bytes = match body { + edgezero_core::body::Body::Once(bytes) => bytes.to_vec(), + edgezero_core::body::Body::Stream(_) => { + return Err(Report::new(PlatformError::HttpClient) + .attach("streaming request bodies are not supported on Cloudflare Workers")); + } + }; + + let mut init = RequestInit::new(); + init.with_method(method).with_headers(headers); + if !body_bytes.is_empty() { + let uint8 = js_sys::Uint8Array::from(body_bytes.as_slice()); + init.with_body(Some(uint8.into())); + } + + let worker_req = + Request::new_with_init(&uri, &init).change_context(PlatformError::HttpClient)?; + + let mut resp = Fetch::Request(worker_req) + .send() + .await + .change_context(PlatformError::HttpClient) + .attach_with(|| format!("outbound request to {uri} failed"))?; + + let status = resp.status_code(); + let mut edge_builder = edgezero_core::http::response_builder().status(status); + for (name, value) in resp.headers().entries() { + // The Workers runtime auto-decompresses gzip/br/deflate and handles + // chunked transfer — strip these headers so the proxy layer does not + // attempt a second decompression pass on the already-decoded body. + // Content-Length is stripped too: the origin value describes the + // compressed payload, not the decoded bytes returned by `bytes()`, + // and forwarding it stale would truncate pass-through responses. + // The accurate length is set from the decoded body below. + if name.eq_ignore_ascii_case("content-encoding") + || name.eq_ignore_ascii_case("transfer-encoding") + || name.eq_ignore_ascii_case("content-length") + { + continue; + } + edge_builder = edge_builder.header(name.as_str(), value.as_bytes()); + } + let body_bytes = resp + .bytes() + .await + .change_context(PlatformError::HttpClient)?; + edge_builder = edge_builder.header( + edgezero_core::http::header::CONTENT_LENGTH, + body_bytes.len(), + ); + let edge_resp = edge_builder + .body(edgezero_core::body::Body::from(body_bytes)) + .change_context(PlatformError::HttpClient)?; + + Ok(PlatformResponse::new(edge_resp).with_backend_name(request.backend_name)) + } +} + +#[cfg(target_arch = "wasm32")] +#[async_trait::async_trait(?Send)] +impl PlatformHttpClient for CloudflareHttpClient { + async fn send( + &self, + request: PlatformHttpRequest, + ) -> Result> { + self.execute(request).await + } + + fn supports_concurrent_fanout(&self) -> bool { + // `send_async` executes each request eagerly, so multiple pending + // requests run sequentially. The auction orchestrator checks this + // before launching more than one provider request. + false + } + + async fn send_async( + &self, + request: PlatformHttpRequest, + ) -> Result> { + let backend_name = request.backend_name.clone(); + let response = self.execute(request).await?; + + let status = response.response.status().as_u16(); + let headers: Vec<(String, Vec)> = response + .response + .headers() + .iter() + .map(|(n, v)| (n.to_string(), v.as_bytes().to_vec())) + .collect(); + let body_bytes = match response.response.into_body() { + edgezero_core::body::Body::Once(bytes) => bytes.to_vec(), + // execute() always buffers via resp.bytes().await → Body::Once. + // Return a typed error rather than panicking in the request path + // in case that edgezero implementation detail ever changes. + edgezero_core::body::Body::Stream(_) => { + return Err(Report::new(PlatformError::HttpClient).attach( + "unexpected streaming body from CloudflareHttpClient::execute \ + — expected a buffered Body::Once", + )); + } + }; + + let pending = CloudflarePendingResponse { + backend_name: backend_name.clone(), + status, + headers, + body: body_bytes, + }; + Ok(PlatformPendingRequest::new(pending).with_backend_name(backend_name)) + } + + async fn select( + &self, + mut pending_requests: Vec, + ) -> Result> { + if pending_requests.is_empty() { + return Err(Report::new(PlatformError::HttpClient) + .attach("select called with an empty pending_requests list")); + } + + reject_multi_provider_fanout(pending_requests.len())?; + + let ready_platform = pending_requests.remove(0); + let pending = ready_platform + .downcast::() + .map_err(|_| { + Report::new(PlatformError::HttpClient) + .attach("unexpected inner type in CloudflareHttpClient::select") + })?; + + let mut builder = edgezero_core::http::response_builder().status(pending.status); + for (name, value) in &pending.headers { + builder = builder.header(name.as_str(), value.as_slice()); + } + let edge_resp = builder + .body(edgezero_core::body::Body::from(pending.body)) + .change_context(PlatformError::HttpClient)?; + + let ready = Ok(PlatformResponse::new(edge_resp).with_backend_name(pending.backend_name)); + Ok(PlatformSelectResult { + ready, + remaining: pending_requests, + failed_backend_name: None, + }) + } +} + +// --------------------------------------------------------------------------- +// CloudflareSecretStoreAdapter — WASM target only +// +// Secrets are the one platform surface that cannot be bridged through an +// edgezero handle: `SecretHandle::get_bytes` is async, but +// `PlatformSecretStore::get_bytes` is sync. The Cloudflare `env.secret()` +// call IS synchronous at the JS level, so we call it directly here. +// --------------------------------------------------------------------------- + +/// Bridges [`worker::Env`] secrets to [`PlatformSecretStore`] by calling +/// `env.secret(key)` synchronously. Writes and deletes return errors. +#[cfg(target_arch = "wasm32")] +struct CloudflareSecretStoreAdapter { + env: worker::Env, +} + +#[cfg(target_arch = "wasm32")] +impl PlatformSecretStore for CloudflareSecretStoreAdapter { + fn get_bytes( + &self, + _store_name: &StoreName, + key: &str, + ) -> Result, Report> { + match self.env.secret(key) { + // worker 0.7: Secret implements Display via JsValue::as_string() which + // returns the raw JS string value with no wrapping or debug formatting. + // Verified in worker-rs src/env.rs: `impl Display for Secret { fn fmt -> + // write!(f, "{}", self.inner.as_string().unwrap_or_default()) }`. + Ok(secret) => Ok(secret.to_string().into_bytes()), + Err(err) => Err(Report::new(PlatformError::SecretStore) + .attach(format!("secret lookup failed for key `{key}`: {err}"))), + } + } + + fn create(&self, _: &StoreId, _: &str, _: &str) -> Result<(), Report> { + Err(Report::new(PlatformError::SecretStore) + .attach("secret store writes are not supported on Cloudflare Workers")) + } + + fn delete(&self, _: &StoreId, _: &str) -> Result<(), Report> { + Err(Report::new(PlatformError::SecretStore) + .attach("secret store writes are not supported on Cloudflare Workers")) + } +} + +// --------------------------------------------------------------------------- +// build_runtime_services +// --------------------------------------------------------------------------- + +/// Construct [`RuntimeServices`] for an incoming Cloudflare Workers request. +/// +/// Config and KV are sourced from the edgezero handles that `run_app` injects +/// before routing — via the `TRUSTED_SERVER_CONFIG` env-var binding and the +/// `TRUSTED_SERVER_KV` KV namespace declared in `cloudflare.toml`. No +/// platform-specific `#[cfg]` is required for these two stores. +/// +/// Secrets still require direct `worker::Env` access because +/// `SecretHandle::get_bytes` is async while `PlatformSecretStore::get_bytes` +/// is sync; the underlying `env.secret()` call is synchronous at the JS level. +/// +/// Geo information is read from Cloudflare's injected request headers +/// (`cf-ipcountry`, etc.) which are present on all plans; headers absent on +/// the native host target simply produce empty/zero defaults. +pub fn build_runtime_services(ctx: &edgezero_core::context::RequestContext) -> RuntimeServices { + let client_ip = extract_client_ip(ctx); + + #[cfg(target_arch = "wasm32")] + let http_client: Arc = Arc::new(CloudflareHttpClient); + #[cfg(not(target_arch = "wasm32"))] + let http_client: Arc = Arc::new(UnavailableHttpClient); + + // Config: use the ConfigStoreHandle injected by run_app — no #[cfg] needed. + let config_store: Arc = ctx + .config_store() + .map(|h| Arc::new(ConfigStoreHandleAdapter(h)) as Arc) + .unwrap_or_else(|| Arc::new(NoopConfigStore)); + + // KV: use the KvHandle injected by run_app — no #[cfg] needed. + let kv_store: Arc = ctx + .kv_handle() + .map(|h| Arc::new(KvHandleAdapter(h)) as Arc) + .unwrap_or_else(|| Arc::new(UnavailableKvStore)); + + // Secrets: still requires wasm32-specific env.secret() (async/sync mismatch). + #[cfg(target_arch = "wasm32")] + let secret_store: Arc = + edgezero_adapter_cloudflare::CloudflareRequestContext::get(ctx.request()) + .map(|cf_ctx| { + Arc::new(CloudflareSecretStoreAdapter { + env: cf_ctx.env().clone(), + }) as Arc + }) + .unwrap_or_else(|| Arc::new(NoopSecretStore)); + #[cfg(not(target_arch = "wasm32"))] + let secret_store: Arc = Arc::new(NoopSecretStore); + + // Geo: read Cloudflare-injected headers — no #[cfg] needed; headers are + // simply absent on the native host target, producing Ok(None) from lookup(). + let geo = build_geo(ctx); + + RuntimeServices::builder() + .config_store(config_store) + .secret_store(secret_store) + .kv_store(kv_store) + .backend(Arc::new(NoopBackend)) + .http_client(http_client) + .geo(Arc::new(geo)) + .client_info(ClientInfo { + client_ip, + tls_protocol: None, + tls_cipher: None, + }) + .build() +} + +// --------------------------------------------------------------------------- +// Geo — reads Cloudflare-injected request headers (no #[cfg] needed) +// --------------------------------------------------------------------------- + +/// Reads Cloudflare geo headers injected by the Workers runtime. +/// +/// `cf-ipcountry` is available on all plans. `cf-ipcity`, `cf-ipcontinent`, +/// `cf-iplatitude`, and `cf-iplongitude` require an Enterprise plan. Absent or +/// unparseable values default to empty strings or `0.0`. Country code `XX` +/// (Cloudflare's "unknown" sentinel) is treated as absent. +struct CloudflareGeo { + country: String, + city: String, + continent: String, + latitude: f64, + longitude: f64, +} + +impl PlatformGeo for CloudflareGeo { + fn lookup(&self, _client_ip: Option) -> Result, Report> { + if self.country.is_empty() { + return Ok(None); + } + Ok(Some(GeoInfo { + city: self.city.clone(), + country: self.country.clone(), + continent: self.continent.clone(), + latitude: self.latitude, + longitude: self.longitude, + metro_code: 0, + region: None, + asn: None, + })) + } +} + +fn build_geo(ctx: &edgezero_core::context::RequestContext) -> CloudflareGeo { + let headers = ctx.request().headers(); + let country = headers + .get("cf-ipcountry") + .and_then(|v| v.to_str().ok()) + .filter(|s| !s.is_empty() && *s != "XX") + .unwrap_or("") + .to_string(); + let city = headers + .get("cf-ipcity") + .and_then(|v| v.to_str().ok()) + .unwrap_or("") + .to_string(); + let continent = headers + .get("cf-ipcontinent") + .and_then(|v| v.to_str().ok()) + .unwrap_or("") + .to_string(); + let latitude = headers + .get("cf-iplatitude") + .and_then(|v| v.to_str().ok()) + .and_then(|s| s.parse::().ok()) + .unwrap_or(0.0); + let longitude = headers + .get("cf-iplongitude") + .and_then(|v| v.to_str().ok()) + .and_then(|s| s.parse::().ok()) + .unwrap_or(0.0); + CloudflareGeo { + country, + city, + continent, + latitude, + longitude, + } +} + +fn extract_client_ip(ctx: &edgezero_core::context::RequestContext) -> Option { + ctx.request() + .headers() + .get("cf-connecting-ip") + .and_then(|v| v.to_str().ok()) + .and_then(|s| s.parse().ok()) +} + +/// Reject multi-provider auction fan-out at the Cloudflare adapter level. +/// +/// Cloudflare Workers executes `send_async` eagerly (no true concurrency), so +/// N simultaneous DSP requests run sequentially and accrue `sum(latencies)` +/// instead of `max(latencies)`. +/// +/// The primary guard lives in the auction orchestrator, which checks +/// `supports_concurrent_fanout()` and rejects multi-provider configurations +/// before any request launches. This `select`-time rejection is +/// defense-in-depth for callers that bypass that check. +/// +/// Extracted as a free function so the critical control-flow is testable on +/// native targets where the `#[cfg(target_arch = "wasm32")]` `select` impl +/// is excluded from the test binary. +#[cfg(any(target_arch = "wasm32", test))] +fn reject_multi_provider_fanout(len: usize) -> Result<(), Report> { + if len >= 2 { + return Err(Report::new(PlatformError::HttpClient).attach(format!( + "CloudflareHttpClient: multi-provider fan-out is not supported \ + ({len} providers submitted). Configure a single auction provider \ + or use the Fastly adapter for parallel DSP fan-out." + ))); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use edgezero_core::context::RequestContext; + use edgezero_core::http::{HeaderValue, request_builder}; + use edgezero_core::params::PathParams; + + fn make_ctx_with_header(name: &str, value: &str) -> RequestContext { + let req = request_builder() + .method("GET") + .uri("https://example.com/") + .header( + name, + HeaderValue::from_str(value).expect("should parse test header value"), + ) + .body(edgezero_core::body::Body::empty()) + .expect("should build test request"); + RequestContext::new(req, PathParams::default()) + } + + fn make_ctx_without_header() -> RequestContext { + let req = request_builder() + .method("GET") + .uri("https://example.com/") + .body(edgezero_core::body::Body::empty()) + .expect("should build test request"); + RequestContext::new(req, PathParams::default()) + } + + #[test] + fn extract_client_ip_parses_cf_connecting_ip() { + let ctx = make_ctx_with_header("cf-connecting-ip", "203.0.113.42"); + let ip = extract_client_ip(&ctx); + assert_eq!( + ip, + Some("203.0.113.42".parse().expect("should parse test IP")), + "should parse cf-connecting-ip header" + ); + } + + #[test] + fn extract_client_ip_returns_none_when_header_absent() { + let ctx = make_ctx_without_header(); + assert!( + extract_client_ip(&ctx).is_none(), + "should return None when cf-connecting-ip is not set" + ); + } + + #[test] + fn extract_client_ip_returns_none_for_invalid_ip() { + let ctx = make_ctx_with_header("cf-connecting-ip", "not-an-ip"); + assert!( + extract_client_ip(&ctx).is_none(), + "should return None for an unparseable IP string" + ); + } + + #[test] + fn build_geo_returns_country_from_header() { + let ctx = make_ctx_with_header("cf-ipcountry", "US"); + let geo = build_geo(&ctx); + assert_eq!(geo.country, "US", "should extract cf-ipcountry"); + } + + #[test] + fn build_geo_treats_xx_as_absent() { + let ctx = make_ctx_with_header("cf-ipcountry", "XX"); + let geo = build_geo(&ctx); + assert!(geo.country.is_empty(), "XX should be treated as absent"); + } + + #[test] + fn build_geo_lookup_returns_none_when_country_absent() { + let ctx = make_ctx_without_header(); + let geo = build_geo(&ctx); + assert!( + geo.lookup(None) + .expect("should perform geo lookup") + .is_none(), + "should return None when no country header" + ); + } + + #[test] + fn build_geo_lookup_returns_some_with_populated_country() { + let req = request_builder() + .method("GET") + .uri("https://example.com/") + .header("cf-ipcountry", HeaderValue::from_static("US")) + .header("cf-ipcity", HeaderValue::from_static("New York")) + .header("cf-ipcontinent", HeaderValue::from_static("NA")) + .header("cf-iplatitude", HeaderValue::from_static("40.71")) + .header("cf-iplongitude", HeaderValue::from_static("-74.01")) + .body(edgezero_core::body::Body::empty()) + .expect("should build test request"); + let ctx = RequestContext::new(req, PathParams::default()); + let geo = build_geo(&ctx); + let info = geo + .lookup(None) + .expect("should perform geo lookup") + .expect("should return GeoInfo when country is set"); + assert_eq!(info.country, "US", "should populate country"); + assert_eq!(info.city, "New York", "should populate city"); + assert_eq!(info.continent, "NA", "should populate continent"); + assert!( + (info.latitude - 40.71).abs() < 0.01, + "should populate latitude" + ); + assert!( + (info.longitude - (-74.01)).abs() < 0.01, + "should populate longitude" + ); + } + + // --------------------------------------------------------------------------- + // reject_multi_provider_fanout tests + // --------------------------------------------------------------------------- + + #[test] + fn reject_multi_provider_fanout_passes_empty() { + assert!( + reject_multi_provider_fanout(0).is_ok(), + "len=0 should pass (empty list caught separately in select)" + ); + } + + #[test] + fn reject_multi_provider_fanout_passes_single_provider() { + assert!( + reject_multi_provider_fanout(1).is_ok(), + "single provider should be allowed" + ); + } + + #[test] + fn reject_multi_provider_fanout_rejects_two_providers() { + assert!( + reject_multi_provider_fanout(2).is_err(), + "len=2 should be rejected" + ); + } + + #[test] + fn reject_multi_provider_fanout_rejects_five_providers() { + let err = reject_multi_provider_fanout(5).expect_err("should reject five providers"); + let msg = format!("{err:?}"); + assert!( + msg.contains("5"), + "error message should include provider count" + ); + } +} diff --git a/crates/trusted-server-adapter-cloudflare/tests/routes.rs b/crates/trusted-server-adapter-cloudflare/tests/routes.rs new file mode 100644 index 000000000..831e6bd46 --- /dev/null +++ b/crates/trusted-server-adapter-cloudflare/tests/routes.rs @@ -0,0 +1,74 @@ +//! Smoke tests for the Cloudflare adapter route wiring. +//! +//! Runs on the host target (no Workers runtime). Verifies that +//! `TrustedServerApp::routes()` builds without panicking. Does not exercise +//! the platform layer or outbound network calls. + +use edgezero_core::app::Hooks as _; +use edgezero_core::http::request_builder; +use trusted_server_adapter_cloudflare::app::TrustedServerApp; + +#[test] +fn routes_build_without_panic() { + // build_state() may fail (no real settings in CI) — startup_error_router + // is the fallback. Either way, routes() must not panic. + let _router = TrustedServerApp::routes(); +} + +// --------------------------------------------------------------------------- +// Middleware regression tests — verify FinalizeResponseMiddleware and +// AuthMiddleware are wired so they cannot be removed silently. +// --------------------------------------------------------------------------- + +#[tokio::test] +async fn finalize_middleware_injects_geo_header() { + // The X-Geo-Info-Available header is injected by FinalizeResponseMiddleware. + // Its absence on any response means the middleware was not wired. + let router = TrustedServerApp::routes(); + + let req = request_builder() + .method("GET") + .uri("/.well-known/trusted-server.json") + .body(edgezero_core::body::Body::empty()) + .expect("should build request"); + + let resp = router.oneshot(req).await; + + assert!( + resp.headers().contains_key("x-geo-info-available"), + "FinalizeResponseMiddleware must inject X-Geo-Info-Available on every response" + ); +} + +#[tokio::test] +async fn auth_middleware_runs_in_chain_for_protected_routes() { + // Verifies that AuthMiddleware is wired into the middleware chain for auction + // requests. Without it, FinalizeResponseMiddleware would still run but auth + // challenges would be skipped silently. + // + // CI settings may not have basic_auth configured, so this test does not + // assert 401 — it asserts that both middleware layers ran (X-Geo-Info-Available + // present) and that the route is actually reached (status != 404). + let router = TrustedServerApp::routes(); + + let req = request_builder() + .method("POST") + .uri("/auction") + .header("content-type", "application/json") + .body(edgezero_core::body::Body::from("{}")) + .expect("should build request"); + + let resp = router.oneshot(req).await; + + // Regardless of auth config the response must carry the finalize header, + // confirming both middleware layers ran (auth short-circuits through finalize). + assert!( + resp.headers().contains_key("x-geo-info-available"), + "middleware chain must inject X-Geo-Info-Available even on auth-rejected responses" + ); + assert_ne!( + resp.status().as_u16(), + 404, + "auction endpoint must be routed" + ); +} diff --git a/crates/trusted-server-adapter-cloudflare/wrangler.ci.toml b/crates/trusted-server-adapter-cloudflare/wrangler.ci.toml new file mode 100644 index 000000000..d68be33ad --- /dev/null +++ b/crates/trusted-server-adapter-cloudflare/wrangler.ci.toml @@ -0,0 +1,14 @@ +name = "trusted-server" +main = "build/index.js" +compatibility_date = "2024-09-23" +compatibility_flags = ["nodejs_compat"] +# No [build] section — bundle is pre-built in CI; wrangler dev must not rebuild. + +[[kv_namespaces]] +binding = "TRUSTED_SERVER_KV" +id = "ci-local-kv" + +[vars] +# Settings are baked into the WASM binary at build time; this JSON only needs +# to satisfy any runtime config-store lookups (e.g. request-signing keys). +TRUSTED_SERVER_CONFIG = "{}" diff --git a/crates/trusted-server-adapter-cloudflare/wrangler.toml b/crates/trusted-server-adapter-cloudflare/wrangler.toml new file mode 100644 index 000000000..077cfa6e9 --- /dev/null +++ b/crates/trusted-server-adapter-cloudflare/wrangler.toml @@ -0,0 +1,24 @@ +name = "trusted-server" +main = "build/index.js" +# Pins the Workers runtime API surface. Bump to a recent date and re-run +# integration tests to pick up free runtime fixes. Keep in sync with the +# minimum date supported by the worker crate version in Cargo.toml. +compatibility_date = "2024-09-23" +compatibility_flags = ["nodejs_compat"] + +[build] +command = "bash build.sh" + +[[kv_namespaces]] +binding = "TRUSTED_SERVER_KV" +# Run `wrangler kv:namespace create TRUSTED_SERVER_KV` and paste the returned +# id here before `wrangler deploy` or `wrangler dev --remote`. +# `wrangler dev --local` (Miniflare default) tolerates this placeholder. +id = "REPLACE_WITH_YOUR_KV_NAMESPACE_ID" + +[vars] +# TRUSTED_SERVER_CONFIG is consumed by the edgezero layer as a JSON-encoded env +# var binding. At runtime it is bridged to PlatformConfigStore via +# ConfigStoreHandleAdapter — replace the placeholder values with your publisher +# settings before deploying. +TRUSTED_SERVER_CONFIG = '{"publisher.domain":"your-publisher.com"}' diff --git a/crates/trusted-server-core/Cargo.toml b/crates/trusted-server-core/Cargo.toml index 2c8303ec5..bd13ee8bf 100644 --- a/crates/trusted-server-core/Cargo.toml +++ b/crates/trusted-server-core/Cargo.toml @@ -48,6 +48,7 @@ uuid = { workspace = true } validator = { workspace = true } ed25519-dalek = { workspace = true } edgezero-core = { workspace = true } +web-time = { workspace = true } [target.'cfg(all(target_arch = "wasm32", target_os = "unknown"))'.dependencies] # Enable JS-backed RNG for `wasm32-unknown-unknown` targets (e.g. Cloudflare Workers). diff --git a/crates/trusted-server-core/src/auction/orchestrator.rs b/crates/trusted-server-core/src/auction/orchestrator.rs index 917b45fb6..9ef49c970 100644 --- a/crates/trusted-server-core/src/auction/orchestrator.rs +++ b/crates/trusted-server-core/src/auction/orchestrator.rs @@ -3,7 +3,8 @@ use error_stack::{Report, ResultExt}; use std::collections::HashMap; use std::sync::Arc; -use std::time::{Duration, Instant}; +use std::time::Duration; +use web_time::Instant; use crate::error::TrustedServerError; @@ -282,6 +283,22 @@ impl AuctionOrchestrator { })); } + // Reject multi-provider fan-out before any request launches when the + // platform executes `send_async` eagerly (e.g. Cloudflare Workers): + // sequential execution would accrue the sum of provider latencies and + // blow the auction budget before a later `select` could reject it. + if provider_names.len() > 1 && !context.services.http_client().supports_concurrent_fanout() + { + return Err(Report::new(TrustedServerError::Auction { + message: format!( + "{} auction providers configured, but this platform's HTTP \ + client executes requests sequentially — configure a single \ + provider, or use an adapter with concurrent fan-out support", + provider_names.len(), + ), + })); + } + log::info!( "Running {} providers in parallel using select", provider_names.len() @@ -691,6 +708,9 @@ impl OrchestrationResult { #[cfg(test)] mod tests { + use std::time::Duration; + use web_time::Instant; + use crate::auction::config::AuctionConfig; use crate::auction::provider::AuctionProvider; use crate::auction::test_support::create_test_auction_context; @@ -1013,7 +1033,7 @@ mod tests { #[test] fn remaining_budget_returns_full_timeout_immediately() { - let start = std::time::Instant::now(); + let start = Instant::now(); let result = super::remaining_budget_ms(start, 2000); // Should be very close to 2000 (allow a few ms for test execution) assert!( @@ -1025,7 +1045,7 @@ mod tests { #[test] fn remaining_budget_saturates_at_zero() { // Create an instant in the past by sleeping briefly with a tiny timeout - let start = std::time::Instant::now(); + let start = Instant::now(); // Use a timeout of 0 — elapsed will always exceed it let result = super::remaining_budget_ms(start, 0); assert_eq!(result, 0, "should return 0 when timeout is 0"); @@ -1033,8 +1053,8 @@ mod tests { #[test] fn remaining_budget_decreases_over_time() { - let start = std::time::Instant::now(); - std::thread::sleep(std::time::Duration::from_millis(50)); + let start = Instant::now(); + std::thread::sleep(Duration::from_millis(50)); let result = super::remaining_budget_ms(start, 2000); assert!( result < 2000, @@ -1131,6 +1151,68 @@ mod tests { }); } + #[test] + fn rejects_multi_provider_fanout_before_launch_on_sequential_platform() { + futures::executor::block_on(async { + // Arrange: two configured providers on a platform whose HTTP + // client executes send_async eagerly (no concurrent fan-out). + let stub = Arc::new(StubHttpClient::new()); + stub.set_concurrent_fanout(false); + let stub_for_assertion = Arc::clone(&stub); + + let services = build_services_with_http_client(stub); + // SAFETY: `Box::leak` creates a `'static` reference for test use only. + // The leaked allocation is bounded to the test process lifetime. + let services: &'static RuntimeServices = Box::leak(Box::new(services)); + + let config = AuctionConfig { + enabled: true, + providers: vec!["provider-a".to_string(), "provider-b".to_string()], + timeout_ms: 2000, + mediator: None, + ..Default::default() + }; + let mut orchestrator = AuctionOrchestrator::new(config); + orchestrator.register_provider(Arc::new(StubAuctionProvider { + name: "provider-a", + backend: "backend-a", + })); + orchestrator.register_provider(Arc::new(StubAuctionProvider { + name: "provider-b", + backend: "backend-b", + })); + + let request = create_test_auction_request(); + let settings = create_test_settings(); + let req = http::Request::builder() + .method(http::Method::GET) + .uri("https://example.com/test") + .body(edgezero_core::body::Body::empty()) + .expect("should build request"); + let context = AuctionContext { + settings: &settings, + request: &req, + timeout_ms: 2000, + provider_responses: None, + services, + }; + + // Act + let result = orchestrator.run_auction(&request, &context).await; + + // Assert: rejected before any provider request launches. + let err = result.expect_err("should reject multi-provider fan-out"); + assert!( + format!("{err}").contains("sequentially"), + "should explain the sequential-execution limitation" + ); + assert!( + stub_for_assertion.recorded_backend_names().is_empty(), + "should not launch any provider request before rejecting" + ); + }); + } + #[test] fn test_apply_floor_prices_allows_none_prices_for_encoded_bids() { // Test that bids with None prices (APS-style) pass through floor pricing diff --git a/crates/trusted-server-core/src/consent/mod.rs b/crates/trusted-server-core/src/consent/mod.rs index 2d3f14b1b..15dd9794d 100644 --- a/crates/trusted-server-core/src/consent/mod.rs +++ b/crates/trusted-server-core/src/consent/mod.rs @@ -45,7 +45,8 @@ pub use types::{ }; use std::sync::atomic::{AtomicBool, Ordering}; -use std::time::{SystemTime, UNIX_EPOCH}; + +use web_time::{SystemTime, UNIX_EPOCH}; use cookie::CookieJar; use edgezero_core::body::Body as EdgeBody; diff --git a/crates/trusted-server-core/src/platform/http.rs b/crates/trusted-server-core/src/platform/http.rs index cdfc9ae52..45a402329 100644 --- a/crates/trusted-server-core/src/platform/http.rs +++ b/crates/trusted-server-core/src/platform/http.rs @@ -157,6 +157,43 @@ pub struct PlatformSelectResult { pub failed_backend_name: Option, } +/// A [`PlatformHttpClient`] stand-in used when outbound HTTP is not available +/// on the current platform (e.g. Cloudflare Workers, where the proxy client is +/// managed by the edgezero dispatch layer instead). +/// +/// Every method returns [`PlatformError::HttpClient`], ensuring that code paths +/// that reach this stub receive a typed error. Adapter crates should use this +/// type rather than defining their own stub so the fallback behaviour is +/// consistent across all platform implementations. +pub struct UnavailableHttpClient; + +#[async_trait::async_trait(?Send)] +impl PlatformHttpClient for UnavailableHttpClient { + async fn send( + &self, + _request: PlatformHttpRequest, + ) -> Result> { + Err(Report::new(PlatformError::HttpClient) + .attach("HTTP client is unavailable on this platform")) + } + + async fn send_async( + &self, + _request: PlatformHttpRequest, + ) -> Result> { + Err(Report::new(PlatformError::HttpClient) + .attach("HTTP client is unavailable on this platform")) + } + + async fn select( + &self, + _pending_requests: Vec, + ) -> Result> { + Err(Report::new(PlatformError::HttpClient) + .attach("HTTP client is unavailable on this platform")) + } +} + /// Outbound HTTP client abstraction. /// /// Supports both single-request sends ([`Self::send`]) and async fan-out @@ -192,6 +229,19 @@ pub trait PlatformHttpClient: Send + Sync { request: PlatformHttpRequest, ) -> Result>; + /// Whether [`send_async`](Self::send_async) defers execution so multiple + /// pending requests progress concurrently and [`select`](Self::select) + /// races them. + /// + /// Platforms where `send_async` executes each request eagerly before + /// returning (e.g. Cloudflare Workers) return `false`. On such platforms + /// multi-request fan-out runs sequentially and accrues the sum of the + /// individual latencies, so callers with a latency budget (the auction + /// orchestrator) must check this before launching more than one request. + fn supports_concurrent_fanout(&self) -> bool { + true + } + /// Wait for one of the in-flight requests to complete. /// /// # Errors diff --git a/crates/trusted-server-core/src/platform/mod.rs b/crates/trusted-server-core/src/platform/mod.rs index 7342e7c11..58b87bbf0 100644 --- a/crates/trusted-server-core/src/platform/mod.rs +++ b/crates/trusted-server-core/src/platform/mod.rs @@ -44,7 +44,7 @@ pub use edgezero_core::key_value_store::{KvError, KvHandle, KvStore as PlatformK pub use error::PlatformError; pub use http::{ PlatformHttpClient, PlatformHttpRequest, PlatformPendingRequest, PlatformResponse, - PlatformSelectResult, + PlatformSelectResult, UnavailableHttpClient, }; pub use kv::UnavailableKvStore; pub use traits::{PlatformBackend, PlatformConfigStore, PlatformGeo, PlatformSecretStore}; diff --git a/crates/trusted-server-core/src/platform/test_support.rs b/crates/trusted-server-core/src/platform/test_support.rs index a6f040490..bf4401df5 100644 --- a/crates/trusted-server-core/src/platform/test_support.rs +++ b/crates/trusted-server-core/src/platform/test_support.rs @@ -221,6 +221,9 @@ pub(crate) struct StubHttpClient { request_headers: Mutex>>, // Queued select() errors — each pop makes the next select() return ready: Err. select_errors: Mutex>, + // Reported by supports_concurrent_fanout(); set false to emulate + // platforms whose send_async executes eagerly (e.g. Cloudflare Workers). + concurrent_fanout: std::sync::atomic::AtomicBool, } impl StubHttpClient { @@ -230,9 +233,16 @@ impl StubHttpClient { responses: Mutex::new(VecDeque::new()), request_headers: Mutex::new(Vec::new()), select_errors: Mutex::new(VecDeque::new()), + concurrent_fanout: std::sync::atomic::AtomicBool::new(true), } } + /// Make `supports_concurrent_fanout()` report the given value. + pub fn set_concurrent_fanout(&self, supported: bool) { + self.concurrent_fanout + .store(supported, std::sync::atomic::Ordering::Relaxed); + } + /// Queue a canned response by status code and body bytes. pub fn push_response(&self, status: u16, body: Vec) { self.responses @@ -268,6 +278,11 @@ impl StubHttpClient { // ?Send matches PlatformHttpClient. See http.rs for the full rationale. #[async_trait::async_trait(?Send)] impl PlatformHttpClient for StubHttpClient { + fn supports_concurrent_fanout(&self) -> bool { + self.concurrent_fanout + .load(std::sync::atomic::Ordering::Relaxed) + } + async fn send( &self, request: PlatformHttpRequest, diff --git a/crates/trusted-server-core/src/proxy.rs b/crates/trusted-server-core/src/proxy.rs index 3938ffd81..ec54aa0bf 100644 --- a/crates/trusted-server-core/src/proxy.rs +++ b/crates/trusted-server-core/src/proxy.rs @@ -5,7 +5,8 @@ use error_stack::{Report, ResultExt}; use http::{header, HeaderValue, Method, Request, Response, StatusCode}; use serde::{Deserialize, Serialize}; use std::io::Cursor; -use std::time::{Duration, SystemTime, UNIX_EPOCH}; +use std::time::Duration; +use web_time::{SystemTime, UNIX_EPOCH}; use crate::constants::{ HEADER_ACCEPT, HEADER_ACCEPT_ENCODING, HEADER_ACCEPT_LANGUAGE, HEADER_REFERER, diff --git a/crates/trusted-server-core/src/request_signing/signing.rs b/crates/trusted-server-core/src/request_signing/signing.rs index 176f75e11..516e2f528 100644 --- a/crates/trusted-server-core/src/request_signing/signing.rs +++ b/crates/trusted-server-core/src/request_signing/signing.rs @@ -96,8 +96,8 @@ impl SigningParams { request_id, request_host, request_scheme, - timestamp: std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) + timestamp: web_time::SystemTime::now() + .duration_since(web_time::UNIX_EPOCH) .map(|d| d.as_millis() as u64) .unwrap_or(0), } @@ -366,8 +366,8 @@ mod tests { "https".to_string(), ); - let now_ms = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) + let now_ms = web_time::SystemTime::now() + .duration_since(web_time::UNIX_EPOCH) .expect("should get system time") .as_millis() as u64; diff --git a/docs/superpowers/plans/2026-04-17-pr17-cloudflare-adapter.md b/docs/superpowers/plans/2026-04-17-pr17-cloudflare-adapter.md new file mode 100644 index 000000000..8ae93a6a6 --- /dev/null +++ b/docs/superpowers/plans/2026-04-17-pr17-cloudflare-adapter.md @@ -0,0 +1,964 @@ +# PR17 — Cloudflare Workers Adapter Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add `trusted-server-adapter-cloudflare` crate so trusted-server runs on Cloudflare Workers, using the same `TrustedServerApp` core as the Fastly and Axum adapters. + +**Architecture:** A new `crates/trusted-server-adapter-cloudflare/` crate implements `Hooks` on `TrustedServerApp` and wires `RuntimeServices` using Cloudflare Workers bindings (KV, Config, Secrets) via the `edgezero-adapter-cloudflare` crate. The entry point is a `#[event(fetch)]` macro. Before adding the crate, `std::time::Instant` in `trusted-server-core` must be replaced with `web_time::Instant` (which is a zero-cost alias on native, but works on `wasm32-unknown-unknown` where `std::time::Instant` panics). The crate is host-compilable via `cfg`-gated shims so CI can validate it with `cargo check` on native before deploying to Workers. + +**Tech Stack:** Rust 2024 edition, `worker` crate (Cloudflare Workers SDK), `edgezero-adapter-cloudflare`, `web-time`, `wrangler` (CLI, for manual deploy only — not in CI). + +--- + +## File Map + +### New files + +- `crates/trusted-server-adapter-cloudflare/Cargo.toml` — crate manifest +- `crates/trusted-server-adapter-cloudflare/cloudflare.toml` — edgezero manifest (kv/config/secret store names) +- `crates/trusted-server-adapter-cloudflare/wrangler.toml` — Wrangler config (bindings, compatibility) +- `crates/trusted-server-adapter-cloudflare/.gitignore` — ignore `target/`, `.edgezero/` +- `crates/trusted-server-adapter-cloudflare/src/lib.rs` — `#[event(fetch)]` entry point + host shim +- `crates/trusted-server-adapter-cloudflare/src/app.rs` — `TrustedServerApp` + `Hooks` impl +- `crates/trusted-server-adapter-cloudflare/src/platform.rs` — `build_runtime_services` for Cloudflare +- `crates/trusted-server-adapter-cloudflare/tests/routes.rs` — route smoke tests (host target, no Workers runtime) + +### Modified files + +- `crates/trusted-server-core/Cargo.toml` — add `web-time` workspace dep +- `crates/trusted-server-core/src/auction/orchestrator.rs` — replace `std::time::Instant` with `web_time::Instant` +- `Cargo.toml` (workspace) — add `web-time` to `[workspace.dependencies]`; add cloudflare crate to `[members]` +- `.github/workflows/test.yml` — add `test-cloudflare` CI job +- `CLAUDE.md` — document new crate + +--- + +## Task 1: Replace `std::time::Instant` with `web_time::Instant` in core + +`std::time::Instant` panics on `wasm32-unknown-unknown` (Cloudflare). `web_time::Instant` is a zero-cost drop-in on native and JS-backed on WASM. + +**Files:** + +- Modify: `Cargo.toml` (workspace `[workspace.dependencies]`) +- Modify: `crates/trusted-server-core/Cargo.toml` +- Modify: `crates/trusted-server-core/src/auction/orchestrator.rs` + +- [ ] **Step 1: Add `web-time` to workspace dependencies** + +In `Cargo.toml`: + +```toml +web-time = "1" +``` + +Add alphabetically in `[workspace.dependencies]`. + +- [ ] **Step 2: Add `web-time` to `trusted-server-core/Cargo.toml`** + +```toml +web-time = { workspace = true } +``` + +- [ ] **Step 3: Replace `std::time::Instant` in orchestrator** + +In `crates/trusted-server-core/src/auction/orchestrator.rs`, change line 6: + +```rust +// Before: +use std::time::{Duration, Instant}; + +// After: +use std::time::Duration; +use web_time::Instant; +``` + +Lines 830 and 842 use `std::time::Instant::now()` — change both to `Instant::now()` (they already use the bare name once the import is replaced). + +- [ ] **Step 4: Verify WASM and native both compile** + +```bash +cargo check -p trusted-server-core +cargo check -p trusted-server-core --target wasm32-wasip1 +``` + +Expected: `Finished` with no errors. + +- [ ] **Step 5: Run core tests** + +```bash +cargo test -p trusted-server-core --target wasm32-wasip1 +``` + +Expected: all pass. + +- [ ] **Step 6: Commit** + +```bash +git add Cargo.toml crates/trusted-server-core/Cargo.toml crates/trusted-server-core/src/auction/orchestrator.rs +git commit -m "Replace std::time::Instant with web_time::Instant in auction orchestrator + +wasm32-unknown-unknown (Cloudflare Workers) does not support +std::time::Instant — it panics at runtime. web_time::Instant is a +zero-cost drop-in on native and JS-backed on WASM." +``` + +--- + +## Task 2: Workspace plumbing — add cloudflare crate as member + +**Files:** + +- Modify: `Cargo.toml` (workspace) +- Modify: `Cargo.toml` (workspace.dependencies) + +- [ ] **Step 1: Add `edgezero-adapter-cloudflare` to workspace deps** + +In `Cargo.toml` `[workspace.dependencies]`: + +```toml +edgezero-adapter-cloudflare = { git = "https://github.com/stackpop/edgezero", rev = "38198f9839b70aef03ab971ae5876982773fc2a1", default-features = false } +``` + +(Same `rev` as the other edgezero deps already in the workspace.) + +- [ ] **Step 2: Add cloudflare crate to workspace `[members]`** + +```toml +members = [ + "crates/trusted-server-core", + "crates/trusted-server-adapter-fastly", + "crates/trusted-server-adapter-axum", + "crates/trusted-server-adapter-cloudflare", + "crates/js", + "crates/openrtb", +] +``` + +- [ ] **Step 3: Verify workspace resolves (crate doesn't exist yet — expect path error)** + +```bash +cargo metadata --no-deps 2>&1 | head -5 +``` + +Expected: error about missing path (that's fine — the crate directory doesn't exist yet). Proceed to Task 3. + +--- + +## Task 3: Crate skeleton + +**Files:** + +- Create: `crates/trusted-server-adapter-cloudflare/.gitignore` +- Create: `crates/trusted-server-adapter-cloudflare/Cargo.toml` +- Create: `crates/trusted-server-adapter-cloudflare/src/lib.rs` +- Create: `crates/trusted-server-adapter-cloudflare/src/app.rs` +- Create: `crates/trusted-server-adapter-cloudflare/src/platform.rs` + +- [ ] **Step 1: Create `.gitignore`** + +``` +target/ +.edgezero/ +``` + +- [ ] **Step 2: Create `Cargo.toml`** + +```toml +[package] +name = "trusted-server-adapter-cloudflare" +version = "0.1.0" +edition = "2024" +publish = false + +[lints] +workspace = true + +[lib] +name = "trusted_server_adapter_cloudflare" +path = "src/lib.rs" +crate-type = ["cdylib", "rlib"] + +[features] +default = [] +cloudflare = ["edgezero-adapter-cloudflare/cloudflare", "dep:worker"] + +[dependencies] +async-trait = { workspace = true } +edgezero-adapter-cloudflare = { workspace = true, features = [] } +edgezero-core = { workspace = true } +error-stack = { workspace = true } +log = { workspace = true } +trusted-server-core = { path = "../trusted-server-core" } +trusted-server-js = { path = "../js" } +worker = { version = "0.7", default-features = false, features = ["http"], optional = true } + +[dev-dependencies] +edgezero-adapter-cloudflare = { workspace = true } +edgezero-core = { workspace = true } +tokio = { workspace = true, features = ["rt-multi-thread", "macros"] } +tower = { version = "0.4", features = ["util"] } +``` + +- [ ] **Step 3: Create stub `src/lib.rs`** + +```rust +pub mod app; +pub mod platform; +``` + +- [ ] **Step 4: Create stub `src/app.rs`** + +```rust +use trusted_server_core::error::TrustedServerError; +use error_stack::Report; + +/// Application entry point (stub — implementation in Task 4). +pub struct TrustedServerApp; + +pub(crate) fn http_error(_report: &Report) -> edgezero_core::http::Response { + todo!("implemented in Task 4") +} +``` + +- [ ] **Step 5: Create stub `src/platform.rs`** + +```rust +use trusted_server_core::platform::RuntimeServices; + +pub fn build_runtime_services( + _ctx: &edgezero_core::context::RequestContext, +) -> RuntimeServices { + todo!("implemented in Task 5") +} +``` + +- [ ] **Step 6: Verify workspace compiles** + +```bash +cargo check -p trusted-server-adapter-cloudflare +``` + +Expected: `Finished` (stubs compile, `todo!()` is fine at check time). + +- [ ] **Step 7: Commit** + +```bash +git add crates/trusted-server-adapter-cloudflare/ Cargo.toml +git commit -m "Add trusted-server-adapter-cloudflare crate skeleton" +``` + +--- + +## Task 4: App wiring — `TrustedServerApp` + `Hooks` implementation + +This mirrors `crates/trusted-server-adapter-axum/src/app.rs` exactly, except the entry point and error helper. + +**Files:** + +- Modify: `crates/trusted-server-adapter-cloudflare/src/app.rs` + +- [ ] **Step 1: Write the full `app.rs`** + +```rust +use std::sync::Arc; + +use edgezero_core::app::Hooks; +use edgezero_core::context::RequestContext; +use edgezero_core::error::EdgeError; +use edgezero_core::http::{HeaderValue, Response, header}; +use edgezero_core::router::RouterService; +use error_stack::Report; +use trusted_server_core::auction::endpoints::handle_auction; +use trusted_server_core::auction::{AuctionOrchestrator, build_orchestrator}; +use trusted_server_core::error::{IntoHttpResponse as _, TrustedServerError}; +use trusted_server_core::integrations::IntegrationRegistry; +use trusted_server_core::platform::RuntimeServices; +use trusted_server_core::proxy::{ + handle_first_party_click, handle_first_party_proxy, handle_first_party_proxy_rebuild, + handle_first_party_proxy_sign, +}; +use trusted_server_core::publisher::{handle_publisher_request, handle_tsjs_dynamic}; +use trusted_server_core::request_signing::{ + handle_deactivate_key, handle_rotate_key, handle_trusted_server_discovery, + handle_verify_signature, +}; +use trusted_server_core::settings::Settings; +use trusted_server_core::settings_data::get_settings; + +use crate::platform::build_runtime_services; + +pub struct AppState { + settings: Arc, + orchestrator: Arc, + registry: Arc, +} + +fn build_state() -> Result, Report> { + let settings = get_settings()?; + let orchestrator = build_orchestrator(&settings)?; + let registry = IntegrationRegistry::new(&settings)?; + Ok(Arc::new(AppState { + settings: Arc::new(settings), + orchestrator: Arc::new(orchestrator), + registry: Arc::new(registry), + })) +} + +fn build_per_request_services(ctx: &RequestContext) -> RuntimeServices { + build_runtime_services(ctx) +} + +/// Convert a [`Report`] into an HTTP [`Response`]. +pub(crate) fn http_error(report: &Report) -> Response { + let root_error = report.current_context(); + log::error!("Error occurred: {:?}", report); + let body = edgezero_core::body::Body::from(format!("{}\n", root_error.user_message())); + let mut response = Response::new(body); + *response.status_mut() = root_error.status_code(); + response.headers_mut().insert( + header::CONTENT_TYPE, + HeaderValue::from_static("text/plain; charset=utf-8"), + ); + response +} + +fn startup_error_router(e: Report) -> RouterService { + RouterService::new(move |_ctx: RequestContext| { + let body = edgezero_core::body::Body::from(format!( + "trusted-server failed to start: {}\n", + e.current_context() + )); + let mut r = Response::new(body); + *r.status_mut() = edgezero_core::http::StatusCode::INTERNAL_SERVER_ERROR; + async move { Ok(r) } + }) +} + +pub struct TrustedServerApp; + +impl Hooks for TrustedServerApp { + fn routes() -> RouterService { + let state = match build_state() { + Ok(s) => s, + Err(e) => return startup_error_router(e), + }; + + let settings = Arc::clone(&state.settings); + let orchestrator = Arc::clone(&state.orchestrator); + let registry = Arc::clone(&state.registry); + + let mut router = edgezero_core::router::Router::new(); + + // Discovery + signing + { + let s = Arc::clone(&settings); + router.get("/.well-known/trusted-server.json", move |ctx| { + let s = Arc::clone(&s); + let svc = build_per_request_services(&ctx); + async move { handle_trusted_server_discovery(&ctx, &s, &svc).await.or_else(|e| Ok(http_error(&e))) } + }); + } + { + let s = Arc::clone(&settings); + router.post("/verify-signature", move |ctx| { + let s = Arc::clone(&s); + let svc = build_per_request_services(&ctx); + async move { handle_verify_signature(&ctx, &s, &svc).await.or_else(|e| Ok(http_error(&e))) } + }); + } + + // Admin + { + let s = Arc::clone(&settings); + router.post("/admin/keys/rotate", move |ctx| { + let s = Arc::clone(&s); + let svc = build_per_request_services(&ctx); + async move { handle_rotate_key(&ctx, &s, &svc).await.or_else(|e| Ok(http_error(&e))) } + }); + } + { + let s = Arc::clone(&settings); + router.post("/admin/keys/deactivate", move |ctx| { + let s = Arc::clone(&s); + let svc = build_per_request_services(&ctx); + async move { handle_deactivate_key(&ctx, &s, &svc).await.or_else(|e| Ok(http_error(&e))) } + }); + } + + // Static JS + { + let s = Arc::clone(&settings); + let r = Arc::clone(®istry); + router.get("/static/tsjs=:hash", move |ctx| { + let s = Arc::clone(&s); + let r = Arc::clone(&r); + async move { handle_tsjs_dynamic(&ctx, &s, &r).await.or_else(|e| Ok(http_error(&e))) } + }); + } + + // First-party proxy + { + let s = Arc::clone(&settings); + router.get("/first-party/proxy", move |ctx| { + let s = Arc::clone(&s); + let svc = build_per_request_services(&ctx); + async move { handle_first_party_proxy(&ctx, &s, &svc).await.or_else(|e| Ok(http_error(&e))) } + }); + } + { + let s = Arc::clone(&settings); + router.post("/first-party/proxy", move |ctx| { + let s = Arc::clone(&s); + let svc = build_per_request_services(&ctx); + async move { handle_first_party_proxy(&ctx, &s, &svc).await.or_else(|e| Ok(http_error(&e))) } + }); + } + { + let s = Arc::clone(&settings); + router.get("/first-party/proxy/sign", move |ctx| { + let s = Arc::clone(&s); + let svc = build_per_request_services(&ctx); + async move { handle_first_party_proxy_sign(&ctx, &s, &svc).await.or_else(|e| Ok(http_error(&e))) } + }); + } + { + let s = Arc::clone(&settings); + router.get("/first-party/proxy/rebuild", move |ctx| { + let s = Arc::clone(&s); + let svc = build_per_request_services(&ctx); + async move { handle_first_party_proxy_rebuild(&ctx, &s, &svc).await.or_else(|e| Ok(http_error(&e))) } + }); + } + { + let s = Arc::clone(&settings); + router.get("/first-party/click", move |ctx| { + let s = Arc::clone(&s); + let svc = build_per_request_services(&ctx); + async move { handle_first_party_click(&ctx, &s, &svc).await.or_else(|e| Ok(http_error(&e))) } + }); + } + + // Auction + { + let s = Arc::clone(&settings); + let o = Arc::clone(&orchestrator); + router.post("/auction", move |ctx| { + let s = Arc::clone(&s); + let o = Arc::clone(&o); + let svc = build_per_request_services(&ctx); + async move { handle_auction(&ctx, &s, &o, &svc).await.or_else(|e| Ok(http_error(&e))) } + }); + } + + // Publisher proxy (catch-all) + { + let s = Arc::clone(&settings); + let r = Arc::clone(®istry); + router.any("/:path*", move |ctx| { + let s = Arc::clone(&s); + let r = Arc::clone(&r); + let svc = build_per_request_services(&ctx); + async move { handle_publisher_request(&ctx, &s, &r, &svc).await.or_else(|e| Ok(http_error(&e))) } + }); + } + + router.build() + } +} +``` + +- [ ] **Step 2: Verify it compiles** + +```bash +cargo check -p trusted-server-adapter-cloudflare +``` + +Expected: `Finished`. + +- [ ] **Step 3: Commit** + +```bash +git add crates/trusted-server-adapter-cloudflare/src/app.rs +git commit -m "Add TrustedServerApp Hooks implementation for Cloudflare adapter" +``` + +--- + +## Task 5: Platform trait implementations + +Cloudflare Workers exposes KV, config, and secrets through the `worker::Env` binding. The edgezero Cloudflare adapter already wraps these — we just need to wire them into `RuntimeServices`. + +**Key difference from Axum:** On Cloudflare the `worker::Env` is passed per-request via `CloudflareRequestContext`. KV is available via the edgezero adapter's built-in handle; config/secret use `edgezero-adapter-cloudflare`'s `CloudflareConfigStore` and `CloudflareSecretStore`. For the `PlatformHttpClient`, the edgezero adapter's `CloudflareProxyClient` is already registered at dispatch time via `ProxyHandle` — so we use `UnavailableHttpClient` (same pattern as Axum's `UnavailableKvStore`). + +**Files:** + +- Modify: `crates/trusted-server-adapter-cloudflare/src/platform.rs` + +- [ ] **Step 1: Write `platform.rs`** + +On native (host compile for CI), the `worker` crate types are unavailable. Use `cfg` to gate the Cloudflare-specific implementation behind `#[cfg(all(feature = "cloudflare", target_arch = "wasm32"))]` and provide a no-op stub for host builds. + +```rust +use std::sync::Arc; +use trusted_server_core::platform::{ + PlatformError, RuntimeServices, UnavailableKvStore, + ClientInfo, PlatformBackend, PlatformBackendSpec, PlatformConfigStore, + PlatformGeo, GeoInfo, StoreName, StoreId, +}; +use error_stack::Report; + +// --------------------------------------------------------------------------- +// Host-only stub (native target, used in CI cargo check + tests) +// --------------------------------------------------------------------------- + +/// Construct a no-op [`RuntimeServices`] for host-target builds. +/// +/// All platform operations degrade gracefully on native. This exists only so +/// the crate host-compiles for CI; Cloudflare Workers always runs the +/// `cfg`-gated implementation below. +#[cfg(not(all(feature = "cloudflare", target_arch = "wasm32")))] +pub fn build_runtime_services( + _ctx: &edgezero_core::context::RequestContext, +) -> RuntimeServices { + struct NoopConfigStore; + impl PlatformConfigStore for NoopConfigStore { + fn get(&self, _: &StoreName, _: &str) -> Result> { + Err(Report::new(PlatformError::ConfigStore).attach("unavailable on host target")) + } + fn put(&self, _: &StoreId, _: &str, _: &str) -> Result<(), Report> { + Err(Report::new(PlatformError::ConfigStore).attach("unavailable on host target")) + } + fn delete(&self, _: &StoreId, _: &str) -> Result<(), Report> { + Err(Report::new(PlatformError::ConfigStore).attach("unavailable on host target")) + } + } + + struct NoopSecretStore; + impl trusted_server_core::platform::PlatformSecretStore for NoopSecretStore { + fn get_bytes(&self, _: &StoreName, _: &str) -> Result, Report> { + Err(Report::new(PlatformError::SecretStore).attach("unavailable on host target")) + } + fn create(&self, _: &StoreId, _: &str, _: &str) -> Result<(), Report> { + Err(Report::new(PlatformError::SecretStore).attach("unavailable on host target")) + } + fn delete(&self, _: &StoreId, _: &str) -> Result<(), Report> { + Err(Report::new(PlatformError::SecretStore).attach("unavailable on host target")) + } + } + + struct NoopBackend; + impl PlatformBackend for NoopBackend { + fn predict_name(&self, _: &PlatformBackendSpec) -> Result> { + Ok("noop".to_string()) + } + fn ensure(&self, spec: &PlatformBackendSpec) -> Result> { + self.predict_name(spec) + } + } + + struct NoopGeo; + impl PlatformGeo for NoopGeo { + fn lookup(&self, _: Option) -> Result, Report> { + Ok(None) + } + } + + use trusted_server_core::platform::UnavailableHttpClient; + + RuntimeServices::builder() + .config_store(Arc::new(NoopConfigStore)) + .secret_store(Arc::new(NoopSecretStore)) + .kv_store(Arc::new(UnavailableKvStore)) + .backend(Arc::new(NoopBackend)) + .http_client(Arc::new(UnavailableHttpClient)) + .geo(Arc::new(NoopGeo)) + .client_info(ClientInfo { client_ip: None, tls_protocol: None, tls_cipher: None }) + .build() +} + +// --------------------------------------------------------------------------- +// Cloudflare Workers implementation +// --------------------------------------------------------------------------- + +#[cfg(all(feature = "cloudflare", target_arch = "wasm32"))] +pub fn build_runtime_services( + ctx: &edgezero_core::context::RequestContext, +) -> RuntimeServices { + use edgezero_adapter_cloudflare::CloudflareRequestContext; + + let client_ip = CloudflareRequestContext::get(ctx.request()) + .and_then(|c| c.client_ip()); + + // KV, config, secrets are injected at dispatch time by edgezero's + // dispatch_with_bindings — they live in the request extensions. + // UnavailableKvStore and UnavailableHttpClient are correct here: + // KV is accessed via edgezero's KvHandle (not PlatformKvStore), + // and outbound HTTP uses CloudflareProxyClient via ProxyHandle. + use trusted_server_core::platform::UnavailableHttpClient; + + struct CloudflareBackend; + impl PlatformBackend for CloudflareBackend { + fn predict_name(&self, spec: &PlatformBackendSpec) -> Result> { + Ok(format!("{}_{}", spec.scheme, spec.host)) + } + fn ensure(&self, spec: &PlatformBackendSpec) -> Result> { + self.predict_name(spec) + } + } + + struct CloudflareGeo; + impl PlatformGeo for CloudflareGeo { + fn lookup(&self, _: Option) -> Result, Report> { + // Cloudflare geo is available via cf-ipcountry header; not yet wired. + Ok(None) + } + } + + struct UnavailableConfigStore; + impl PlatformConfigStore for UnavailableConfigStore { + fn get(&self, _: &StoreName, _: &str) -> Result> { + Err(Report::new(PlatformError::ConfigStore).attach("use edgezero config store handle")) + } + fn put(&self, _: &StoreId, _: &str, _: &str) -> Result<(), Report> { + Err(Report::new(PlatformError::ConfigStore).attach("writes not supported")) + } + fn delete(&self, _: &StoreId, _: &str) -> Result<(), Report> { + Err(Report::new(PlatformError::ConfigStore).attach("deletes not supported")) + } + } + + struct UnavailableSecretStore; + impl trusted_server_core::platform::PlatformSecretStore for UnavailableSecretStore { + fn get_bytes(&self, _: &StoreName, _: &str) -> Result, Report> { + Err(Report::new(PlatformError::SecretStore).attach("use edgezero secret handle")) + } + fn create(&self, _: &StoreId, _: &str, _: &str) -> Result<(), Report> { + Err(Report::new(PlatformError::SecretStore).attach("writes not supported")) + } + fn delete(&self, _: &StoreId, _: &str) -> Result<(), Report> { + Err(Report::new(PlatformError::SecretStore).attach("deletes not supported")) + } + } + + RuntimeServices::builder() + .config_store(Arc::new(UnavailableConfigStore)) + .secret_store(Arc::new(UnavailableSecretStore)) + .kv_store(Arc::new(UnavailableKvStore)) + .backend(Arc::new(CloudflareBackend)) + .http_client(Arc::new(UnavailableHttpClient)) + .geo(Arc::new(CloudflareGeo)) + .client_info(ClientInfo { + client_ip, + tls_protocol: None, + tls_cipher: None, + }) + .build() +} +``` + +- [ ] **Step 2: Verify host compiles** + +```bash +cargo check -p trusted-server-adapter-cloudflare +``` + +Expected: `Finished`. + +- [ ] **Step 3: Commit** + +```bash +git add crates/trusted-server-adapter-cloudflare/src/platform.rs +git commit -m "Add Cloudflare platform trait implementations (cfg-gated)" +``` + +--- + +## Task 6: Entry point — `#[event(fetch)]` + cloudflare manifest + +**Files:** + +- Modify: `crates/trusted-server-adapter-cloudflare/src/lib.rs` +- Create: `crates/trusted-server-adapter-cloudflare/cloudflare.toml` +- Create: `crates/trusted-server-adapter-cloudflare/wrangler.toml` + +- [ ] **Step 1: Write the full `lib.rs`** + +```rust +pub mod app; +pub mod platform; + +/// Host-target shim — keeps the crate compilable on native for CI. +/// +/// The real `#[event(fetch)]` entry point is gated to +/// `cfg(all(feature = "cloudflare", target_arch = "wasm32"))`. +#[cfg(not(all(feature = "cloudflare", target_arch = "wasm32")))] +pub fn _host_build_shim() {} + +#[cfg(all(feature = "cloudflare", target_arch = "wasm32"))] +use worker::*; + +#[cfg(all(feature = "cloudflare", target_arch = "wasm32"))] +#[event(fetch)] +pub async fn main( + req: Request, + env: Env, + ctx: Context, +) -> Result { + edgezero_adapter_cloudflare::run_app::( + include_str!("../cloudflare.toml"), + req, + env, + ctx, + ) + .await +} +``` + +- [ ] **Step 2: Create `cloudflare.toml`** (edgezero manifest) + +```toml +[app] +name = "trusted-server" +version = "0.1.0" +kind = "http" + +[adapters.cloudflare] + +[stores.kv] +name = "trusted_server_kv" +[stores.kv.adapters] +cloudflare = "TRUSTED_SERVER_KV" + +[stores.config] +name = "trusted_server_config" +[stores.config.adapters] +cloudflare = "TRUSTED_SERVER_CONFIG" + +[stores.secrets] +name = "trusted_server_secrets" +[stores.secrets.adapters.cloudflare] +enabled = true +``` + +- [ ] **Step 3: Create `wrangler.toml`** + +```toml +name = "trusted-server" +main = "../../target/wasm32-unknown-unknown/release/trusted_server_adapter_cloudflare.wasm" +compatibility_date = "2024-09-23" +compatibility_flags = ["nodejs_compat"] + +[[kv_namespaces]] +binding = "TRUSTED_SERVER_KV" +id = "REPLACE_WITH_YOUR_KV_NAMESPACE_ID" + +[vars] +TRUSTED_SERVER_CONFIG = '{"publisher.domain":"your-publisher.com"}' +``` + +- [ ] **Step 4: Verify host compiles with lib changes** + +```bash +cargo check -p trusted-server-adapter-cloudflare +``` + +Expected: `Finished`. + +- [ ] **Step 5: Commit** + +```bash +git add crates/trusted-server-adapter-cloudflare/src/lib.rs \ + crates/trusted-server-adapter-cloudflare/cloudflare.toml \ + crates/trusted-server-adapter-cloudflare/wrangler.toml +git commit -m "Add Cloudflare Workers entry point and wrangler config" +``` + +--- + +## Task 7: Route smoke tests (host target) + +Same pattern as `trusted-server-adapter-axum/tests/routes.rs`. Uses `EdgeZeroAxumService` — wait, Cloudflare doesn't have an axum-style in-process service. Instead we test `TrustedServerApp::routes()` returns a valid `RouterService` by calling it on the host, without any Workers runtime. + +**Files:** + +- Create: `crates/trusted-server-adapter-cloudflare/tests/routes.rs` + +- [ ] **Step 1: Write `tests/routes.rs`** + +```rust +//! Smoke tests for the Cloudflare adapter route wiring. +//! +//! Runs on the host target (no Workers runtime). Verifies that +//! TrustedServerApp::routes() builds without panicking and that +//! the expected routes exist. Does not exercise the platform layer. + +use edgezero_core::app::Hooks as _; +use trusted_server_adapter_cloudflare::app::TrustedServerApp; + +#[test] +fn routes_build_without_panic() { + // build_state() may fail (no real settings on CI) — startup_error_router + // is the fallback. Either way, routes() must not panic. + let _router = TrustedServerApp::routes(); +} + +#[test] +fn crate_compiles_on_host_target() { + // Ensures the cfg-gated shim keeps the crate host-compilable. +} +``` + +- [ ] **Step 2: Run tests** + +```bash +cargo test -p trusted-server-adapter-cloudflare +``` + +Expected: `test result: ok. 2 passed`. + +- [ ] **Step 3: Commit** + +```bash +git add crates/trusted-server-adapter-cloudflare/tests/routes.rs +git commit -m "Add Cloudflare adapter smoke tests (host target)" +``` + +--- + +## Task 8: CI workflow + +**Files:** + +- Modify: `.github/workflows/test.yml` + +- [ ] **Step 1: Add `test-cloudflare` job** + +After the existing `test-axum` job, add: + +```yaml +test-cloudflare: + name: cargo check (cloudflare native + wasm32) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Retrieve Rust version + id: rust-version + run: echo "rust-version=$(grep '^rust ' .tool-versions | awk '{print $2}')" >> $GITHUB_OUTPUT + shell: bash + + - name: Set up Rust toolchain (native + wasm32-unknown-unknown) + uses: actions-rust-lang/setup-rust-toolchain@v1 + with: + toolchain: ${{ steps.rust-version.outputs.rust-version }} + target: wasm32-unknown-unknown + cache-shared-key: cargo-${{ runner.os }} + + - name: Check Cloudflare adapter (native host) + run: cargo check -p trusted-server-adapter-cloudflare + + - name: Check Cloudflare adapter (wasm32-unknown-unknown) + run: cargo check -p trusted-server-adapter-cloudflare --target wasm32-unknown-unknown --features cloudflare + + - name: Run Cloudflare adapter tests (native host) + run: cargo test -p trusted-server-adapter-cloudflare +``` + +- [ ] **Step 2: Verify test.yml is valid YAML** + +```bash +python3 -c "import yaml; yaml.safe_load(open('.github/workflows/test.yml'))" && echo "valid" +``` + +Expected: `valid`. + +- [ ] **Step 3: Commit** + +```bash +git add .github/workflows/test.yml +git commit -m "Add CI job for Cloudflare adapter (native check + wasm32-unknown-unknown check + tests)" +``` + +--- + +## Task 9: CLAUDE.md update + +**Files:** + +- Modify: `CLAUDE.md` + +- [ ] **Step 1: Add Cloudflare to workspace layout table** + +In the `## Workspace Layout` section, add: + +``` + trusted-server-adapter-cloudflare/ # Cloudflare Workers entry point (wasm32-unknown-unknown binary) +``` + +- [ ] **Step 2: Add build commands** + +In `## Build & Test Commands`, under `### Rust`: + +```bash +# Check Cloudflare adapter (native) +cargo check -p trusted-server-adapter-cloudflare + +# Check Cloudflare adapter (WASM target) +cargo check -p trusted-server-adapter-cloudflare --target wasm32-unknown-unknown --features cloudflare + +# Test Cloudflare adapter +cargo test -p trusted-server-adapter-cloudflare +``` + +- [ ] **Step 3: Commit** + +```bash +git add CLAUDE.md +git commit -m "Update CLAUDE.md: add Cloudflare adapter to workspace layout and commands" +``` + +--- + +## Task 10: Full verification pass + +- [ ] **Step 1: Format check** + +```bash +cargo fmt --all -- --check +``` + +Expected: no output (clean). + +- [ ] **Step 2: Clippy** + +```bash +cargo clippy --workspace --all-targets --all-features -- -D warnings +``` + +Expected: `Finished`. + +- [ ] **Step 3: Full test suite** + +```bash +cargo test --workspace --exclude trusted-server-adapter-axum --target wasm32-wasip1 +cargo test -p trusted-server-adapter-axum +cargo test -p trusted-server-adapter-cloudflare +``` + +Expected: all pass. + +- [ ] **Step 4: JS tests** + +```bash +cd crates/js/lib && npm run build && npm test -- --run +``` + +Expected: all pass. + +- [ ] **Step 5: Verify cloudflare WASM target check** + +```bash +cargo check -p trusted-server-adapter-cloudflare --target wasm32-unknown-unknown --features cloudflare +``` + +Expected: `Finished` (no panics, no unsupported types).