diff --git a/.cargo/config.toml b/.cargo/config.toml index 168f5884..774c04ff 100644 --- a/.cargo/config.toml +++ b/.cargo/config.toml @@ -1,23 +1,27 @@ # 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"] +clippy-cloudflare = ["clippy", "-p", "trusted-server-adapter-cloudflare", "--all-targets", "--all-features", "--", "-D", "warnings"] [target.'cfg(all(target_arch = "wasm32"))'] runner = "viceroy run -C ../../fastly.toml -- " diff --git a/.github/actions/setup-integration-test-env/action.yml b/.github/actions/setup-integration-test-env/action.yml index fe6f25b9..1b3b8686 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: @@ -113,3 +117,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 f4334f47..18b22097 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 2c570ef3..ae13cb91 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 d27ded91..2a79efe3 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: @@ -74,6 +74,33 @@ jobs: - name: Run Axum adapter tests run: cargo test-axum + 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/CLAUDE.md b/CLAUDE.md index 60c627e6..102b7047 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 ``` @@ -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) +cargo check -p trusted-server-adapter-cloudflare --target wasm32-unknown-unknown --features cloudflare + +# Test Cloudflare adapter (native host) +cargo test-cloudflare ``` ### Testing & Quality @@ -63,8 +73,9 @@ 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 diff --git a/Cargo.lock b/Cargo.lock index cc6eb77f..26b539f5 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" @@ -1949,6 +1967,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" @@ -2928,6 +2952,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" @@ -3527,6 +3562,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" @@ -3594,6 +3646,7 @@ dependencies = [ "urlencoding", "uuid", "validator", + "web-time", ] [[package]] @@ -3864,6 +3917,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" @@ -4262,6 +4328,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 60032d1e..f560b9eb 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", ] @@ -74,6 +75,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" @@ -95,5 +97,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 3d46000b..c8c1e740 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,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,8 +51,9 @@ cargo fmt cargo clippy --workspace --all-targets --all-features -- -D warnings # 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 9b98a638..090cf100 100644 --- a/crates/integration-tests/Cargo.lock +++ b/crates/integration-tests/Cargo.lock @@ -1220,6 +1220,7 @@ dependencies = [ "derive_more 2.1.1", "env_logger", "error-stack", + "libc", "log", "reqwest", "scraper", diff --git a/crates/integration-tests/Cargo.toml b/crates/integration-tests/Cargo.toml index 24d0cdcc..257b7878 100644 --- a/crates/integration-tests/Cargo.toml +++ b/crates/integration-tests/Cargo.toml @@ -18,3 +18,4 @@ serde_json = "1.0.149" error-stack = "0.6" derive_more = { version = "2.0", features = ["display"] } env_logger = "0.11" +libc = "0.2" diff --git a/crates/integration-tests/tests/environments/cloudflare.rs b/crates/integration-tests/tests/environments/cloudflare.rs new file mode 100644 index 00000000..c1cc85de --- /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 c3797e20..41b3d69c 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 288f1685..1d5bb181 100644 --- a/crates/integration-tests/tests/integration.rs +++ b/crates/integration-tests/tests/integration.rs @@ -135,6 +135,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/js/lib/package-lock.json b/crates/js/lib/package-lock.json index 6b4f3e21..4e972f90 100644 --- a/crates/js/lib/package-lock.json +++ b/crates/js/lib/package-lock.json @@ -27,6 +27,9 @@ "typescript-eslint": "^8.56.1", "vite": "^7.3.1", "vitest": "^4.0.8" + }, + "optionalDependencies": { + "@rollup/rollup-darwin-x64": "^4.60.1" } }, "node_modules/@acemir/cssom": { @@ -99,6 +102,7 @@ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -1728,6 +1732,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=20.19.0" }, @@ -1768,6 +1773,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=20.19.0" } @@ -2588,13 +2594,12 @@ ] }, "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.59.0.tgz", - "integrity": "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==", + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.1.tgz", + "integrity": "sha512-haZ7hJ1JT4e9hqkoT9R/19XW2QKqjfJVv+i5AGg57S+nLk9lQnJ1F/eZloRO3o9Scy9CM3wQ9l+dkXtcBgN5Ew==", "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3024,6 +3029,7 @@ "integrity": "sha512-klQbnPAAiGYFyI02+znpBRLyjL4/BrBd0nyWkdC0s/6xFLkXYQ8OoRrSkqacS1ddVxf/LDyODIKbQ5TgKAf/Fg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.56.1", "@typescript-eslint/types": "8.56.1", @@ -3352,6 +3358,7 @@ "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -3401,7 +3408,6 @@ "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-2.1.1.tgz", "integrity": "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==", "license": "MIT", - "peer": true, "dependencies": { "ajv": "^8.0.0" }, @@ -3419,7 +3425,6 @@ "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -3435,8 +3440,7 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/ansi-colors": { "version": "1.1.0", @@ -3847,6 +3851,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -4707,6 +4712,7 @@ "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -5317,8 +5323,7 @@ "url": "https://opencollective.com/fastify" } ], - "license": "BSD-3-Clause", - "peer": true + "license": "BSD-3-Clause" }, "node_modules/fdir": { "version": "6.5.0", @@ -7205,6 +7210,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -7644,6 +7650,20 @@ "fsevents": "~2.3.2" } }, + "node_modules/rollup/node_modules/@rollup/rollup-darwin-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.59.0.tgz", + "integrity": "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, "node_modules/safe-array-concat": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.3.tgz", @@ -7743,7 +7763,6 @@ "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.3.3.tgz", "integrity": "sha512-eflK8wEtyOE6+hsaRVPxvUKYCpRgzLqDTb8krvAsRIwOGlHoSgYLgBXoubGgLd2fT41/OUYdb48v4k4WWHQurA==", "license": "MIT", - "peer": true, "dependencies": { "@types/json-schema": "^7.0.9", "ajv": "^8.9.0", @@ -7780,7 +7799,6 @@ "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-5.1.0.tgz", "integrity": "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==", "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3" }, @@ -7792,8 +7810,7 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/semver": { "version": "7.7.4", @@ -8535,6 +8552,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -8763,6 +8781,7 @@ "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", diff --git a/crates/trusted-server-adapter-cloudflare/.gitignore b/crates/trusted-server-adapter-cloudflare/.gitignore new file mode 100644 index 00000000..f3cade26 --- /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 00000000..0cc3a0c4 --- /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 00000000..c6a042cf --- /dev/null +++ b/crates/trusted-server-adapter-cloudflare/build.sh @@ -0,0 +1,20 @@ +#!/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" +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 00000000..f505e6b2 --- /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 00000000..2c28c73b --- /dev/null +++ b/crates/trusted-server-adapter-cloudflare/src/app.rs @@ -0,0 +1,370 @@ +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::{ + PublisherResponse, handle_publisher_request, handle_tsjs_dynamic, stream_publisher_body, +}; +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) +} + +// --------------------------------------------------------------------------- +// Publisher response helper +// --------------------------------------------------------------------------- + +/// Collapse a [`PublisherResponse`] into a plain [`Response`]. +/// +/// Buffers streaming and pass-through variants in memory (acceptable for a +/// Workers invocation which processes one request at a time). +fn resolve_publisher_response( + publisher_response: PublisherResponse, + settings: &Settings, + registry: &IntegrationRegistry, +) -> Result> { + match publisher_response { + PublisherResponse::Buffered(response) => Ok(response), + PublisherResponse::Stream { + mut response, + body, + params, + } => { + let mut output = Vec::new(); + stream_publisher_body(body, &mut output, ¶ms, settings, registry)?; + response.headers_mut().insert( + header::CONTENT_LENGTH, + edgezero_core::http::HeaderValue::from(output.len() as u64), + ); + *response.body_mut() = edgezero_core::body::Body::from(output); + Ok(response) + } + PublisherResponse::PassThrough { mut response, body } => { + *response.body_mut() = body; + 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); + } + }; + + // /.well-known/trusted-server.json + let s = Arc::clone(&state); + let discovery_handler = move |ctx: RequestContext| { + let s = Arc::clone(&s); + async move { + let services = build_per_request_services(&ctx); + let req = ctx.into_request(); + Ok(handle_trusted_server_discovery(&s.settings, &services, req) + .unwrap_or_else(|e| http_error(&e))) + } + }; + + // /verify-signature + let s = Arc::clone(&state); + let verify_handler = move |ctx: RequestContext| { + let s = Arc::clone(&s); + async move { + let services = build_per_request_services(&ctx); + let req = ctx.into_request(); + Ok(handle_verify_signature(&s.settings, &services, req) + .unwrap_or_else(|e| http_error(&e))) + } + }; + + // /admin/keys/rotate + let s = Arc::clone(&state); + let rotate_handler = move |ctx: RequestContext| { + let s = Arc::clone(&s); + async move { + let services = build_per_request_services(&ctx); + let req = ctx.into_request(); + Ok(handle_rotate_key(&s.settings, &services, req) + .unwrap_or_else(|e| http_error(&e))) + } + }; + + // /admin/keys/deactivate + let s = Arc::clone(&state); + let deactivate_handler = move |ctx: RequestContext| { + let s = Arc::clone(&s); + async move { + let services = build_per_request_services(&ctx); + let req = ctx.into_request(); + Ok(handle_deactivate_key(&s.settings, &services, req) + .unwrap_or_else(|e| http_error(&e))) + } + }; + + // /auction + let s = Arc::clone(&state); + let auction_handler = move |ctx: RequestContext| { + let s = Arc::clone(&s); + async move { + let services = build_per_request_services(&ctx); + let req = ctx.into_request(); + Ok(handle_auction(&s.settings, &s.orchestrator, &services, req) + .await + .unwrap_or_else(|e| http_error(&e))) + } + }; + + // GET /first-party/proxy + let s = Arc::clone(&state); + let fp_proxy_handler = move |ctx: RequestContext| { + let s = Arc::clone(&s); + async move { + let services = build_per_request_services(&ctx); + let req = ctx.into_request(); + Ok(handle_first_party_proxy(&s.settings, &services, req) + .await + .unwrap_or_else(|e| http_error(&e))) + } + }; + + // /first-party/click + let s = Arc::clone(&state); + let fp_click_handler = move |ctx: RequestContext| { + let s = Arc::clone(&s); + async move { + let services = build_per_request_services(&ctx); + let req = ctx.into_request(); + Ok(handle_first_party_click(&s.settings, &services, req) + .await + .unwrap_or_else(|e| http_error(&e))) + } + }; + + // GET /first-party/sign + let s = Arc::clone(&state); + let fp_sign_get_handler = move |ctx: RequestContext| { + let s = Arc::clone(&s); + async move { + let services = build_per_request_services(&ctx); + let req = ctx.into_request(); + Ok(handle_first_party_proxy_sign(&s.settings, &services, req) + .await + .unwrap_or_else(|e| http_error(&e))) + } + }; + + // POST /first-party/sign + let s = Arc::clone(&state); + let fp_sign_post_handler = move |ctx: RequestContext| { + let s = Arc::clone(&s); + async move { + let services = build_per_request_services(&ctx); + let req = ctx.into_request(); + Ok(handle_first_party_proxy_sign(&s.settings, &services, req) + .await + .unwrap_or_else(|e| http_error(&e))) + } + }; + + // /first-party/proxy-rebuild + let s = Arc::clone(&state); + let fp_rebuild_handler = move |ctx: RequestContext| { + let s = Arc::clone(&s); + async move { + let services = build_per_request_services(&ctx); + let req = ctx.into_request(); + Ok( + handle_first_party_proxy_rebuild(&s.settings, &services, req) + .await + .unwrap_or_else(|e| http_error(&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) { + state + .registry + .handle_proxy(&method, &path, &state.settings, &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))) + } + + // GET /{*rest} — tsjs, integration proxy, or publisher fallback + let s = Arc::clone(&state); + let get_fallback = move |ctx: RequestContext| { + let s = Arc::clone(&s); + dispatch(s, ctx, true) + }; + + // POST /{*rest} — integration proxy or publisher origin fallback + let s = Arc::clone(&state); + let post_fallback = 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", discovery_handler) + .post("/verify-signature", verify_handler) + .post("/admin/keys/rotate", rotate_handler) + .post("/admin/keys/deactivate", deactivate_handler) + .post("/auction", auction_handler) + .get("/first-party/proxy", fp_proxy_handler) + .get("/first-party/click", fp_click_handler) + .get("/first-party/sign", fp_sign_get_handler) + .post("/first-party/sign", fp_sign_post_handler) + .post("/first-party/proxy-rebuild", fp_rebuild_handler) + .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 00000000..76ded81e --- /dev/null +++ b/crates/trusted-server-adapter-cloudflare/src/lib.rs @@ -0,0 +1,25 @@ +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 00000000..0eb9a63f --- /dev/null +++ b/crates/trusted-server-adapter-cloudflare/src/middleware.rs @@ -0,0 +1,220 @@ +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 { + let mut s = + trusted_server_core::settings_data::get_settings().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 00000000..7e03c8d4 --- /dev/null +++ b/crates/trusted-server-adapter-cloudflare/src/platform.rs @@ -0,0 +1,660 @@ +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. Multi-provider +/// auctions (more than one DSP) are therefore not supported: `select` will +/// return `PlatformError::HttpClient` when called with 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(); + let method = Method::from(request.request.method().as_str().to_ascii_uppercase()); + + let headers = Headers::new(); + for (name, value) in request.request.headers() { + headers + .set(name.as_str(), &String::from_utf8_lossy(value.as_bytes())) + .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. + if matches!( + name.to_ascii_lowercase().as_str(), + "content-encoding" | "transfer-encoding" + ) { + continue; + } + edge_builder = edge_builder.header(name.as_str(), value.as_bytes()); + } + let body_bytes = resp + .bytes() + .await + .change_context(PlatformError::HttpClient)?; + 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 + } + + 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, so + // this branch is unreachable in practice. + edgezero_core::body::Body::Stream(_) => { + unreachable!("CloudflareHttpClient::execute always returns 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")); + } + + // Cloudflare Workers does not support concurrent fetch calls through the + // ?Send PlatformHttpClient interface. send_async() executes each request + // eagerly, so multi-provider auctions accrue sum(DSP_i) latency instead + // of max(DSP_i) and per-provider timeouts baked into the backend name are + // not enforced. Reject multi-provider fan-out loudly rather than silently + // degrading the auction budget. + if pending_requests.len() >= 2 { + return Err(Report::new(PlatformError::HttpClient).attach(format!( + "CloudflareHttpClient: multi-provider fan-out is not supported \ + ({} providers submitted). Configure a single auction provider \ + or use the Fastly adapter for parallel DSP fan-out.", + 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, + }) + } +} + +// --------------------------------------------------------------------------- +// 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) { + 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, + })) + } +} + +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()) +} + +#[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).unwrap()) + .body(edgezero_core::body::Body::empty()) + .unwrap(); + 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()) + .unwrap(); + 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().unwrap()), + "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).unwrap().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()) + .unwrap(); + let ctx = RequestContext::new(req, PathParams::default()); + let geo = build_geo(&ctx); + let info = geo + .lookup(None) + .unwrap() + .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" + ); + } +} 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 00000000..831e6bd4 --- /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 00000000..d68be33a --- /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 00000000..060d4fd9 --- /dev/null +++ b/crates/trusted-server-adapter-cloudflare/wrangler.toml @@ -0,0 +1,18 @@ +name = "trusted-server" +main = "build/index.js" +compatibility_date = "2024-09-23" +compatibility_flags = ["nodejs_compat"] + +[build] +command = "bash build.sh" + +[[kv_namespaces]] +binding = "TRUSTED_SERVER_KV" +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 704dc515..c4ea9088 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 5e0ceb33..45d65809 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; use crate::platform::{PlatformPendingRequest, RuntimeServices}; @@ -622,6 +623,9 @@ impl OrchestrationResult { #[cfg(test)] mod tests { + use std::time::Duration; + use web_time::Instant; + use crate::auction::config::AuctionConfig; use crate::auction::test_support::create_test_auction_context; use crate::auction::types::{ @@ -803,7 +807,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!( @@ -815,7 +819,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"); @@ -823,8 +827,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, diff --git a/crates/trusted-server-core/src/consent/mod.rs b/crates/trusted-server-core/src/consent/mod.rs index 36e7e628..1f3db8c6 100644 --- a/crates/trusted-server-core/src/consent/mod.rs +++ b/crates/trusted-server-core/src/consent/mod.rs @@ -40,7 +40,7 @@ pub use types::{ ConsentContext, ConsentSource, PrivacyFlag, RawConsentSignals, TcfConsent, UsPrivacy, }; -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 b6efe1b4..d8d3b052 100644 --- a/crates/trusted-server-core/src/platform/http.rs +++ b/crates/trusted-server-core/src/platform/http.rs @@ -148,6 +148,43 @@ pub struct PlatformSelectResult { pub remaining: Vec, } +/// 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 diff --git a/crates/trusted-server-core/src/platform/mod.rs b/crates/trusted-server-core/src/platform/mod.rs index e92de7ac..600bff57 100644 --- a/crates/trusted-server-core/src/platform/mod.rs +++ b/crates/trusted-server-core/src/platform/mod.rs @@ -45,7 +45,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/proxy.rs b/crates/trusted-server-core/src/proxy.rs index fd693f22..b5b7eca7 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 176f75e1..516e2f52 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 00000000..8ae93a6a --- /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).