diff --git a/.cargo/config.toml b/.cargo/config.toml new file mode 100644 index 00000000..a868107a --- /dev/null +++ b/.cargo/config.toml @@ -0,0 +1,9 @@ +# Intentionally empty — runners for `wasm32-wasip1` differ per adapter +# (Viceroy for fastly, Wasmtime for spin) and a single workspace-wide +# default would silently pick the wrong host ABI for one of them. +# +# Per-package configs at `crates/edgezero-adapter-{fastly,spin}/.cargo/` +# wire up the right runner when cargo is invoked from inside the package +# directory. For workspace-root invocations, set +# `CARGO_TARGET_WASM32_WASIP1_RUNNER` explicitly (CI does this in +# `.github/workflows/test.yml`). diff --git a/.claude/agents/build-validator.md b/.claude/agents/build-validator.md index 076f1b4e..a17269dc 100644 --- a/.claude/agents/build-validator.md +++ b/.claude/agents/build-validator.md @@ -27,7 +27,7 @@ cargo check -p edgezero-core --all-features cargo check -p edgezero-adapter-fastly --features cli cargo check -p edgezero-adapter-cloudflare --features cli cargo check -p edgezero-adapter-axum --features axum -cargo check -p edgezero-cli --features dev-example +cargo check -p edgezero-cli --features demo-example ``` ## Demo apps diff --git a/.claude/agents/verify-app.md b/.claude/agents/verify-app.md index e3a7407e..36ac6a18 100644 --- a/.claude/agents/verify-app.md +++ b/.claude/agents/verify-app.md @@ -50,7 +50,7 @@ Demo adapters must build for their respective WASM targets. ## 6. Dev server smoke test ``` -cargo run -p edgezero-cli --features dev-example -- dev & +cargo run -p edgezero-cli --features demo-example -- demo & pid=$! trap 'kill "$pid" 2>/dev/null || true; wait "$pid" 2>/dev/null || true' EXIT sleep 3 diff --git a/.claude/settings.json b/.claude/settings.json index c671bef2..e7e94052 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -1,32 +1,30 @@ { "permissions": { "allow": [ - "Bash(ls:*)", - "Bash(cat:*)", - "Bash(head:*)", - "Bash(tail:*)", - "Bash(wc:*)", - "Bash(tree:*)", - "Bash(which:*)", - "Bash(cargo build:*)", - "Bash(cargo test:*)", "Bash(cargo check:*)", + "Bash(cargo clippy:*)", + "Bash(cargo fmt:*)", "Bash(cargo metadata:*)", "Bash(cargo run -p edgezero-cli:*)", - - "Bash(cargo fmt:*)", - "Bash(cargo clippy:*)", - + "Bash(cargo test:*)", + "Bash(cat:*)", + "Bash(git branch:*)", + "Bash(git diff:*)", + "Bash(git log:*)", + "Bash(git status:*)", + "Bash(head:*)", + "Bash(ls:*)", "Bash(npm ci:*)", "Bash(npm run:*)", - "Bash(rustup target:*)", - - "Bash(git status:*)", - "Bash(git diff:*)", - "Bash(git log:*)", - "Bash(git branch:*)" + "Bash(tail:*)", + "Bash(tree:*)", + "Bash(wc:*)", + "Bash(which:*)" ] + }, + "enabledPlugins": { + "superpowers@claude-plugins-official": true } } diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index ef309532..7e2ec4dc 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -25,9 +25,12 @@ Closes # - [ ] `cargo test --workspace --all-targets` - [ ] `cargo clippy --workspace --all-targets --all-features -- -D warnings` -- [ ] `cargo check --workspace --all-targets --features "fastly cloudflare"` -- [ ] WASM builds: `wasm32-wasip1` (Fastly) / `wasm32-unknown-unknown` (Cloudflare) -- [ ] Manual testing via `edgezero-cli dev` +- [ ] `cargo fmt --all -- --check` +- [ ] `cargo check --workspace --all-targets --features "fastly cloudflare spin"` +- [ ] WASM builds: `wasm32-wasip1` (Fastly) / `wasm32-wasip2` (Spin) / `wasm32-unknown-unknown` (Cloudflare) +- [ ] `examples/app-demo` workspace: `cd examples/app-demo && cargo test --workspace --all-targets` +- [ ] Docs build: `cd docs && npm run lint && npm run format && npm run build` +- [ ] Manual testing via `edgezero serve --adapter axum` (the pre-rewrite `edgezero-cli dev` was renamed; see [cli-reference](docs/guide/cli-reference.md#edgezero-demo)) - [ ] Other: ## Checklist @@ -36,5 +39,6 @@ Closes # - [ ] No Tokio deps added to core or adapter crates - [ ] Route params use `{id}` syntax (not `:id`) - [ ] Types imported from `edgezero_core` (not `http` crate) +- [ ] Store wiring goes through `KvRegistry` / `ConfigRegistry` / `SecretRegistry` (not the legacy single-handle setters) — see spec §6.6 - [ ] New code has tests - [ ] No secrets or credentials committed diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index c23f862b..30a452a6 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -59,7 +59,7 @@ jobs: # your codebase is analyzed, see https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/codeql-code-scanning-for-compiled-languages steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@v6 # Add any setup steps before running the `github/codeql-action/init` action. # This includes steps like installing compilers or runtimes (`actions/setup-node` diff --git a/.github/workflows/deploy-docs.yml b/.github/workflows/deploy-docs.yml index ab71a3ca..c34d0ccb 100644 --- a/.github/workflows/deploy-docs.yml +++ b/.github/workflows/deploy-docs.yml @@ -24,7 +24,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v6 with: fetch-depth: 0 # For lastUpdated feature @@ -38,14 +38,14 @@ jobs: fi - name: Setup Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@v6 with: node-version: ${{ steps.node-version.outputs.node-version }} cache: "npm" cache-dependency-path: docs/package-lock.json - name: Setup Pages - uses: actions/configure-pages@v4 + uses: actions/configure-pages@v6 - name: Install dependencies working-directory: docs @@ -56,7 +56,7 @@ jobs: run: npm run build - name: Upload artifact - uses: actions/upload-pages-artifact@v3 + uses: actions/upload-pages-artifact@v5 with: path: docs/.vitepress/dist @@ -69,4 +69,4 @@ jobs: steps: - name: Deploy to GitHub Pages id: deployment - uses: actions/deploy-pages@v4 + uses: actions/deploy-pages@v5 diff --git a/.github/workflows/format.yml b/.github/workflows/format.yml index 8a9bb3da..8a842537 100644 --- a/.github/workflows/format.yml +++ b/.github/workflows/format.yml @@ -18,10 +18,10 @@ jobs: name: cargo fmt runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Cache cargo dependencies - uses: actions/cache@v4 + uses: actions/cache@v5 with: path: | ~/.cargo/registry/index/ @@ -52,6 +52,67 @@ jobs: - name: Run cargo clippy run: cargo clippy --workspace --all-targets --all-features -- -D warnings + # Plan task 8.3 corollary: `examples/app-demo` is excluded + # from the root workspace, so the fmt/clippy steps above + # don't cover it. Run the same gates against it explicitly + # — the app-demo workspace has the same strict-clippy + # config and is the showcase a new user clones. + - name: Run cargo fmt (app-demo workspace) + working-directory: examples/app-demo + run: cargo fmt --all -- --check + + - name: Run cargo clippy (app-demo workspace) + working-directory: examples/app-demo + run: cargo clippy --workspace --all-targets --all-features -- -D warnings + + adapter-wasm-clippy: + name: ${{ matrix.adapter }} wasm clippy + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + include: + - adapter: cloudflare + target: wasm32-unknown-unknown + - adapter: fastly + target: wasm32-wasip1 + - adapter: spin + target: wasm32-wasip2 + steps: + - uses: actions/checkout@v6 + + - name: Cache Cargo dependencies + uses: actions/cache@v5 + with: + path: | + ~/.cargo/registry/index/ + ~/.cargo/registry/cache/ + ~/.cargo/git/db/ + target/ + key: ${{ runner.os }}-cargo-${{ matrix.adapter }}-clippy-${{ hashFiles('**/Cargo.lock') }} + restore-keys: | + ${{ runner.os }}-cargo-${{ matrix.adapter }}-clippy- + + - 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 tool chain + uses: actions-rust-lang/setup-rust-toolchain@v1 + with: + toolchain: ${{ steps.rust-version.outputs.rust-version }} + components: clippy + + - name: Add wasm target + run: rustup target add ${{ matrix.target }} + + - name: Fetch dependencies (locked) + run: cargo fetch --locked + + - name: Clippy ${{ matrix.adapter }} on ${{ matrix.target }} + run: cargo clippy -p edgezero-adapter-${{ matrix.adapter }} --features ${{ matrix.adapter }} --target ${{ matrix.target }} --all-targets -- -D warnings + format-docs: runs-on: ubuntu-latest defaults: @@ -59,7 +120,7 @@ jobs: working-directory: docs steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Retrieve Node.js version id: node-version @@ -68,7 +129,7 @@ jobs: shell: bash - name: Use Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@v6 with: node-version: ${{ steps.node-version.outputs.node-version }} cache: "npm" diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index b32fa75a..456a2e38 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -18,12 +18,13 @@ jobs: name: cargo test runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Cache Cargo dependencies - uses: actions/cache@v4 + uses: actions/cache@v5 with: path: | + ~/.cargo/bin/ ~/.cargo/registry/index/ ~/.cargo/registry/cache/ ~/.cargo/git/db/ @@ -43,7 +44,7 @@ jobs: toolchain: ${{ steps.rust-version.outputs.rust-version }} - name: Add wasm targets - run: rustup target add wasm32-wasip1 wasm32-unknown-unknown + run: rustup target add wasm32-wasip1 wasm32-wasip2 wasm32-unknown-unknown - name: Fetch dependencies (locked) run: cargo fetch --locked @@ -54,6 +55,23 @@ jobs: - name: Check feature compilation run: cargo check --workspace --all-targets --features "fastly cloudflare spin" + - name: Verify a generated project compiles + run: cargo test -p edgezero-cli --test generated_project_builds -- --ignored + + # `examples/app-demo` is excluded from the root workspace, so + # `cargo test --workspace` above does not cover it. Run its own + # workspace tests separately. An end-to-end push → + # AxumConfigStore → handler roundtrip in + # `app-demo-cli/tests/config_flow.rs` exists to be exercised by + # THIS step — without it, a regression in the JSON-file contract + # between `config push --adapter axum` and + # `AxumConfigStore::from_path` would not be caught by CI. + # Axum-only path, no live external calls — intentionally kept + # off the wasm matrix. + - name: Run app-demo workspace tests + working-directory: examples/app-demo + run: cargo test --workspace --all-targets + adapter-wasm-tests: name: ${{ matrix.adapter }} wasm tests runs-on: ubuntu-latest @@ -70,22 +88,23 @@ jobs: runner_env: CARGO_TARGET_WASM32_WASIP1_RUNNER runner_value: viceroy run - adapter: spin - target: wasm32-wasip1 - runner_env: CARGO_TARGET_WASM32_WASIP1_RUNNER + target: wasm32-wasip2 + runner_env: CARGO_TARGET_WASM32_WASIP2_RUNNER runner_value: wasmtime run steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Cache Cargo dependencies - uses: actions/cache@v4 + uses: actions/cache@v5 with: path: | + ~/.cargo/bin/ ~/.cargo/registry/index/ ~/.cargo/registry/cache/ ~/.cargo/git/db/ ~/.wasmtime/bin/ target/ - key: ${{ runner.os }}-cargo-${{ matrix.adapter }}-${{ hashFiles('**/Cargo.lock') }} + key: ${{ runner.os }}-cargo-${{ matrix.adapter }}-${{ hashFiles('**/Cargo.lock', '.tool-versions') }} restore-keys: | ${{ runner.os }}-cargo-${{ matrix.adapter }}- @@ -120,36 +139,62 @@ jobs: test -n "$version" echo "version=$version" >> "$GITHUB_OUTPUT" + # `--force` is required because the cargo cache may restore an existing + # `~/.cargo/bin/` from a prior run, which `cargo install` rejects + # by default. Force-overwriting is safe — `--locked` pins the version. - name: Install wasm-bindgen test runner if: matrix.adapter == 'cloudflare' - run: | - required="${{ steps.wasm-bindgen-version.outputs.version }}" - if ! command -v wasm-bindgen-test-runner &>/dev/null \ - || ! wasm-bindgen --version 2>/dev/null | grep -q "$required"; then - cargo install wasm-bindgen-cli --version "$required" --locked --force - fi + run: cargo install wasm-bindgen-cli --version "${{ steps.wasm-bindgen-version.outputs.version }}" --locked --force + + - name: Resolve Viceroy version + if: matrix.adapter == 'fastly' + id: viceroy-version + shell: bash + run: echo "version=$(grep '^viceroy ' .tool-versions | awk '{print $2}')" >> "$GITHUB_OUTPUT" - name: Setup Viceroy if: matrix.adapter == 'fastly' - env: - VICEROY_VERSION: 0.16.5 - run: | - if ! command -v viceroy &>/dev/null \ - || ! viceroy --version 2>/dev/null | grep -qF "$VICEROY_VERSION"; then - cargo install viceroy --version "$VICEROY_VERSION" --locked - fi + # Version comes from .tool-versions (single source of truth shared with + # local dev). + run: cargo install viceroy --version "${{ steps.viceroy-version.outputs.version }}" --locked --force + + - name: Resolve Wasmtime version + if: matrix.adapter == 'spin' + id: wasmtime-version + shell: bash + run: echo "version=$(grep '^wasmtime ' .tool-versions | awk '{print $2}')" >> "$GITHUB_OUTPUT" - name: Setup Wasmtime if: matrix.adapter == 'spin' - env: - WASMTIME_VERSION: v44.0.0 + # Direct GitHub-release tarball install. The official + # `https://wasmtime.dev/install.sh` script broke as of + # 2026-05-19 (interpolation failure: tried to download + # version literal `{`), so we pin via .tool-versions and + # land the binary in `~/.wasmtime/bin/` so the cache step + # above actually short-circuits the download on subsequent + # runs. + # + # The cache key now hashes `.tool-versions` too, so a + # version bump invalidates the cached binary. We also + # explicitly compare `wasmtime --version` against the pin + # — without that a runner-provided wasmtime or a stale + # cache hit would silently shadow the pinned version. run: | - export PATH="$HOME/.wasmtime/bin:$PATH" - echo "$HOME/.wasmtime/bin" >> "$GITHUB_PATH" + install_dir="$HOME/.wasmtime/bin" + echo "$install_dir" >> "$GITHUB_PATH" + export PATH="$install_dir:$PATH" + version="${{ steps.wasmtime-version.outputs.version }}" if ! command -v wasmtime &>/dev/null \ - || ! wasmtime --version 2>/dev/null | grep -qF "${WASMTIME_VERSION#v}"; then - curl https://wasmtime.dev/install.sh -sSf | bash -s -- --version "$WASMTIME_VERSION" + || ! wasmtime --version 2>/dev/null | grep -qF "wasmtime $version"; then + tag="v${version}" + archive="wasmtime-${tag}-x86_64-linux" + mkdir -p "$install_dir" + curl -fL "https://github.com/bytecodealliance/wasmtime/releases/download/${tag}/${archive}.tar.xz" -o /tmp/wasmtime.tar.xz + tar -xJf /tmp/wasmtime.tar.xz -C /tmp + install -m 0755 "/tmp/${archive}/wasmtime" "$install_dir/wasmtime" fi + wasmtime --version | grep -qF "wasmtime $version" + wasmtime --version - name: Fetch dependencies (locked) run: cargo fetch --locked diff --git a/.gitignore b/.gitignore index 0389097e..ce82db2f 100644 --- a/.gitignore +++ b/.gitignore @@ -18,9 +18,6 @@ target/ # Worktrees .worktrees/ -# Superpowers plans -docs/superpowers/ - # Editors .claude/* !.claude/settings.json @@ -33,3 +30,4 @@ docs/superpowers/ !.vscode/tasks.json !.vscode/launch.json !.vscode/extensions.json +*.rlib diff --git a/.tool-versions b/.tool-versions index 9934717c..f386dc5f 100644 --- a/.tool-versions +++ b/.tool-versions @@ -1,3 +1,5 @@ -fasltly v13.0.0 +fastly 15.1.0 nodejs 24.12.0 -rust 1.91.1 +rust 1.95.0 +viceroy 0.17.0 +wasmtime 44.0.1 diff --git a/CLAUDE.md b/CLAUDE.md index 8f0e92b6..6a0495c5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -17,9 +17,9 @@ crates/ edgezero-adapter/ # Adapter registry and traits edgezero-adapter-fastly/ # Fastly Compute bridge (wasm32-wasip1) edgezero-adapter-cloudflare/# Cloudflare Workers bridge (wasm32-unknown-unknown) - edgezero-adapter-spin/ # Fermyon Spin bridge (wasm32-wasip1) + edgezero-adapter-spin/ # Fermyon Spin bridge (wasm32-wasip2) edgezero-adapter-axum/ # Axum/Tokio bridge (native, dev server) - edgezero-cli/ # CLI: new, build, deploy, dev, serve + edgezero-cli/ # CLI lib + bin: new, build, deploy, serve, auth, provision, config (validate|push), demo examples/app-demo/ # Reference app with all 4 adapters (excluded from workspace) docs/ # VitePress documentation site (Node.js) scripts/ # Build/deploy/test helper scripts @@ -27,9 +27,9 @@ scripts/ # Build/deploy/test helper scripts ## Toolchain & Versions -- **Rust**: 1.91.1 (from `.tool-versions`) +- **Rust**: 1.95.0 (from `.tool-versions`) - **Node.js**: 24.12.0 (for docs site only) -- **Fastly CLI**: v13.0.0 +- **Fastly CLI**: 15.1.0 - **Edition**: 2021 - **Resolver**: 2 - **License**: Apache-2.0 @@ -53,10 +53,10 @@ cargo clippy --workspace --all-targets --all-features -- -D warnings cargo check --workspace --all-targets --features "fastly cloudflare spin" # Spin wasm32 compilation check -cargo check -p edgezero-adapter-spin --target wasm32-wasip1 --features spin +cargo check -p edgezero-adapter-spin --target wasm32-wasip2 --features spin -# Run the demo dev server -cargo run -p edgezero-cli --features dev-example -- dev +# Run the demo server +cargo run -p edgezero-cli --features demo-example -- demo # Docs site cd docs && npm ci && npm run dev @@ -71,7 +71,7 @@ faster iteration on a single crate. | ---------- | ------------------------ | ---------------------------------- | | Fastly | `wasm32-wasip1` | Requires Viceroy for local testing | | Cloudflare | `wasm32-unknown-unknown` | Requires `wrangler` for dev/deploy | -| Spin | `wasm32-wasip1` | Requires `spin` CLI for dev/deploy | +| Spin | `wasm32-wasip2` | Requires `spin` CLI for dev/deploy | | Axum | Native (host triple) | Standard Tokio runtime | ## Coding Conventions @@ -170,7 +170,7 @@ Each adapter follows the same structure: - `response.rs` — core response → platform response conversion - `proxy.rs` — platform-specific proxy client - `logger.rs` — platform-specific logging init -- `cli.rs` — build/deploy commands (behind `cli` feature) +- `cli.rs` — adapter dispatch behind the `cli` feature: `build` / `deploy` / `serve` (legacy) plus `Adapter::execute` for `auth` (login/logout/status) and dedicated trait methods `provision` (Stage 6 — platform-resource creation) and `push_config_entries` (Stage 7 — `config push` writeback). Self-registers via `#[ctor]` into the `edgezero-adapter` registry. Contract tests live in `tests/contract.rs` within each adapter crate. @@ -187,7 +187,7 @@ Every PR must pass: 2. `cargo clippy --workspace --all-targets --all-features -- -D warnings` 3. `cargo test --workspace --all-targets` 4. `cargo check --workspace --all-targets --features "fastly cloudflare spin"` -5. `cargo check -p edgezero-adapter-spin --target wasm32-wasip1 --features spin` +5. `cargo check -p edgezero-adapter-spin --target wasm32-wasip2 --features spin` Docs CI additionally runs ESLint + Prettier on the `docs/` directory. diff --git a/Cargo.lock b/Cargo.lock index 29d7c261..d2988207 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8,6 +8,18 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "once_cell", + "version_check", + "zerocopy", +] + [[package]] name = "aho-corasick" version = "1.1.4" @@ -58,9 +70,9 @@ dependencies = [ [[package]] name = "anstyle" -version = "1.0.13" +version = "1.0.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" [[package]] name = "anstyle-parse" @@ -111,9 +123,9 @@ dependencies = [ [[package]] name = "async-compression" -version = "0.4.41" +version = "0.4.42" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d0f9ee0f6e02ffd7ad5816e9464499fba7b3effd01123b515c41d1697c43dad1" +checksum = "e79b3f8a79cccc2898f31920fc69f304859b3bd567490f75ebf51ae1c792a9ac" dependencies = [ "compression-codecs", "compression-core", @@ -162,15 +174,15 @@ checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" [[package]] name = "autocfg" -version = "1.5.0" +version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" [[package]] name = "aws-lc-rs" -version = "1.15.4" +version = "1.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7b7b6141e96a8c160799cc2d5adecd5cbbe5054cb8c7c4af53da0f83bb7ad256" +checksum = "5ec2f1fc3ec205783a5da9a7e6c1509cc69dedf09a1949e412c1e18469326d00" dependencies = [ "aws-lc-sys", "zeroize", @@ -178,9 +190,9 @@ dependencies = [ [[package]] name = "aws-lc-sys" -version = "0.37.1" +version = "0.41.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b092fe214090261288111db7a2b2c2118e5a7f30dc2569f1732c4069a6840549" +checksum = "1a2f9779ce85b93ab6170dd940ad0169b5766ff848247aff13bb788b832fe3f4" dependencies = [ "cc", "cmake", @@ -190,9 +202,9 @@ dependencies = [ [[package]] name = "axum" -version = "0.8.8" +version = "0.8.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b52af3cb4058c895d37317bb27508dccc8e5f2d39454016b297bf4a400597b8" +checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" dependencies = [ "axum-core", "axum-macros", @@ -243,9 +255,9 @@ dependencies = [ [[package]] name = "axum-macros" -version = "0.5.0" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "604fde5e028fea851ce1d8570bbdc034bec850d157f7569d10f347d06808c05c" +checksum = "7aa268c23bfbbd2c4363b9cd302a4f504fb2a9dfe7e3451d66f35dd392e20aca" dependencies = [ "proc-macro2", "quote", @@ -266,9 +278,9 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" [[package]] name = "bitflags" -version = "2.10.0" +version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" +checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" [[package]] name = "block-buffer" @@ -290,9 +302,9 @@ dependencies = [ [[package]] name = "brotli" -version = "8.0.2" +version = "8.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4bd8b9603c7aa97359dbd97ecf258968c95f3adddd6db2f7e7a5bef101c84560" +checksum = "8119e4516436f5708bbc474a9d395bf12f1b5395e93a92a56e647ac3388c8610" dependencies = [ "alloc-no-stdlib", "alloc-stdlib", @@ -301,9 +313,9 @@ dependencies = [ [[package]] name = "brotli-decompressor" -version = "5.0.0" +version = "5.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "874bb8112abecc98cbd6d81ea4fa7e94fb9449648c93cc89aa40c81c24d7de03" +checksum = "5962523e1b92ce1b5e793d9169b9943eece10d39f62550bc04bb605d75b94924" dependencies = [ "alloc-no-stdlib", "alloc-stdlib", @@ -311,9 +323,9 @@ dependencies = [ [[package]] name = "bumpalo" -version = "3.19.1" +version = "3.20.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5dd9dc738b7a8311c7ade152424974d8115f2cdad61e8dab8dac9f2362298510" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" [[package]] name = "bytes" @@ -329,9 +341,9 @@ checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5" [[package]] name = "cc" -version = "1.2.55" +version = "1.2.63" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47b26a0954ae34af09b50f0de26458fa95369a0d478d8236d3f93082b219bd29" +checksum = "556e016178bb5662a08681bbe0f00f8e17631781a4dfc8c45e466e4b185ec27f" dependencies = [ "find-msvc-tools", "jobserver", @@ -339,12 +351,6 @@ dependencies = [ "shlex", ] -[[package]] -name = "cesu8" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" - [[package]] name = "cfg-if" version = "1.0.4" @@ -372,9 +378,9 @@ dependencies = [ [[package]] name = "clap" -version = "4.6.0" +version = "4.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b193af5b67834b676abd72466a96c1024e6a6ad978a1f484bd90b85c94041351" +checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51" dependencies = [ "clap_builder", "clap_derive", @@ -394,9 +400,9 @@ dependencies = [ [[package]] name = "clap_derive" -version = "4.6.0" +version = "4.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1110bd8a634a1ab8cb04345d8d878267d57c3cf1b38d91b71af6686408bbca6a" +checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9" dependencies = [ "heck", "proc-macro2", @@ -406,24 +412,24 @@ dependencies = [ [[package]] name = "clap_lex" -version = "1.0.0" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a822ea5bc7590f9d40f1ba12c0dc3c2760f3482c6984db1573ad11031420831" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" [[package]] name = "cmake" -version = "0.1.57" +version = "0.1.58" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75443c44cd6b379beb8c5b45d85d0773baf31cce901fe7bb252f4eff3008ef7d" +checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" dependencies = [ "cc", ] [[package]] name = "colorchoice" -version = "1.0.4" +version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" [[package]] name = "colored" @@ -446,9 +452,9 @@ dependencies = [ [[package]] name = "compression-codecs" -version = "0.4.37" +version = "0.4.38" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eb7b51a7d9c967fc26773061ba86150f19c50c0d65c887cb1fbe295fd16619b7" +checksum = "ce2548391e9c1929c21bf6aa2680af86fe4c1b33e6cea9ac1cfeec0bd11218cf" dependencies = [ "brotli", "compression-core", @@ -458,9 +464,9 @@ dependencies = [ [[package]] name = "compression-core" -version = "0.4.31" +version = "0.4.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75984efb6ed102a0d42db99afb6c1948f0380d1d91808d5529916e6c08b49d8d" +checksum = "cc14f565cf027a105f7a44ccf9e5b424348421a1d8952a8fc9d499d313107789" [[package]] name = "core-foundation" @@ -508,21 +514,14 @@ dependencies = [ [[package]] name = "ctor" -version = "0.10.0" +version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "95d0d11eb38e7642efca359c3cf6eb7b2e528182d09110165de70192b0352775" +checksum = "01334b89b69ff726750c5ce5073fc8bd860e99aa9a8fc5ca11b04730e3aee97a" dependencies = [ - "ctor-proc-macro", - "dtor", "link-section", + "linktime-proc-macro", ] -[[package]] -name = "ctor-proc-macro" -version = "0.0.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a7ab264ea985f1bd27887d7b21ea2bb046728e05d11909ca138d700c494730db" - [[package]] name = "darling" version = "0.20.11" @@ -560,9 +559,9 @@ dependencies = [ [[package]] name = "deranged" -version = "0.5.6" +version = "0.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc3dc5ad92c2e2d1c193bbbbdf2ea477cb81331de4f3103f267ca18368b988c4" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" dependencies = [ "powerfmt", "serde_core", @@ -620,9 +619,9 @@ dependencies = [ [[package]] name = "displaydoc" -version = "0.2.5" +version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" dependencies = [ "proc-macro2", "quote", @@ -635,21 +634,6 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" -[[package]] -name = "dtor" -version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "17f72721db8027a4e96dd6fb50d2a1d32259c9d3da1b63dee612ccd981e14293" -dependencies = [ - "dtor-proc-macro", -] - -[[package]] -name = "dtor-proc-macro" -version = "0.0.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8c98b077c7463d01d22dde8a24378ddf1ca7263dc687cffbed38819ea6c21131" - [[package]] name = "dunce" version = "1.0.5" @@ -660,7 +644,6 @@ checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" name = "edgezero-adapter" version = "0.1.0" dependencies = [ - "once_cell", "tempfile", "toml", ] @@ -683,6 +666,7 @@ dependencies = [ "redb", "reqwest", "serde", + "serde_json", "simple_logger", "tempfile", "thiserror 2.0.18", @@ -709,6 +693,8 @@ dependencies = [ "futures-util", "log", "serde_json", + "tempfile", + "toml_edit", "walkdir", "wasm-bindgen-test", "web-sys", @@ -735,7 +721,10 @@ dependencies = [ "futures-util", "log", "log-fastly", + "serde_json", "tempfile", + "thiserror 2.0.18", + "toml_edit", "walkdir", ] @@ -753,9 +742,17 @@ dependencies = [ "flate2", "futures", "futures-util", + "http-body-util", "log", + "rusqlite", + "serde", + "serde_json", "spin-sdk", + "subtle", "tempfile", + "thiserror 2.0.18", + "toml", + "toml_edit", "walkdir", ] @@ -776,8 +773,11 @@ dependencies = [ "log", "serde", "serde_json", + "simple_logger", "tempfile", + "thiserror 2.0.18", "toml", + "validator", ] [[package]] @@ -797,7 +797,7 @@ dependencies = [ "http", "http-body", "log", - "matchit 0.9.1", + "matchit 0.9.2", "serde", "serde_json", "serde_urlencoded", @@ -814,6 +814,7 @@ dependencies = [ name = "edgezero-macros" version = "0.1.0" dependencies = [ + "edgezero-core", "log", "proc-macro2", "quote", @@ -821,14 +822,15 @@ dependencies = [ "syn 2.0.117", "tempfile", "toml", + "trybuild", "validator", ] [[package]] name = "either" -version = "1.15.0" +version = "1.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" +checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" [[package]] name = "elsa" @@ -856,11 +858,23 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "fallible-iterator" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" + +[[package]] +name = "fallible-streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" + [[package]] name = "fastly" -version = "0.12.0" +version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "16393f187c703d5460d095201e194940a190479cd5a45aa7e324e8c97f4a3df4" +checksum = "531e4c3df48350d9f4fc95b4deaf87fd29820336b7926bb84bf460457c2a126b" dependencies = [ "anyhow", "bytes", @@ -886,9 +900,9 @@ dependencies = [ [[package]] name = "fastly-macros" -version = "0.12.0" +version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4e11b9b78e4d8d0fab4b9d7d8ba289c30d62d641e649e89153bc4d5446c88db2" +checksum = "cc2aef5f9690b04c8890f9a54ddb591b12b9779ec25ee0e572d207106e52e3d8" dependencies = [ "proc-macro2", "quote", @@ -897,9 +911,9 @@ dependencies = [ [[package]] name = "fastly-shared" -version = "0.12.0" +version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e4ca5a9664c64b9f85188426aa1598e9885d6dbb247d6155fd9ebe043b551800" +checksum = "080ad138403159fd366d3e0b14bb49cb0c01dc18c25095bbbd1c85e3338f5413" dependencies = [ "bitflags 1.3.2", "http", @@ -907,22 +921,22 @@ dependencies = [ [[package]] name = "fastly-sys" -version = "0.12.0" +version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d5dacc6ac7a7400e0b38757f48fbf1db09971812ef3dbb1f1a90a50746df662f" +checksum = "de75ef193f6c29c43d667458bede648970715aedd5db2d42c2eba3ffa3ad738b" dependencies = [ "bitflags 1.3.2", "fastly-shared", "http", "wasip2", - "wit-bindgen", + "wit-bindgen 0.51.0", ] [[package]] name = "fastrand" -version = "2.3.0" +version = "2.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" [[package]] name = "fern" @@ -961,6 +975,12 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + [[package]] name = "form_urlencoded" version = "1.2.2" @@ -1083,7 +1103,7 @@ dependencies = [ "cfg-if", "js-sys", "libc", - "wasi 0.11.1+wasi-snapshot-preview1", + "wasi", "wasm-bindgen", ] @@ -1096,29 +1116,35 @@ dependencies = [ "cfg-if", "js-sys", "libc", - "r-efi", + "r-efi 5.3.0", "wasip2", "wasm-bindgen", ] [[package]] name = "getrandom" -version = "0.4.1" +version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "139ef39800118c7683f2fd3c98c1b23c09ae076556b435f8e9064ae108aaeeec" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" dependencies = [ "cfg-if", "libc", - "r-efi", + "r-efi 6.0.0", "wasip2", - "wasip3", + "wasip3 0.4.0+wasi-0.3.0-rc-2026-01-06", ] +[[package]] +name = "glob" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" + [[package]] name = "handlebars" -version = "6.4.0" +version = "6.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b3f9296c208515b87bd915a2f5d1163d4b3f863ba83337d7713cf478055948e" +checksum = "d43ccdfe15a81ab0a8af639e90254227c9a46afd9c5f5b6ec7efaa345c4b0f00" dependencies = [ "derive_builder", "log", @@ -1130,20 +1156,41 @@ dependencies = [ "thiserror 2.0.18", ] +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" +dependencies = [ + "ahash", +] + [[package]] name = "hashbrown" version = "0.15.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" dependencies = [ - "foldhash", + "foldhash 0.1.5", ] [[package]] name = "hashbrown" -version = "0.16.1" +version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +dependencies = [ + "foldhash 0.2.0", +] + +[[package]] +name = "hashlink" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ba4ff7128dee98c7dc9794b6a411377e1404dba1c97deb8d1a55297bd25d8af" +dependencies = [ + "hashbrown 0.14.5", +] [[package]] name = "heck" @@ -1153,9 +1200,9 @@ checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" [[package]] name = "http" -version = "1.4.0" +version = "1.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" +checksum = "8be7462df143984c4598a256ef469b251d7d7f9e271135073e78fc535414f3d0" dependencies = [ "bytes", "itoa", @@ -1198,9 +1245,9 @@ checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" [[package]] name = "hyper" -version = "1.8.1" +version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2ab2d4f250c3d7b1c9fcdff1cece94ea4e2dfbec68614f7b87cb205f24ca9d11" +checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498" dependencies = [ "atomic-waker", "bytes", @@ -1212,7 +1259,6 @@ dependencies = [ "httpdate", "itoa", "pin-project-lite", - "pin-utils", "smallvec", "tokio", "want", @@ -1220,15 +1266,14 @@ dependencies = [ [[package]] name = "hyper-rustls" -version = "0.27.7" +version = "0.27.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" dependencies = [ "http", "hyper", "hyper-util", "rustls", - "rustls-pki-types", "tokio", "tokio-rustls", "tower-service", @@ -1283,12 +1328,13 @@ dependencies = [ [[package]] name = "icu_collections" -version = "2.1.1" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" dependencies = [ "displaydoc", "potential_utf", + "utf8_iter", "yoke", "zerofrom", "zerovec", @@ -1296,9 +1342,9 @@ dependencies = [ [[package]] name = "icu_locale_core" -version = "2.1.1" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" dependencies = [ "displaydoc", "litemap", @@ -1309,9 +1355,9 @@ dependencies = [ [[package]] name = "icu_normalizer" -version = "2.1.1" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" dependencies = [ "icu_collections", "icu_normalizer_data", @@ -1323,15 +1369,15 @@ dependencies = [ [[package]] name = "icu_normalizer_data" -version = "2.1.1" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" [[package]] name = "icu_properties" -version = "2.1.2" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "020bfc02fe870ec3a66d93e677ccca0562506e5872c650f893269e08615d74ec" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" dependencies = [ "icu_collections", "icu_locale_core", @@ -1343,15 +1389,15 @@ dependencies = [ [[package]] name = "icu_properties_data" -version = "2.1.2" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "616c294cf8d725c6afcd8f55abc17c56464ef6211f9ed59cccffe534129c77af" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" [[package]] name = "icu_provider" -version = "2.1.1" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" dependencies = [ "displaydoc", "icu_locale_core", @@ -1387,9 +1433,9 @@ dependencies = [ [[package]] name = "idna_adapter" -version = "1.2.1" +version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" dependencies = [ "icu_normalizer", "icu_properties", @@ -1397,31 +1443,21 @@ dependencies = [ [[package]] name = "indexmap" -version = "2.13.0" +version = "2.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" dependencies = [ "equivalent", - "hashbrown 0.16.1", + "hashbrown 0.17.1", "serde", "serde_core", ] [[package]] name = "ipnet" -version = "2.11.0" +version = "2.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130" - -[[package]] -name = "iri-string" -version = "0.7.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c91338f0783edbd6195decb37bae672fd3b165faffb89bf7b9e6942f8b1a731a" -dependencies = [ - "memchr", - "serde", -] +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" [[package]] name = "is_terminal_polyfill" @@ -1440,31 +1476,58 @@ dependencies = [ [[package]] name = "itoa" -version = "1.0.17" +version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" [[package]] name = "jni" -version = "0.21.1" +version = "0.22.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" dependencies = [ - "cesu8", "cfg-if", "combine", + "jni-macros", "jni-sys", "log", - "thiserror 1.0.69", + "simd_cesu8", + "thiserror 2.0.18", "walkdir", - "windows-sys 0.45.0", + "windows-link", +] + +[[package]] +name = "jni-macros" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "simd_cesu8", + "syn 2.0.117", ] [[package]] name = "jni-sys" -version = "0.3.0" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8eaf4bc02d17cbdd7ff4c7438cafcdf7fb9a4613313ad11b4f8fefe7d3fa0130" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn 2.0.117", +] [[package]] name = "jobserver" @@ -1478,9 +1541,9 @@ dependencies = [ [[package]] name = "js-sys" -version = "0.3.95" +version = "0.3.99" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2964e92d1d9dc3364cae4d718d93f227e3abb088e747d92e0395bfdedf1c12ca" +checksum = "142bc4740e452c1e57ade0cbc129f139c9093e354346f0872ef985f4f5cf5f11" dependencies = [ "cfg-if", "futures-util", @@ -1502,9 +1565,9 @@ checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" [[package]] name = "libc" -version = "0.2.184" +version = "0.2.186" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48f5d2a454e16a5ea0f4ced81bd44e4cfc7bd3a507b61887c99fd3538b28e4af" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" [[package]] name = "libm" @@ -1512,35 +1575,52 @@ version = "0.2.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" +[[package]] +name = "libsqlite3-sys" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + [[package]] name = "link-section" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "014e440054ce8170890229eeef5bcda955305e056ec713de40ed366944483f09" + +[[package]] +name = "linktime-proc-macro" version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "468808413fa8bdf0edbe61c2bbc182dfc59885b94f496cf3fb42c9c96b1e0149" +checksum = "8c7b0a3383c2a1002d11349c92c85a666a5fb679e96c79d782cf0dbe557fd6ee" [[package]] name = "linux-raw-sys" -version = "0.11.0" +version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" [[package]] name = "litemap" -version = "0.8.1" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" [[package]] name = "log" -version = "0.4.29" +version = "0.4.30" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +checksum = "616ec5685824bcc94416c6d4a7a446eea774a31efd7062c8480ba6fd06d7a6e5" [[package]] name = "log-fastly" -version = "0.12.0" +version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68896fe30b7c6c46d38cb33ade05daff20ad03a51d2dc422eab3138f2419fc51" +checksum = "51dae5def13a2d557fdb63862d642f8d4641ec3773c036bb14092697b6764013" dependencies = [ "fastly", "log", @@ -1567,15 +1647,15 @@ checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" [[package]] name = "matchit" -version = "0.9.1" +version = "0.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b3eede3bdf92f3b4f9dc04072a9ce5ab557d5ec9038773bf9ffcd5588b3cc05b" +checksum = "8863b587001c1b9a8a4e36008cebc6b3612cb1226fe2de94858e06092687b608" [[package]] name = "memchr" -version = "2.8.0" +version = "2.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" +checksum = "6b947ae49db0d222b1dbc6b113ce7248a3fc3a6ca21b696717bfc000ba4484d8" [[package]] name = "mime" @@ -1605,12 +1685,12 @@ dependencies = [ [[package]] name = "mio" -version = "1.1.1" +version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" +checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda" dependencies = [ "libc", - "wasi 0.11.1+wasi-snapshot-preview1", + "wasi", "windows-sys 0.61.2", ] @@ -1625,9 +1705,9 @@ dependencies = [ [[package]] name = "num-conv" -version = "0.2.0" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf97ec579c3c42f953ef76dbf8d55ac91fb219dde70e49aa4a6b7d74e9919050" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" [[package]] name = "num-modular" @@ -1744,18 +1824,18 @@ dependencies = [ [[package]] name = "pin-project" -version = "1.1.10" +version = "1.1.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "677f1add503faace112b9f1373e43e9e054bfdd22ff1a63c1bc485eaec6a6a8a" +checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924" dependencies = [ "pin-project-internal", ] [[package]] name = "pin-project-internal" -version = "1.1.10" +version = "1.1.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e918e4ff8c4549eb882f14b3a4bc8c8bc93de829416eacf579f1207a8fbf861" +checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" dependencies = [ "proc-macro2", "quote", @@ -1764,21 +1844,21 @@ dependencies = [ [[package]] name = "pin-project-lite" -version = "0.2.16" +version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" [[package]] -name = "pin-utils" -version = "0.1.0" +name = "pkg-config" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" [[package]] name = "potential_utf" -version = "0.1.4" +version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" dependencies = [ "zerovec", ] @@ -1910,11 +1990,17 @@ version = "5.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + [[package]] name = "rand" -version = "0.9.2" +version = "0.9.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" +checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" dependencies = [ "rand_chacha", "rand_core", @@ -1941,9 +2027,9 @@ dependencies = [ [[package]] name = "redb" -version = "4.0.0" +version = "4.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67f7f231ea7b1172b7ac00ccf96b1250f0fb5a16d5585836aa4ebc997df7cbde" +checksum = "8e925444704b5f17d32bf42f5b6e2df050bceebc3dcd6e71cc73dafe8092e839" dependencies = [ "libc", ] @@ -1973,19 +2059,21 @@ dependencies = [ [[package]] name = "regex-syntax" -version = "0.8.9" +version = "0.8.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a96887878f22d7bad8a3b6dc5b7440e0ada9a245242924394987b21cf2210a4c" +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" [[package]] name = "reqwest" -version = "0.13.2" +version = "0.13.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ab3f43e3283ab1488b624b44b0e988d0acea0b3214e694730a055cb6b2efa801" +checksum = "219c5811de6525e5416c7d5d53bb656d3afdbc6c5af816e0802bcfa42dbdc1c3" dependencies = [ "base64", "bytes", + "futures-channel", "futures-core", + "futures-util", "http", "http-body", "http-body-util", @@ -2000,6 +2088,8 @@ dependencies = [ "rustls", "rustls-pki-types", "rustls-platform-verifier", + "serde", + "serde_json", "sync_wrapper", "tokio", "tokio-rustls", @@ -2027,28 +2117,41 @@ dependencies = [ ] [[package]] -name = "routefinder" -version = "0.5.4" +name = "rusqlite" +version = "0.32.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0971d3c8943a6267d6bd0d782fdc4afa7593e7381a92a3df950ff58897e066b5" +checksum = "7753b721174eb8ff87a9a0e799e2d7bc3749323e773db92e0984debb00019d6e" dependencies = [ - "smartcow", - "smartstring", + "bitflags 2.11.1", + "fallible-iterator", + "fallible-streaming-iterator", + "hashlink", + "libsqlite3-sys", + "smallvec", ] [[package]] name = "rustc-hash" -version = "2.1.1" +version = "2.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" +checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] [[package]] name = "rustix" -version = "1.1.3" +version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "146c9e247ccc180c1f61615433868c99f3de3ae256a30a43b49f67c2d9171f34" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.1", "errno", "libc", "linux-raw-sys", @@ -2057,9 +2160,9 @@ dependencies = [ [[package]] name = "rustls" -version = "0.23.36" +version = "0.23.40" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c665f33d38cea657d9614f766881e4d510e0eda4239891eea56b4cadcf01801b" +checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b" dependencies = [ "aws-lc-rs", "once_cell", @@ -2071,9 +2174,9 @@ dependencies = [ [[package]] name = "rustls-native-certs" -version = "0.8.3" +version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "612460d5f7bea540c490b2b6395d8e34a953e52b491accd6c86c8164c5932a63" +checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d" dependencies = [ "openssl-probe", "rustls-pki-types", @@ -2083,9 +2186,9 @@ dependencies = [ [[package]] name = "rustls-pki-types" -version = "1.14.0" +version = "1.14.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd" +checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" dependencies = [ "web-time", "zeroize", @@ -2093,9 +2196,9 @@ dependencies = [ [[package]] name = "rustls-platform-verifier" -version = "0.6.2" +version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d99feebc72bae7ab76ba994bb5e121b8d83d910ca40b36e0921f53becc41784" +checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0" dependencies = [ "core-foundation", "core-foundation-sys", @@ -2120,9 +2223,9 @@ checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" [[package]] name = "rustls-webpki" -version = "0.103.9" +version = "0.103.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7df23109aa6c1567d1c575b9952556388da57401e4ace1d15f79eedad0d8f53" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" dependencies = [ "aws-lc-rs", "ring", @@ -2153,20 +2256,20 @@ dependencies = [ [[package]] name = "schannel" -version = "0.1.28" +version = "0.1.29" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "891d81b926048e76efe18581bf793546b4c0eaf8448d72be8de2bbee5fd166e1" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" dependencies = [ "windows-sys 0.61.2", ] [[package]] name = "security-framework" -version = "3.5.1" +version = "3.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b3297343eaf830f66ede390ea39da1d462b6b0c1b000f420d0a83f898bbbe6ef" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.1", "core-foundation", "core-foundation-sys", "libc", @@ -2175,9 +2278,9 @@ dependencies = [ [[package]] name = "security-framework-sys" -version = "2.15.0" +version = "2.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc1f0cbffaac4852523ce30d8bd3c5cdc873501d96ff467ca09b6767bb8cd5c0" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" dependencies = [ "core-foundation-sys", "libc", @@ -2185,9 +2288,9 @@ dependencies = [ [[package]] name = "semver" -version = "1.0.27" +version = "1.0.28" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" [[package]] name = "serde" @@ -2232,9 +2335,9 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.149" +version = "1.0.150" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" dependencies = [ "itoa", "memchr", @@ -2312,9 +2415,9 @@ dependencies = [ [[package]] name = "shlex" -version = "1.3.0" +version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" [[package]] name = "signal-hook-registry" @@ -2328,9 +2431,25 @@ dependencies = [ [[package]] name = "simd-adler32" -version = "0.3.8" +version = "0.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2" +checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" + +[[package]] +name = "simd_cesu8" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94f90157bb87cddf702797c5dadfa0be7d266cdf49e22da2fcaa32eff75b2c33" +dependencies = [ + "rustc_version", + "simdutf8", +] + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" [[package]] name = "simple_logger" @@ -2356,55 +2475,22 @@ version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" -[[package]] -name = "smartcow" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "656fcb1c1fca8c4655372134ce87d8afdf5ec5949ebabe8d314be0141d8b5da2" -dependencies = [ - "smartstring", -] - -[[package]] -name = "smartstring" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3fb72c633efbaa2dd666986505016c32c3044395ceaf881518399d2f4127ee29" -dependencies = [ - "autocfg", - "static_assertions", - "version_check", -] - [[package]] name = "socket2" -version = "0.6.2" +version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "86f4aa3ad99f2088c990dfa82d367e19cb29268ed67c574d10d0a4bfe71f07e0" +checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" dependencies = [ "libc", - "windows-sys 0.60.2", -] - -[[package]] -name = "spin-executor" -version = "5.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bba409d00af758cd5de128da4a801e891af0545138f66a688f025f6d4e33870b" -dependencies = [ - "futures", - "once_cell", - "wasi 0.13.1+wasi-0.2.0", + "windows-sys 0.61.2", ] [[package]] name = "spin-macro" -version = "5.2.0" +version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f959f16928e3c023468e41da9ebb77442e2ce22315e8dab11508fe76b3567ee1" +checksum = "11e483b94d5bcfac493caf0427fa875063e3e8604d0466a4ab491ec200a42857" dependencies = [ - "anyhow", - "bytes", "proc-macro2", "quote", "syn 1.0.109", @@ -2412,24 +2498,19 @@ dependencies = [ [[package]] name = "spin-sdk" -version = "5.2.0" +version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8951c7c4ab7f87f332d497789eeed9631c8116988b628b4851eb2fa999ead019" +checksum = "4fd2abac3eb2ee249c2241ab87f7b1287f36172c8cc1ea815c19c85e41ede44d" dependencies = [ "anyhow", - "async-trait", "bytes", - "chrono", - "form_urlencoded", "futures", "http", - "once_cell", - "routefinder", - "spin-executor", + "http-body", + "http-body-util", "spin-macro", "thiserror 2.0.18", - "wasi 0.13.1+wasi-0.2.0", - "wit-bindgen", + "wasip3 0.6.0+wasi-0.3.0-rc-2026-03-15", ] [[package]] @@ -2438,12 +2519,6 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" -[[package]] -name = "static_assertions" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" - [[package]] name = "strsim" version = "0.11.1" @@ -2519,19 +2594,34 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "target-triple" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "591ef38edfb78ca4771ee32cf494cb8771944bee237a9b91fc9c1424ac4b777b" + [[package]] name = "tempfile" -version = "3.25.0" +version = "3.27.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0136791f7c95b1f6dd99f9cc786b91bb81c3800b639b3478e561ddb7be95e5f1" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.4.1", + "getrandom 0.4.2", "once_cell", "rustix", "windows-sys 0.61.2", ] +[[package]] +name = "termcolor" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755" +dependencies = [ + "winapi-util", +] + [[package]] name = "thiserror" version = "1.0.69" @@ -2607,9 +2697,9 @@ dependencies = [ [[package]] name = "tinystr" -version = "0.8.2" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" dependencies = [ "displaydoc", "zerovec", @@ -2617,9 +2707,9 @@ dependencies = [ [[package]] name = "tinyvec" -version = "1.10.0" +version = "1.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bfa5fdc3bce6191a1dbc8c02d5c8bffcf557bafa17c124c5264a458f1b0613fa" +checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" dependencies = [ "tinyvec_macros", ] @@ -2632,9 +2722,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "tokio" -version = "1.50.0" +version = "1.52.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "27ad5e34374e03cfffefc301becb44e9dc3c17584f414349ebe29ed26661822d" +checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" dependencies = [ "bytes", "libc", @@ -2648,9 +2738,9 @@ dependencies = [ [[package]] name = "tokio-macros" -version = "2.6.0" +version = "2.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" dependencies = [ "proc-macro2", "quote", @@ -2676,10 +2766,19 @@ dependencies = [ "indexmap", "serde_core", "serde_spanned", - "toml_datetime", + "toml_datetime 1.1.1+spec-1.1.0", "toml_parser", "toml_writer", - "winnow", + "winnow 1.0.3", +] + +[[package]] +name = "toml_datetime" +version = "0.7.5+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347" +dependencies = [ + "serde_core", ] [[package]] @@ -2691,13 +2790,26 @@ dependencies = [ "serde_core", ] +[[package]] +name = "toml_edit" +version = "0.23.10+spec-1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84c8b9f757e028cee9fa244aea147aab2a9ec09d5325a9b01e0a49730c2b5269" +dependencies = [ + "indexmap", + "toml_datetime 0.7.5+spec-1.1.0", + "toml_parser", + "toml_writer", + "winnow 0.7.15", +] + [[package]] name = "toml_parser" version = "1.1.2+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" dependencies = [ - "winnow", + "winnow 1.0.3", ] [[package]] @@ -2724,20 +2836,20 @@ dependencies = [ [[package]] name = "tower-http" -version = "0.6.8" +version = "0.6.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.1", "bytes", "futures-util", "http", "http-body", - "iri-string", "pin-project-lite", "tower", "tower-layer", "tower-service", + "url", ] [[package]] @@ -2790,11 +2902,26 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +[[package]] +name = "trybuild" +version = "1.0.116" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47c635f0191bd3a2941013e5062667100969f8c4e9cd787c14f977265d73616e" +dependencies = [ + "glob", + "serde", + "serde_derive", + "serde_json", + "target-triple", + "termcolor", + "toml", +] + [[package]] name = "typenum" -version = "1.19.0" +version = "1.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" [[package]] name = "ucd-trie" @@ -2804,9 +2931,9 @@ checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" [[package]] name = "unicode-ident" -version = "1.0.23" +version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "537dd038a89878be9b64dd4bd1b260315c1bb94f4d784956b81e27a088d9a09e" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" [[package]] name = "unicode-xid" @@ -2874,6 +3001,12 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + [[package]] name = "version_check" version = "0.9.5" @@ -2906,37 +3039,41 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" [[package]] -name = "wasi" -version = "0.13.1+wasi-0.2.0" +name = "wasip2" +version = "1.0.3+wasi-0.2.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f43d1c36145feb89a3e61aa0ba3e582d976a8ab77f1474aa0adb80800fe0cf8" +checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" dependencies = [ - "wit-bindgen-rt", + "wit-bindgen 0.57.1", ] [[package]] -name = "wasip2" -version = "1.0.2+wasi-0.2.9" +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" dependencies = [ - "wit-bindgen", + "wit-bindgen 0.51.0", ] [[package]] name = "wasip3" -version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +version = "0.6.0+wasi-0.3.0-rc-2026-03-15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +checksum = "ed83456dd6a0b8581998c0365e4651fa2997e5093b49243b7f35391afaa7a3d9" dependencies = [ - "wit-bindgen", + "bytes", + "http", + "http-body", + "thiserror 2.0.18", + "wit-bindgen 0.57.1", ] [[package]] name = "wasm-bindgen" -version = "0.2.118" +version = "0.2.122" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0bf938a0bacb0469e83c1e148908bd7d5a6010354cf4fb73279b7447422e3a89" +checksum = "3ed04576f974d2b2fba0f38c51dbc5518011e38c36bf1143164be765528fd409" dependencies = [ "cfg-if", "once_cell", @@ -2947,9 +3084,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.68" +version = "0.4.72" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f371d383f2fb139252e0bfac3b81b265689bf45b6874af544ffa4c975ac1ebf8" +checksum = "9473dbd2991ae90b6291c3c32c30c6187ac49aa32f9905d1cce280ec1e110b0f" dependencies = [ "js-sys", "wasm-bindgen", @@ -2957,9 +3094,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.118" +version = "0.2.122" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eeff24f84126c0ec2db7a449f0c2ec963c6a49efe0698c4242929da037ca28ed" +checksum = "916151b09da36bd82f6615cbf3a419e2f0ba23a03c6160e8e92eb6bd4aa1dec6" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -2967,9 +3104,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.118" +version = "0.2.122" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d08065faf983b2b80a79fd87d8254c409281cf7de75fc4b773019824196c904" +checksum = "299047362ccbfce148b67ab7e73349f77748e00c8296f9542adfad2ad82c5c5e" dependencies = [ "bumpalo", "proc-macro2", @@ -2980,18 +3117,18 @@ dependencies = [ [[package]] name = "wasm-bindgen-shared" -version = "0.2.118" +version = "0.2.122" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5fd04d9e306f1907bd13c6361b5c6bfc7b3b3c095ed3f8a9246390f8dbdee129" +checksum = "9a929b2c61f11ba3e9bc35b50c1f25cb38e0e892c0c231ae2b8cf78d5dad4437" dependencies = [ "unicode-ident", ] [[package]] name = "wasm-bindgen-test" -version = "0.3.68" +version = "0.3.72" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6bb55e2540ad1c56eec35fd63e2aea15f83b11ce487fd2de9ad11578dfc047ea" +checksum = "74fde991ccdc895cb7fbaa14b137d62af74d9011be67b71c694bfc40edd3119c" dependencies = [ "async-trait", "cast", @@ -3011,9 +3148,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-test-macro" -version = "0.3.68" +version = "0.3.72" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "caf0ca1bd612b988616bac1ab34c4e4290ef18f7148a1d8b7f31c150080e9295" +checksum = "e925354648d2a4d1bf205412e36d520a800280622eef4719678d268e5d40e978" dependencies = [ "proc-macro2", "quote", @@ -3022,9 +3159,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-test-shared" -version = "0.2.118" +version = "0.2.122" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "23cda5ecc67248c48d3e705d3e03e00af905769b78b9d2a1678b663b8b9d4472" +checksum = "684365b586a9a6256c1cc3544eee8680de48d6041142f581776ec7b139622ae9" [[package]] name = "wasm-encoder" @@ -3033,7 +3170,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" dependencies = [ "leb128fmt", - "wasmparser", + "wasmparser 0.244.0", +] + +[[package]] +name = "wasm-encoder" +version = "0.247.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30b6733b8b91d010a6ac5b0fb237dc46a19650bc4c67db66857e2e787d437204" +dependencies = [ + "leb128fmt", + "wasmparser 0.247.0", ] [[package]] @@ -3044,8 +3191,20 @@ checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" dependencies = [ "anyhow", "indexmap", - "wasm-encoder", - "wasmparser", + "wasm-encoder 0.244.0", + "wasmparser 0.244.0", +] + +[[package]] +name = "wasm-metadata" +version = "0.247.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "665fe59e56cc9b419ca6fcca56673e3421d1a5011e3b65caf6b726fd9e041d10" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder 0.247.0", + "wasmparser 0.247.0", ] [[package]] @@ -3067,17 +3226,29 @@ version = "0.244.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.1", "hashbrown 0.15.5", "indexmap", "semver", ] +[[package]] +name = "wasmparser" +version = "0.247.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e6fb4c2bee46c5ea4d40f8cdb5c131725cd976718ec56f1c8e82fbde5fa2a80" +dependencies = [ + "bitflags 2.11.1", + "hashbrown 0.17.1", + "indexmap", + "semver", +] + [[package]] name = "web-sys" -version = "0.3.95" +version = "0.3.99" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4f2dfbb17949fa2088e5d39408c48368947b86f7834484e87b73de55bc14d97d" +checksum = "6d621441cfc37b84979402712047321980c178f299193a3589d05b99e8763436" dependencies = [ "js-sys", "wasm-bindgen", @@ -3095,9 +3266,9 @@ dependencies = [ [[package]] name = "webpki-root-certs" -version = "1.0.6" +version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "804f18a4ac2676ffb4e8b5b5fa9ae38af06df08162314f96a68d2a363e21a8ca" +checksum = "f31141ce3fc3e300ae89b78c0dd67f9708061d1d2eda54b8209346fd6be9a92c" dependencies = [ "rustls-pki-types", ] @@ -3170,15 +3341,6 @@ dependencies = [ "windows-link", ] -[[package]] -name = "windows-sys" -version = "0.45.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" -dependencies = [ - "windows-targets 0.42.2", -] - [[package]] name = "windows-sys" version = "0.52.0" @@ -3206,21 +3368,6 @@ dependencies = [ "windows-link", ] -[[package]] -name = "windows-targets" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" -dependencies = [ - "windows_aarch64_gnullvm 0.42.2", - "windows_aarch64_msvc 0.42.2", - "windows_i686_gnu 0.42.2", - "windows_i686_msvc 0.42.2", - "windows_x86_64_gnu 0.42.2", - "windows_x86_64_gnullvm 0.42.2", - "windows_x86_64_msvc 0.42.2", -] - [[package]] name = "windows-targets" version = "0.52.6" @@ -3254,12 +3401,6 @@ dependencies = [ "windows_x86_64_msvc 0.53.1", ] -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" - [[package]] name = "windows_aarch64_gnullvm" version = "0.52.6" @@ -3272,12 +3413,6 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" -[[package]] -name = "windows_aarch64_msvc" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" - [[package]] name = "windows_aarch64_msvc" version = "0.52.6" @@ -3290,12 +3425,6 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" -[[package]] -name = "windows_i686_gnu" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" - [[package]] name = "windows_i686_gnu" version = "0.52.6" @@ -3320,12 +3449,6 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" -[[package]] -name = "windows_i686_msvc" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" - [[package]] name = "windows_i686_msvc" version = "0.52.6" @@ -3338,12 +3461,6 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" -[[package]] -name = "windows_x86_64_gnu" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" - [[package]] name = "windows_x86_64_gnu" version = "0.52.6" @@ -3356,12 +3473,6 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" - [[package]] name = "windows_x86_64_gnullvm" version = "0.52.6" @@ -3374,12 +3485,6 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" -[[package]] -name = "windows_x86_64_msvc" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" - [[package]] name = "windows_x86_64_msvc" version = "0.52.6" @@ -3394,9 +3499,18 @@ checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" [[package]] name = "winnow" -version = "1.0.1" +version = "0.7.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09dac053f1cd375980747450bfc7250c264eaae0583872e845c0c7cd578872b5" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" +dependencies = [ + "memchr", +] + +[[package]] +name = "winnow" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0592e1c9d151f854e6fd382574c3a0855250e1d9b2f99d9281c6e6391af352f1" [[package]] name = "wit-bindgen" @@ -3404,8 +3518,19 @@ version = "0.51.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" dependencies = [ - "bitflags 2.10.0", - "wit-bindgen-rust-macro", + "bitflags 2.11.1", + "wit-bindgen-rust-macro 0.51.0", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" +dependencies = [ + "bitflags 2.11.1", + "futures", + "wit-bindgen-rust-macro 0.57.1", ] [[package]] @@ -3416,16 +3541,18 @@ checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" dependencies = [ "anyhow", "heck", - "wit-parser", + "wit-parser 0.244.0", ] [[package]] -name = "wit-bindgen-rt" -version = "0.24.0" +name = "wit-bindgen-core" +version = "0.57.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b0780cf7046630ed70f689a098cd8d56c5c3b22f2a7379bbdb088879963ff96" +checksum = "02dee27a2dc20d1008016c742ec9fc6ea498492994ba3750be7454cbc97ff04c" dependencies = [ - "bitflags 2.10.0", + "anyhow", + "heck", + "wit-parser 0.247.0", ] [[package]] @@ -3439,9 +3566,25 @@ dependencies = [ "indexmap", "prettyplease", "syn 2.0.117", - "wasm-metadata", - "wit-bindgen-core", - "wit-component", + "wasm-metadata 0.244.0", + "wit-bindgen-core 0.51.0", + "wit-component 0.244.0", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5007dae772945b7a5003d69d90a3a4a78929d41f19d004e980c4259a6af4484" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn 2.0.117", + "wasm-metadata 0.247.0", + "wit-bindgen-core 0.57.1", + "wit-component 0.247.0", ] [[package]] @@ -3455,8 +3598,23 @@ dependencies = [ "proc-macro2", "quote", "syn 2.0.117", - "wit-bindgen-core", - "wit-bindgen-rust", + "wit-bindgen-core 0.51.0", + "wit-bindgen-rust 0.51.0", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af9237d678e3513ad24e96fe98beacdc0db6405284ba2a2400418cf0d42caa89" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn 2.0.117", + "wit-bindgen-core 0.57.1", + "wit-bindgen-rust 0.57.1", ] [[package]] @@ -3466,16 +3624,35 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" dependencies = [ "anyhow", - "bitflags 2.10.0", + "bitflags 2.11.1", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder 0.244.0", + "wasm-metadata 0.244.0", + "wasmparser 0.244.0", + "wit-parser 0.244.0", +] + +[[package]] +name = "wit-component" +version = "0.247.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d567162a6b9843080e5e0053f696623ff694bae8ae017c9ec536d1873bbe3d8" +dependencies = [ + "anyhow", + "bitflags 2.11.1", "indexmap", "log", "serde", "serde_derive", "serde_json", - "wasm-encoder", - "wasm-metadata", - "wasmparser", - "wit-parser", + "wasm-encoder 0.247.0", + "wasm-metadata 0.247.0", + "wasmparser 0.247.0", + "wit-parser 0.247.0", ] [[package]] @@ -3493,14 +3670,33 @@ dependencies = [ "serde_derive", "serde_json", "unicode-xid", - "wasmparser", + "wasmparser 0.244.0", +] + +[[package]] +name = "wit-parser" +version = "0.247.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ffe4064318cdf3c08cb99343b44c039fcefe61ccdf58aa9975285f13d74d1fc" +dependencies = [ + "anyhow", + "hashbrown 0.17.1", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser 0.247.0", ] [[package]] name = "worker" -version = "0.8.0" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "60d64fc6b9a9312fb2432adcc0f1432c033c790dee54bf55523854d91e1314c9" +checksum = "2d3c60a70414db58e1890f3675d02692adace736657cb66994f220ae3780c90d" dependencies = [ "async-trait", "bytes", @@ -3516,6 +3712,7 @@ dependencies = [ "serde-wasm-bindgen", "serde_json", "serde_urlencoded", + "strum", "tokio", "url", "wasm-bindgen", @@ -3528,9 +3725,9 @@ dependencies = [ [[package]] name = "worker-macros" -version = "0.8.0" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d90009686c58eb2c34d1c5b80f04a335021b28742b7a52ea833a62c7e21baa25" +checksum = "60bcb459a67977fcb79698a3123ae58a928b1b24cc3035eaec033dbdfc139438" dependencies = [ "async-trait", "proc-macro2", @@ -3545,9 +3742,9 @@ dependencies = [ [[package]] name = "worker-sys" -version = "0.8.0" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eb85940169929c472a35338d81d4283c9a903cd3cf55331a5b87096adfae41b1" +checksum = "c0e59a8504685d87649b8fda877d95fcc48f8c8177dbd77a4dc8e67f8fc80240" dependencies = [ "cfg-if", "js-sys", @@ -3557,15 +3754,15 @@ dependencies = [ [[package]] name = "writeable" -version = "0.6.2" +version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" [[package]] name = "yoke" -version = "0.8.1" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72d6e5c6afb84d73944e5cedb052c4680d5657337201555f9f2a16b7406d4954" +checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca" dependencies = [ "stable_deref_trait", "yoke-derive", @@ -3574,9 +3771,9 @@ dependencies = [ [[package]] name = "yoke-derive" -version = "0.8.1" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" dependencies = [ "proc-macro2", "quote", @@ -3586,18 +3783,18 @@ dependencies = [ [[package]] name = "zerocopy" -version = "0.8.39" +version = "0.8.50" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db6d35d663eadb6c932438e763b262fe1a70987f9ae936e60158176d710cae4a" +checksum = "3b065d4f0e55f82fae73202e189638116a87c55ab6b8e6c2721e13dd9d854ad1" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.39" +version = "0.8.50" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4122cd3169e94605190e77839c9a40d40ed048d305bfdc146e7df40ab0f3e517" +checksum = "0b631b19d36a892ab55420c92dbc83ccd79274f25be714855d3074aa71cab639" dependencies = [ "proc-macro2", "quote", @@ -3606,18 +3803,18 @@ dependencies = [ [[package]] name = "zerofrom" -version = "0.1.6" +version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" dependencies = [ "zerofrom-derive", ] [[package]] name = "zerofrom-derive" -version = "0.1.6" +version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" dependencies = [ "proc-macro2", "quote", @@ -3633,9 +3830,9 @@ checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" [[package]] name = "zerotrie" -version = "0.2.3" +version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a59c17a5562d507e4b54960e8569ebee33bee890c70aa3fe7b97e85a9fd7851" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" dependencies = [ "displaydoc", "yoke", @@ -3644,9 +3841,9 @@ dependencies = [ [[package]] name = "zerovec" -version = "0.11.5" +version = "0.11.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" dependencies = [ "yoke", "zerofrom", @@ -3655,9 +3852,9 @@ dependencies = [ [[package]] name = "zerovec-derive" -version = "0.11.2" +version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" dependencies = [ "proc-macro2", "quote", @@ -3666,6 +3863,6 @@ dependencies = [ [[package]] name = "zmij" -version = "1.0.20" +version = "1.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4de98dfa5d5b7fef4ee834d0073d560c9ca7b6c46a71d058c48db7960f8cfaf7" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/Cargo.toml b/Cargo.toml index caa1c807..1d349980 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -31,13 +31,14 @@ axum = { version = "0.8", default-features = true } brotli = "8" bytes = "1" chrono = "0.4" -ctor = "0.10" +ctor = "1.0" edgezero-adapter = { path = "crates/edgezero-adapter" } edgezero-adapter-axum = { path = "crates/edgezero-adapter-axum", default-features = false } edgezero-adapter-cloudflare = { path = "crates/edgezero-adapter-cloudflare", default-features = false } edgezero-adapter-fastly = { path = "crates/edgezero-adapter-fastly", default-features = false } edgezero-adapter-spin = { path = "crates/edgezero-adapter-spin", default-features = false } edgezero-core = { path = "crates/edgezero-core", default-features = false } +edgezero-cli = { path = "crates/edgezero-cli", default-features = false } fastly = "0.12" fern = "0.7" flate2 = { version = "1", features = ["rust_backend"] } @@ -46,20 +47,34 @@ futures-util = { version = "0.3", features = ["alloc", "io"] } handlebars = "6" http = "1" http-body = "1" +http-body-util = "0.1" log = "0.4" log-fastly = "0.12" matchit = "0.9" once_cell = "1" -redb = "4.0" -reqwest = { version = "0.13", default-features = false, features = ["rustls"] } +redb = "4.1" +reqwest = { version = "0.13", default-features = false, features = ["rustls", "blocking", "json"] } +# `bundled` ships SQLite source so operators don't need a system +# `libsqlite3-sys` install. Used by `edgezero-adapter-spin`'s CLI-only +# `config push --adapter spin` writer to write into Spin's local KV +# file (`/.spin/sqlite_key_value.db`) using the schema +# vendored from spinframework/spin's `crates/key-value-spin/src/store.rs`. +rusqlite = { version = "0.32", default-features = false, features = ["bundled"] } serde = { version = "1", features = ["derive"] } serde_json = "1" +subtle = "2" serde_urlencoded = "0.7" simple_logger = "5" -spin-sdk = { version = "5.2", default-features = false } +# Pinned to the `~6.0` range (allows 6.0.x, blocks 6.1+) so a minor +# bump that touches `key_value::Store::open`'s async signature or the +# wasi-http import surface fails at build time rather than at `spin +# up` (where a runtime mismatch surfaces as opaque WIT linker errors). +spin-sdk = { version = "~6.0", default-features = false, features = ["http", "key-value", "variables"] } tempfile = "3" +toml_edit = "0.23" thiserror = "2" tokio = { version = "1", features = ["macros", "rt-multi-thread", "signal"] } +trybuild = "1" toml = { version = "1.1" } tower = { version = "0.5", features = ["util"] } tower-layer = "0.3" @@ -69,3 +84,80 @@ validator = { version = "0.20", features = ["derive"] } walkdir = { version = "2" } web-time = "1" worker = { version = "0.8", features = ["http"] } + +[workspace.lints.clippy] +# Same strict gate as the demo workspace. Allow-list mirrors the demo's +# slim set; every additional exception lives at the call site as a +# documented `#[allow]` or `#[expect]` rather than a workspace allow. +pedantic = { level = "warn", priority = -1 } +restriction = { level = "deny", priority = -1 } + +# Meta — required when enabling `restriction` as a group. +blanket_clippy_restriction_lints = "allow" + +# Documentation — private items don't need full docs. +missing_docs_in_private_items = "allow" + +# Style / formatting — match idiomatic Rust conventions. +implicit_return = "allow" +question_mark_used = "allow" +single_call_fn = "allow" +separated_literal_suffix = "allow" +# `pub_with_shorthand` flags the shorthand `pub(crate)` form and wants the +# longhand `pub(in crate)` form. Verified by removing this allow on +# clippy 1.95: 6 errors of the form +# +# error: usage of `pub` without `in` +# | pub(crate) fn decompress_body(...) +# | ^^^^^^^^^^ help: add it: `pub(in crate)` +# = help: ...index.html#pub_with_shorthand +# +# So the lint flags `pub(crate)` and suggests `pub(in crate)`. We use +# `pub(crate)` because rustfmt unconditionally rewrites `pub(in crate)` +# → `pub(crate)` on save; no spelling satisfies both clippy and rustfmt. +# Six legitimate cross-file `pub(crate)` items currently fire: +# dispatch_raw, dispatch_with_store_names, parse_uri, parse_client_addr, +# decompress_body, and one extra in fastly/request.rs. +pub_with_shorthand = "allow" +# `module_name_repetitions` was attempted: 39 sites in edgezero-core, +# centred on three concrete blockers that surfaced during the rename: +# 1. `proxy::Request`/`proxy::Response` would collide with the +# `http::Request`/`http::Response` already imported by every +# consumer; the only viable alternative names (`OutboundRequest`, +# `Outbound`) are strictly more verbose than `ProxyRequest`. +# 2. `manifest.rs` has 17 `Manifest*` types; consumers in adapters, +# cli, demos, scaffold templates, and the macro-generated app +# code use these names directly. Stripping the prefix would force +# every site to write `use edgezero_core::manifest::Spec as Manifest` +# etc. — pure churn for no readability gain since `manifest::Spec` +# reads worse than `Manifest`. +# 3. The macro `#[app]` emits code that references these names by +# their current spelling; renaming requires regenerating every +# generated app with new types and updating CLAUDE.md examples. +# Net: the lint's intent (Rust ecosystem `module::Type` idiom) is +# real, but it conflicts with our flat re-export surface and several +# names cannot be deprefixed without losing meaning. +module_name_repetitions = "allow" + +# `pattern_type_mismatch` and `ref_patterns` are mutually exclusive in modern +# Rust — every `if let Some(x) = &foo` flags the first, every +# `*foo { Variant(ref x) => ... }` flags the second. We pick match-ergonomics. +pattern_type_mismatch = "allow" + +# API design — `exhaustive_structs` fires on the unit struct generated by +# `edgezero_core::app!`. +exhaustive_structs = "allow" +# Only one site triggers `exhaustive_enums` workspace-wide: `Body { Once, +# Stream }`. Marking it `#[non_exhaustive]` would force a wildcard arm +# (`_ => unreachable!()`) at every external `match` site — 37 of them +# across the four adapter crates — and a third Body variant would +# silently panic at runtime instead of producing a compile error. +# Body is intentionally a closed enum. +exhaustive_enums = "allow" + +# Imports / paths +std_instead_of_alloc = "allow" +std_instead_of_core = "allow" + +[workspace.lints.rust] +unsafe_code = "deny" \ No newline at end of file diff --git a/README.md b/README.md index a98f8297..e8c78b92 100644 --- a/README.md +++ b/README.md @@ -9,11 +9,11 @@ Production-ready toolkit for portable edge HTTP workloads. Write once, deploy to cargo install --path crates/edgezero-cli # Create a new project -edgezero-cli new my-app +edgezero new my-app cd my-app -# Start the dev server -edgezero-cli dev +# Run it locally on the Axum adapter +edgezero serve --adapter axum # Test it curl http://127.0.0.1:8787/ @@ -34,7 +34,7 @@ Full documentation is available at **[stackpop.github.io/edgezero](https://stack | ------------------ | ------------------------ | ------ | | Fastly Compute | `wasm32-wasip1` | Stable | | Cloudflare Workers | `wasm32-unknown-unknown` | Stable | -| Fermyon Spin | `wasm32-wasip1` | Preview | +| Fermyon Spin | `wasm32-wasip2` | Preview | | Axum (Native) | Host | Stable | ## License diff --git a/TODO.md b/TODO.md index 384a7d63..19bf99e0 100644 --- a/TODO.md +++ b/TODO.md @@ -35,7 +35,7 @@ High-level backlog and decisions to drive the next milestones. - [ ] Adapters: assert error-path mapping for Fastly/Cloudflare request conversion and re-enable the ignored Cloudflare response header test. - [ ] CLI: add integration tests for `edgezero new` scaffolding, feature-flag builds, and `dev` fallback app. - [ ] CLI: cover `dev_server`, generator, and template scaffolding flows with tempdir-based integration tests to guard manual HTTP parsing and shell commands. -- [ ] CI: verify feature combinations (without `dev-example`, `json`, `form`) compile and run basic smoke tests. +- [ ] CI: verify feature combinations (without `demo-example`, `json`, `form`) compile and run basic smoke tests. - [ ] Macros: add trybuild coverage for `app!` manifest expansion (route/middleware generation and error surfacing). - [x] Core: unit-test `App::build_app`/`Hooks` wiring and `PathParams::deserialize` edge cases beyond indirect coverage. _(Added targeted unit tests in `crates/edgezero-core/src/app.rs` and `crates/edgezero-core/src/params.rs`.)_ - [x] Coverage hygiene: consolidate duplicate router/extractor request-parsing tests and share adapter contract fixtures to reduce redundant maintenance. _(Router duplicates trimmed; extractor suite now owns request parsing checks.)_ @@ -158,7 +158,7 @@ High-level backlog and decisions to drive the next milestones. ## Review (2025-09-18 03:08 UTC) - Implemented `edgezero build|deploy --adapter fastly` by wiring cargo wasm32 builds and Fastly CLI invocation in the CLI. -- Documented optional `dev-example` dependency in `edgezero-cli/README.md` and added error handling for unsupported adapters. +- Documented optional `demo-example` dependency in `edgezero-cli/README.md` and added error handling for unsupported adapters. - Verified builds with `cargo test -p edgezero-cli`. ## Review (2025-09-18 03:27 UTC) diff --git a/clippy.toml b/clippy.toml new file mode 100644 index 00000000..0b4d3d8c --- /dev/null +++ b/clippy.toml @@ -0,0 +1,9 @@ +# Clippy configuration. See https://doc.rust-lang.org/clippy/lint_configuration.html +# +# Test code uses `.unwrap()`, `.expect()`, `panic!`, `assert!`, indexing, and +# other "if-this-fails-the-test-fails" idioms by convention. We keep the +# corresponding restriction lints active in production code but exempt tests. +allow-expect-in-tests = true +allow-indexing-slicing-in-tests = true +allow-panic-in-tests = true +allow-unwrap-in-tests = true diff --git a/crates/edgezero-adapter-axum/Cargo.toml b/crates/edgezero-adapter-axum/Cargo.toml index 9f9b3c9e..3351b99f 100644 --- a/crates/edgezero-adapter-axum/Cargo.toml +++ b/crates/edgezero-adapter-axum/Cargo.toml @@ -5,6 +5,9 @@ version = { workspace = true } authors = { workspace = true } license = { workspace = true } +[lints] +workspace = true + [features] default = ["axum"] axum = [ @@ -39,6 +42,7 @@ http = { workspace = true } log = { workspace = true } redb = { workspace = true, optional = true } reqwest = { workspace = true, optional = true } +serde_json = { workspace = true } simple_logger = { workspace = true } thiserror = { workspace = true } tokio = { workspace = true, optional = true } diff --git a/crates/edgezero-adapter-axum/src/cli.rs b/crates/edgezero-adapter-axum/src/cli.rs index 79eb838b..c97b4b22 100644 --- a/crates/edgezero-adapter-axum/src/cli.rs +++ b/crates/edgezero-adapter-axum/src/cli.rs @@ -1,3 +1,5 @@ +use std::collections::BTreeMap; +use std::env; use std::fs; use std::net::{IpAddr, SocketAddr}; use std::path::{Path, PathBuf}; @@ -7,11 +9,15 @@ use ctor::ctor; use edgezero_adapter::cli_support::{ find_manifest_upwards, find_workspace_root, path_distance, read_package_name, }; +use edgezero_adapter::registry::{ + register_adapter, Adapter, AdapterAction, AdapterPushContext, ProvisionStores, ResolvedStoreId, +}; use edgezero_adapter::scaffold::{ register_adapter_blueprint, AdapterBlueprint, AdapterFileSpec, CommandTemplates, DependencySpec, LoggingDefaults, ManifestSpec, ReadmeInfo, TemplateRegistration, }; -use edgezero_adapter::{register_adapter, Adapter, AdapterAction}; +use edgezero_core::addr; +use edgezero_core::manifest::ManifestLoader; use toml::Value; use walkdir::WalkDir; @@ -92,36 +98,199 @@ static AXUM_BLUEPRINT: AdapterBlueprint = AdapterBlueprint { dev_heading: "{display} (local)", dev_steps: &[ "`cd {crate_dir}`", - "`cargo run` or `edgezero-cli serve --adapter axum`", + "`cargo run` or `edgezero serve --adapter axum`", ], }, run_module: "edgezero_adapter_axum", }; +static AXUM_ADAPTER: AxumCliAdapter = AxumCliAdapter; + struct AxumCliAdapter; -static AXUM_ADAPTER: AxumCliAdapter = AxumCliAdapter; +#[derive(Debug)] +struct AxumProject { + addr: SocketAddr, + axum_host: Option, + axum_manifest: PathBuf, + axum_port: Option, + cargo_manifest: PathBuf, + crate_dir: PathBuf, + crate_name: String, + env_host: Option, + env_port: Option, +} -impl Adapter for AxumCliAdapter { - fn name(&self) -> &'static str { - "axum" - } +#[derive(Debug, Default)] +struct EdgezeroAxumConfig { + host: Option, + port: Option, +} +#[expect( + clippy::missing_trait_methods, + reason = "axum has no validate_app_config_keys / validate_adapter_manifest / validate_typed_secrets requirements; those three trait defaults are intentionally inherited. `single_store_kinds` IS overridden below (returns `&[\"secrets\"]`)." +)] +impl Adapter for AxumCliAdapter { fn execute(&self, action: AdapterAction, args: &[String]) -> Result<(), String> { match action { + // The axum adapter is the in-process native dev server — + // there is no remote auth provider to sign in/out of. + // Per spec this is an explicit no-op. + AdapterAction::AuthLogin | AdapterAction::AuthLogout | AdapterAction::AuthStatus => { + log::info!( + "[edgezero] axum has no remote auth surface; `auth` is a no-op for this adapter" + ); + Ok(()) + } AdapterAction::Build => build(args), AdapterAction::Deploy => deploy(args), AdapterAction::Serve => serve(args), + other => Err(format!("axum adapter does not support {other:?}")), } } + + fn name(&self) -> &'static str { + "axum" + } + + fn provision( + &self, + _manifest_root: &Path, + _adapter_manifest_path: Option<&str>, + _component_selector: Option<&str>, + stores: &ProvisionStores<'_>, + _dry_run: bool, + ) -> Result, String> { + //: axum has no remote resources. Print one note per + // declared store id so the operator sees the CLI heard + // them — same shape `dry_run` would have, since there is + // nothing to actually perform. + let mut out = Vec::with_capacity( + stores + .kv + .len() + .saturating_add(stores.config.len()) + .saturating_add(stores.secrets.len()), + ); + for store in stores.kv { + let logical = store.logical.as_str(); + out.push(format!( + "axum KV store `{logical}` is in-memory; nothing to provision" + )); + } + for store in stores.config { + // Axum reads `.edgezero/local-config-.json`. + // The platform name is informational here -- the env + // overlay isn't used for local file paths because the + // path encoding is the spec's canonical form. + let logical = store.logical.as_str(); + out.push(format!( + "axum config store `{logical}` reads `.edgezero/local-config-{logical}.json`; nothing to provision" + )); + } + for store in stores.secrets { + let logical = store.logical.as_str(); + out.push(format!( + "axum secret store `{logical}` reads env vars; nothing to provision" + )); + } + if out.is_empty() { + out.push("axum has no declared stores to provision".to_owned()); + } + Ok(out) + } + + fn push_config_entries( + &self, + manifest_root: &Path, + _adapter_manifest_path: Option<&str>, + _component_selector: Option<&str>, + store: &ResolvedStoreId, + entries: &[(String, String)], + _push_ctx: &AdapterPushContext<'_>, + dry_run: bool, + ) -> Result, String> { + //: axum is local-only. Push writes the same flat + // `string -> string` JSON object `AxumConfigStore` reads + // back from `.edgezero/local-config-.json`. The path + // is keyed on the LOGICAL id, not the env-resolved + // platform name -- the local file flow is the spec's + // canonical form and isn't subject to the per-store env + // overlay (which targets platform store names, not local + // file paths). + let logical = store.logical.as_str(); + let local_dir = manifest_root.join(".edgezero"); + let target = local_dir.join(format!("local-config-{logical}.json")); + if dry_run { + return Ok(vec![format!( + "would write {} entries to {}", + entries.len(), + target.display() + )]); + } + fs::create_dir_all(&local_dir) + .map_err(|err| format!("failed to create {}: {err}", local_dir.display()))?; + let map: BTreeMap<&str, &str> = entries + .iter() + .map(|(key, value)| (key.as_str(), value.as_str())) + .collect(); + let json = serde_json::to_string_pretty(&map) + .map_err(|err| format!("failed to serialize config to JSON: {err}"))?; + fs::write(&target, json) + .map_err(|err| format!("failed to write {}: {err}", target.display()))?; + Ok(vec![format!( + "wrote {} entries to {}", + entries.len(), + target.display() + )]) + } + + fn push_config_entries_local( + &self, + manifest_root: &Path, + adapter_manifest_path: Option<&str>, + component_selector: Option<&str>, + store: &ResolvedStoreId, + entries: &[(String, String)], + push_ctx: &AdapterPushContext<'_>, + dry_run: bool, + ) -> Result, String> { + // Axum is local-only: the default push already writes + // `.edgezero/local-config-.json`, which is what the + // running dev server reads. `--local` is therefore the + // same as the default; we delegate and prepend a notice + // so the operator who typed `--local` for parity with + // fastly/cloudflare knows there was nothing extra to do. + let mut lines = self.push_config_entries( + manifest_root, + adapter_manifest_path, + component_selector, + store, + entries, + push_ctx, + dry_run, + )?; + let notice = + "axum push is always local: `--local` has no separate effect (writes the same `.edgezero/local-config-.json` either way)".to_owned(); + lines.insert(0, notice); + Ok(lines) + } + + fn single_store_kinds(&self) -> &'static [&'static str] { + //: axum is Multi for KV (local file dirs) and Config + // (local JSON files), Single for Secrets (env vars). + &["secrets"] + } } +#[inline] pub fn register() { register_adapter(&AXUM_ADAPTER); register_adapter_blueprint(&AXUM_BLUEPRINT); } -#[ctor] +#[ctor(unsafe)] fn register_ctor() { register(); } @@ -140,21 +309,8 @@ fn deploy(_extra_args: &[String]) -> Result<(), String> { Err("Axum adapter does not define a deploy command. Extend your workspace manifest with one if needed.".into()) } -#[derive(Debug)] -struct AxumProject { - axum_manifest: PathBuf, - crate_dir: PathBuf, - cargo_manifest: PathBuf, - crate_name: String, - addr: SocketAddr, - env_host: Option, - env_port: Option, - axum_host: Option, - axum_port: Option, -} - fn locate_project() -> Result { - let cwd = std::env::current_dir().map_err(|err| err.to_string())?; + let cwd = env::current_dir().map_err(|err| err.to_string())?; let manifest = find_axum_manifest(&cwd)?; read_axum_project(&manifest) } @@ -162,14 +318,14 @@ fn locate_project() -> Result { fn run_cargo(project: &AxumProject, subcommand: &str, extra_args: &[String]) -> Result<(), String> { let resolution = resolve_subprocess_addr(project)?; for warning in &resolution.warnings { - eprintln!("[edgezero] {warning}"); + log::warn!("[edgezero] {warning}"); } - let addr = resolution.addr; + let bind_addr = resolution.addr; let display = project.crate_dir.display(); - println!( - "[edgezero] Axum {subcommand} ({}) in {} ({})", - project.crate_name, display, addr + log::info!( + "[edgezero] Axum {subcommand} ({}) in {display} ({bind_addr})", + project.crate_name ); let mut command = Command::new("cargo"); command.arg(subcommand); @@ -182,21 +338,23 @@ fn run_cargo(project: &AxumProject, subcommand: &str, extra_args: &[String]) -> ); command.args(extra_args); command.current_dir(&project.crate_dir); - command.env("EDGEZERO_HOST", addr.ip().to_string()); - command.env("EDGEZERO_PORT", addr.port().to_string()); + // Canonical env vars. The runtime's `EnvConfig` reads only the + // `EDGEZERO__*` form (see `crates/edgezero-core/src/env_config.rs`); + // setting the legacy `EDGEZERO_HOST` / `EDGEZERO_PORT` here would be a + // no-op for the child process. + command.env("EDGEZERO__ADAPTER__HOST", bind_addr.ip().to_string()); + command.env("EDGEZERO__ADAPTER__PORT", bind_addr.port().to_string()); let status = command .status() .map_err(|err| format!("failed to run cargo {subcommand}: {err}"))?; if status.success() { Ok(()) } else { - Err(format!("cargo {subcommand} failed with status {}", status)) + Err(format!("cargo {subcommand} failed with status {status}")) } } -fn resolve_subprocess_addr( - project: &AxumProject, -) -> Result { +fn resolve_subprocess_addr(project: &AxumProject) -> Result { let axum_only = resolve_subprocess_addr_from_parts( project.env_host.as_deref(), project.env_port.as_deref(), @@ -205,7 +363,10 @@ fn resolve_subprocess_addr( project.axum_host.as_deref(), project.axum_port, ); - debug_assert_eq!(project.addr, axum_only.addr); + debug_assert_eq!( + project.addr, axum_only.addr, + "cached AxumProject.addr must match a fresh axum-only resolution" + ); let edgezero = load_edgezero_axum_config(&project.axum_manifest)?; Ok(resolve_subprocess_addr_from_parts( @@ -225,12 +386,12 @@ fn resolve_subprocess_addr_from_parts( edgezero_port: Option, axum_host: Option<&str>, axum_port: Option, -) -> edgezero_core::addr::BindAddrResolution { +) -> addr::BindAddrResolution { let mut warnings = Vec::new(); let host = resolve_subprocess_host(env_host, edgezero_host, axum_host, &mut warnings); let port = resolve_subprocess_port(env_port, edgezero_port, axum_port, &mut warnings); - edgezero_core::addr::BindAddrResolution { + addr::BindAddrResolution { addr: SocketAddr::from((host, port)), warnings, } @@ -246,7 +407,7 @@ fn resolve_subprocess_host( match value.parse() { Ok(host) => return host, Err(_) => warnings.push(format!( - "EDGEZERO_HOST={value:?} is not a valid IP address (hostnames like \"localhost\" are not supported); falling back" + "EDGEZERO__ADAPTER__HOST={value:?} is not a valid IP address (hostnames like \"localhost\" are not supported); falling back" )), } } @@ -269,7 +430,7 @@ fn resolve_subprocess_host( } } - edgezero_core::addr::DEFAULT_HOST + addr::DEFAULT_HOST } fn resolve_subprocess_port( @@ -281,41 +442,32 @@ fn resolve_subprocess_port( if let Some(value) = env_port { match value.parse::() { Ok(0) => warnings.push( - "EDGEZERO_PORT=\"0\" is not supported (would bind to a random OS port); falling back" - .to_string(), + "EDGEZERO__ADAPTER__PORT=\"0\" is not supported (would bind to a random OS port); falling back".to_owned(), ), Ok(port) => return port, Err(_) => warnings.push(format!( - "EDGEZERO_PORT={value:?} is not a valid port number; falling back" + "EDGEZERO__ADAPTER__PORT={value:?} is not a valid port number; falling back" )), } } - if let Some(0) = edgezero_port { - warnings.push( - "configured port=0 in edgezero.toml is not supported (would bind to a random OS port); falling back" - .to_string(), - ); - } else if let Some(port) = edgezero_port { - return port; + match edgezero_port { + Some(0) => warnings.push( + "configured port=0 in edgezero.toml is not supported (would bind to a random OS port); falling back".to_owned(), + ), + Some(port) => return port, + None => {} } - if let Some(0) = axum_port { - warnings.push( - "configured port=0 in axum.toml is not supported (would bind to a random OS port); falling back" - .to_string(), - ); - } else if let Some(port) = axum_port { - return port; + match axum_port { + Some(0) => warnings.push( + "configured port=0 in axum.toml is not supported (would bind to a random OS port); falling back".to_owned(), + ), + Some(port) => return port, + None => {} } - edgezero_core::addr::DEFAULT_PORT -} - -#[derive(Debug, Default)] -struct EdgezeroAxumConfig { - host: Option, - port: Option, + addr::DEFAULT_PORT } fn load_edgezero_axum_config(axum_manifest: &Path) -> Result, String> { @@ -327,11 +479,10 @@ fn load_edgezero_axum_config(axum_manifest: &Path) -> Result adapter, - None => return Ok(None), + let Some(adapter) = manifest.manifest().adapters.get("axum") else { + return Ok(None); }; Ok(Some(EdgezeroAxumConfig { @@ -351,15 +502,12 @@ fn find_axum_manifest(start: &Path) -> Result { .max_depth(8) .into_iter() .filter_map(Result::ok) - .map(|entry| entry.into_path()) + .map(walkdir::DirEntry::into_path) .filter(|path| { - path.file_name() - .map(|name| name == "axum.toml") - .unwrap_or(false) + path.file_name().is_some_and(|name| name == "axum.toml") && path .parent() - .map(|dir| dir.join("Cargo.toml").exists()) - .unwrap_or(false) + .is_some_and(|dir| dir.join("Cargo.toml").exists()) }) .collect(); @@ -376,8 +524,16 @@ fn find_axum_manifest(start: &Path) -> Result { } fn read_axum_project(manifest: &Path) -> Result { - let env_host = std::env::var("EDGEZERO_HOST").ok(); - let env_port = std::env::var("EDGEZERO_PORT").ok(); + // Per the spec hard-cutoff: only the canonical + // `EDGEZERO__ADAPTER__HOST` / `EDGEZERO__ADAPTER__PORT` env + // vars are honoured. The pre-rewrite `EDGEZERO_HOST` / + // `EDGEZERO_PORT` shim is gone -- the core runtime stopped + // reading those names, and keeping the axum wrapper compatible + // with them silently revived a precedence path the rest of + // the codebase had cut. Operators with legacy CI scripts must + // rename to the canonical form. + let env_host = env::var("EDGEZERO__ADAPTER__HOST").ok(); + let env_port = env::var("EDGEZERO__ADAPTER__PORT").ok(); read_axum_project_with_env(manifest, env_host.as_deref(), env_port.as_deref()) } @@ -396,13 +552,13 @@ fn read_axum_project_with_env( .and_then(Value::as_table) .ok_or_else(|| format!("adapter table missing in {}", manifest.display()))?; - let crate_dir = adapter + let crate_dir_rel = adapter .get("crate_dir") .and_then(Value::as_str) .ok_or_else(|| format!("adapter.crate_dir missing in {}", manifest.display()))?; let manifest_dir = manifest.parent().unwrap_or_else(|| Path::new(".")); - let crate_dir = manifest_dir.join(crate_dir); + let crate_dir = manifest_dir.join(crate_dir_rel); let cargo_manifest = crate_dir.join("Cargo.toml"); if !cargo_manifest.exists() { return Err(format!( @@ -412,54 +568,49 @@ fn read_axum_project_with_env( )); } - let crate_name = adapter - .get("crate") - .and_then(Value::as_str) - .map(|s| s.to_string()) - .unwrap_or_else(|| { + let crate_name = adapter.get("crate").and_then(Value::as_str).map_or_else( + || { read_package_name(&cargo_manifest).unwrap_or_else(|_| { crate_dir .file_name() - .and_then(|n| n.to_str()) + .and_then(|name| name.to_str()) .unwrap_or("axum-adapter") - .to_string() + .to_owned() }) - }); + }, + ToOwned::to_owned, + ); let config_host = adapter .get("host") .and_then(Value::as_str) - .map(str::to_string); + .map(str::to_owned); let config_port = match adapter.get("port").and_then(Value::as_integer) { - Some(value) => Some(u16::try_from(value).map_err(|_| { + Some(port_value) => Some(u16::try_from(port_value).map_err(|err| { format!( - "adapter.port in {} must be between 0 and 65535", + "adapter.port in {} must be between 0 and 65535 ({err})", manifest.display() ) })?), None => None, }; - let resolution = edgezero_core::addr::resolve_bind_addr( - env_host, - env_port, - config_host.as_deref(), - config_port, - ); + let resolution = + addr::resolve_bind_addr(env_host, env_port, config_host.as_deref(), config_port); for warning in &resolution.warnings { - eprintln!("[edgezero] {warning} (in {})", manifest.display()); + log::warn!("[edgezero] {warning} (in {})", manifest.display()); } Ok(AxumProject { - axum_manifest: manifest.to_path_buf(), - crate_dir, - cargo_manifest, - crate_name, addr: resolution.addr, - env_host: env_host.map(str::to_string), - env_port: env_port.map(str::to_string), axum_host: config_host, + axum_manifest: manifest.to_path_buf(), axum_port: config_port, + cargo_manifest, + crate_dir, + crate_name, + env_host: env_host.map(str::to_owned), + env_port: env_port.map(str::to_owned), }) } @@ -467,6 +618,7 @@ fn read_axum_project_with_env( mod tests { use super::*; use edgezero_adapter::cli_support::find_manifest_upwards; + use std::net::Ipv6Addr; use tempfile::tempdir; #[test] @@ -489,8 +641,8 @@ mod tests { assert_eq!(project.crate_name, "demo"); assert_eq!(project.crate_dir, root); assert_eq!(project.cargo_manifest, root.join("Cargo.toml")); - assert_eq!(project.addr.port(), edgezero_core::addr::DEFAULT_PORT); - assert_eq!(project.addr.ip(), edgezero_core::addr::DEFAULT_HOST); + assert_eq!(project.addr.port(), addr::DEFAULT_PORT); + assert_eq!(project.addr.ip(), addr::DEFAULT_HOST); } #[test] @@ -548,7 +700,7 @@ mod tests { let result = read_axum_project_with_env(&root.join("axum.toml"), None, None); match result { Ok(_) => panic!("expected error"), - Err(e) => assert!(e.contains("must be between 0 and 65535")), + Err(err) => assert!(err.contains("must be between 0 and 65535")), } } @@ -569,7 +721,7 @@ mod tests { let project = read_axum_project_with_env(&root.join("axum.toml"), None, None).expect("project"); - assert_eq!(project.addr.port(), edgezero_core::addr::DEFAULT_PORT); + assert_eq!(project.addr.port(), addr::DEFAULT_PORT); } #[test] @@ -588,7 +740,7 @@ mod tests { .unwrap(); let result = read_axum_project_with_env(&root.join("axum.toml"), None, None); - assert!(result.is_err()); + result.unwrap_err(); } #[test] @@ -605,7 +757,7 @@ mod tests { let result = read_axum_project_with_env(&root.join("axum.toml"), None, None); match result { Ok(_) => panic!("expected error"), - Err(e) => assert!(e.contains("adapter table missing")), + Err(err) => assert!(err.contains("adapter table missing")), } } @@ -623,7 +775,7 @@ mod tests { let result = read_axum_project_with_env(&root.join("axum.toml"), None, None); match result { Ok(_) => panic!("expected error"), - Err(e) => assert!(e.contains("crate_dir missing")), + Err(err) => assert!(err.contains("crate_dir missing")), } } @@ -643,7 +795,7 @@ mod tests { let result = read_axum_project_with_env(&root.join("axum.toml"), None, None); match result { Ok(_) => panic!("expected error"), - Err(e) => assert!(e.contains("Cargo.toml missing")), + Err(err) => assert!(err.contains("Cargo.toml missing")), } } @@ -704,7 +856,7 @@ mod tests { let project = read_axum_project_with_env(&root.join("axum.toml"), None, None).expect("project"); - assert_eq!(project.addr.port(), 65535); + assert_eq!(project.addr.port(), u16::MAX); } #[test] @@ -744,7 +896,7 @@ mod tests { let project = read_axum_project_with_env(&root.join("axum.toml"), None, None).expect("project"); - assert_eq!(project.addr.ip(), edgezero_core::addr::DEFAULT_HOST); + assert_eq!(project.addr.ip(), addr::DEFAULT_HOST); } #[test] @@ -764,7 +916,7 @@ mod tests { let project = read_axum_project_with_env(&root.join("axum.toml"), None, None).expect("project"); - assert_eq!(project.addr.ip(), std::net::IpAddr::from([0, 0, 0, 0])); + assert_eq!(project.addr.ip(), IpAddr::from([0, 0, 0, 0])); } #[test] @@ -784,7 +936,7 @@ mod tests { let project = read_axum_project_with_env(&root.join("axum.toml"), None, None).expect("project"); - assert_eq!(project.addr.ip(), edgezero_core::addr::DEFAULT_HOST); + assert_eq!(project.addr.ip(), addr::DEFAULT_HOST); } #[test] @@ -879,7 +1031,7 @@ mod tests { read_axum_project_with_env(&root.join("axum.toml"), Some("0.0.0.0"), Some("9999")); let project = result.expect("project"); - assert_eq!(project.addr.ip(), std::net::IpAddr::from([0, 0, 0, 0])); + assert_eq!(project.addr.ip(), IpAddr::from([0, 0, 0, 0])); assert_eq!(project.addr.port(), 9999); } @@ -920,7 +1072,7 @@ mod tests { assert_eq!( resolution.addr, - SocketAddr::from((std::net::Ipv6Addr::LOCALHOST, 9000)) + SocketAddr::from((Ipv6Addr::LOCALHOST, 9000)) ); assert!(resolution.warnings.is_empty()); } @@ -960,4 +1112,99 @@ mod tests { assert_eq!(AXUM_BLUEPRINT.id, "axum"); assert_eq!(AXUM_BLUEPRINT.display_name, "Axum"); } + + // ---------- push_config_entries ---------- + + #[test] + fn push_writes_flat_json_to_local_config_file() { + let dir = tempfile::tempdir().expect("tempdir"); + let entries = vec![ + ("greeting".to_owned(), "hello".to_owned()), + ("service.timeout_ms".to_owned(), "1500".to_owned()), + ]; + let lines = AxumCliAdapter + .push_config_entries( + dir.path(), + None, + None, + &ResolvedStoreId::from_logical("app_config"), + &entries, + &AdapterPushContext::new(), + false, + ) + .expect("push succeeds"); + assert_eq!(lines.len(), 1); + assert!( + lines[0].contains("wrote 2 entries"), + "status line names count: {lines:?}" + ); + let json_path = dir.path().join(".edgezero/local-config-app_config.json"); + let raw = fs::read_to_string(&json_path).expect("read written file"); + let parsed: serde_json::Value = serde_json::from_str(&raw).expect("valid JSON"); + assert_eq!(parsed["greeting"], "hello"); + assert_eq!(parsed["service.timeout_ms"], "1500"); + } + + #[test] + fn push_dry_run_does_not_create_local_dir_or_file() { + let dir = tempfile::tempdir().expect("tempdir"); + let entries = vec![("greeting".to_owned(), "hello".to_owned())]; + let lines = AxumCliAdapter + .push_config_entries( + dir.path(), + None, + None, + &ResolvedStoreId::from_logical("app_config"), + &entries, + &AdapterPushContext::new(), + true, + ) + .expect("dry-run succeeds"); + assert!( + lines[0].contains("would write 1 entries"), + "dry-run line: {lines:?}" + ); + assert!( + !dir.path().join(".edgezero").exists(), + ".edgezero must not exist after dry-run" + ); + } + + #[test] + fn push_creates_dot_edgezero_directory_when_missing() { + let dir = tempfile::tempdir().expect("tempdir"); + let entries = vec![("key".to_owned(), "value".to_owned())]; + AxumCliAdapter + .push_config_entries( + dir.path(), + None, + None, + &ResolvedStoreId::from_logical("x"), + &entries, + &AdapterPushContext::new(), + false, + ) + .expect("push succeeds"); + assert!(dir.path().join(".edgezero").is_dir(), ".edgezero created"); + } + + #[test] + fn push_with_empty_entries_writes_empty_json_object() { + let dir = tempfile::tempdir().expect("tempdir"); + AxumCliAdapter + .push_config_entries( + dir.path(), + None, + None, + &ResolvedStoreId::from_logical("empty"), + &[], + &AdapterPushContext::new(), + false, + ) + .expect("push succeeds even with no entries"); + let raw = fs::read_to_string(dir.path().join(".edgezero/local-config-empty.json")) + .expect("read written file"); + let parsed: serde_json::Value = serde_json::from_str(&raw).expect("valid JSON"); + assert_eq!(parsed, serde_json::json!({})); + } } diff --git a/crates/edgezero-adapter-axum/src/config_store.rs b/crates/edgezero-adapter-axum/src/config_store.rs index 29025185..34759bb6 100644 --- a/crates/edgezero-adapter-axum/src/config_store.rs +++ b/crates/edgezero-adapter-axum/src/config_store.rs @@ -1,154 +1,220 @@ -//! Axum adapter config store: env vars with in-memory defaults fallback. +//! Axum adapter config store: reads from a per-id local JSON file. +//! +//! Each declared `[stores.config].ids` id maps to a file at +//! `.edgezero/local-config-.json`. The file holds a flat object of +//! `string -> string` pairs — the same shape `edgezero config push +//! --adapter axum` writes. +//! +//! If the file is absent the store is empty (`get` returns `Ok(None)` for +//! every key). This keeps `edgezero serve --adapter axum` permissive when +//! the project hasn't seeded any local config yet. use std::collections::HashMap; +use std::fs; +use std::io::ErrorKind; +use std::path::{Path, PathBuf}; +use async_trait::async_trait; use edgezero_core::config_store::{ConfigStore, ConfigStoreError}; -/// Config store for local dev / Axum. Reads from env vars with manifest -/// defaults as fallback. Env vars take precedence over defaults. +/// Local-file config store used by the Axum dev server. /// -/// # Note on `from_env` -/// -/// [`AxumConfigStore::from_env`] only reads environment variables for keys -/// declared in `[stores.config.defaults]`. Use an empty-string default when a -/// key should be overrideable from env without carrying a real default value. +/// Construction is fallible only when the backing file is present but +/// malformed JSON — a missing file is a documented "no values seeded yet" +/// state, not an error. pub struct AxumConfigStore { - env: HashMap, - defaults: HashMap, + data: HashMap, } impl AxumConfigStore { - /// Create from env vars and optional manifest defaults. - pub fn new( - env: impl IntoIterator, - defaults: impl IntoIterator, - ) -> Self { + fn empty() -> Self { Self { - env: env.into_iter().collect(), - defaults: defaults.into_iter().collect(), + data: HashMap::new(), } } - /// Create from the current process environment and manifest defaults. - pub fn from_env(defaults: impl IntoIterator) -> Self { - Self::from_lookup(defaults, |key| std::env::var(key).ok()) + /// Open the local-file config store for a given logical id. + /// + /// Reads `.edgezero/local-config-.json` if present and parses it + /// as a flat `string -> string` JSON object. A missing file yields an + /// empty store. A malformed file yields + /// [`ConfigStoreError::Unavailable`] so the dev-server log surfaces + /// the problem at startup rather than at first request. + /// + /// # Errors + /// Returns [`ConfigStoreError::Unavailable`] when the backing file + /// exists but cannot be read or parsed. + #[inline] + pub fn from_local_file(id: &str) -> Result { + Self::from_path(&Self::local_path(id)) } - fn from_lookup(defaults: impl IntoIterator, mut lookup: F) -> Self + /// Build a store from an explicit `{key -> value}` map. Intended for + /// tests and for callers that already have parsed config in memory. + #[inline] + pub fn from_map(entries: E) -> Self where - F: FnMut(&str) -> Option, + E: IntoIterator, { - let defaults: HashMap = defaults.into_iter().collect(); - let env = defaults - .keys() - .filter_map(|key| lookup(key).map(|value| (key.clone(), value))) - .collect(); - Self { env, defaults } + Self { + data: entries.into_iter().collect(), + } + } + + /// Open the local-file config store at an explicit path + /// (overrides the `.edgezero/local-config-.json` default + /// from [`Self::from_local_file`]). Intended for downstream + /// integration tests that want to load a JSON payload written + /// by `config push --adapter axum` to a tempdir, without + /// changing the process CWD. + /// + /// The file must contain a flat JSON object of `string -> string` + /// pairs, matching what `config push --adapter axum` writes: + /// + /// ```json + /// { + /// "greeting": "hello", + /// "feature.new_checkout": "false", + /// "service.timeout_ms": "1500" + /// } + /// ``` + /// + /// Dotted keys are stored verbatim (no nesting): the runtime + /// extractors look up the dotted form as a single key. Non-string + /// values (`{"x": 42}`, nested objects, arrays) are rejected. + /// + /// Behaviour matches `from_local_file`: a missing file yields + /// an empty store; a present-but-malformed file yields + /// [`ConfigStoreError::Unavailable`]. + /// + /// # Errors + /// Returns [`ConfigStoreError::Unavailable`] when the file + /// exists but cannot be read or parsed. + #[inline] + pub fn from_path(path: &Path) -> Result { + let raw = match fs::read_to_string(path) { + Ok(raw) => raw, + Err(err) if err.kind() == ErrorKind::NotFound => { + return Ok(Self::empty()); + } + Err(err) => { + return Err(ConfigStoreError::unavailable(format!( + "failed to read {}: {err}", + path.display() + ))); + } + }; + let data: HashMap = serde_json::from_str(&raw).map_err(|err| { + ConfigStoreError::unavailable(format!( + "{} is not a flat string -> string JSON object: {err}", + path.display() + )) + })?; + Ok(Self { data }) + } + + /// Resolve the on-disk path for the given logical config id. + #[must_use] + #[inline] + pub fn local_path(id: &str) -> PathBuf { + PathBuf::from(".edgezero").join(format!("local-config-{id}.json")) } } +#[async_trait(?Send)] impl ConfigStore for AxumConfigStore { - fn get(&self, key: &str) -> Result, ConfigStoreError> { - Ok(self - .env - .get(key) - .or_else(|| self.defaults.get(key)) - .cloned()) + #[inline] + async fn get(&self, key: &str) -> Result, ConfigStoreError> { + Ok(self.data.get(key).cloned()) } } #[cfg(test)] mod tests { - use super::*; + // Run the shared contract tests against AxumConfigStore. + edgezero_core::config_store_contract_tests!(axum_config_store_contract, { + AxumConfigStore::from_map([ + ("contract.key.a".to_owned(), "value_a".to_owned()), + ("contract.key.b".to_owned(), "value_b".to_owned()), + ]) + }); - fn store(env: &[(&str, &str)], defaults: &[(&str, &str)]) -> AxumConfigStore { - AxumConfigStore::new( - env.iter().map(|(k, v)| (k.to_string(), v.to_string())), - defaults.iter().map(|(k, v)| (k.to_string(), v.to_string())), - ) - } + use super::*; + use futures::executor::block_on; + use tempfile::tempdir; #[test] - fn axum_config_store_returns_values() { - let s = store(&[("MY_KEY", "my_val")], &[]); + fn axum_config_store_from_map_returns_values() { + let cs = AxumConfigStore::from_map([("greeting".to_owned(), "hello".to_owned())]); assert_eq!( - s.get("MY_KEY").expect("config value"), - Some("my_val".to_string()) + block_on(cs.get("greeting")).expect("config value"), + Some("hello".to_owned()) ); + assert_eq!(block_on(cs.get("missing")).expect("missing config"), None); } #[test] - fn axum_config_store_returns_none_for_missing() { - let s = store(&[], &[]); - assert_eq!(s.get("NOPE").expect("missing config"), None); + fn axum_config_store_from_path_returns_empty_for_missing_file() { + let temp = tempdir().expect("tempdir"); + let cs = AxumConfigStore::from_path(&temp.path().join("nope.json")) + .expect("missing file is permissive"); + assert_eq!(block_on(cs.get("anything")).expect("empty store"), None); } #[test] - fn axum_config_store_env_overrides_defaults() { - let s = store(&[("KEY", "from_env")], &[("KEY", "from_default")]); + fn axum_config_store_from_path_reads_flat_json() { + let temp = tempdir().expect("tempdir"); + let path = temp.path().join("local-config-app_config.json"); + fs::write( + &path, + r#"{"greeting":"hello from file","feature.new_checkout":"false"}"#, + ) + .expect("write json"); + + let cs = AxumConfigStore::from_path(&path).expect("parse json"); + assert_eq!( + block_on(cs.get("greeting")).expect("value"), + Some("hello from file".to_owned()) + ); assert_eq!( - s.get("KEY").expect("config value"), - Some("from_env".to_string()) + block_on(cs.get("feature.new_checkout")).expect("dotted value"), + Some("false".to_owned()) ); + assert_eq!(block_on(cs.get("missing")).expect("missing"), None); } #[test] - fn axum_config_store_falls_back_to_defaults() { - let s = store(&[], &[("KEY", "default_val")]); - assert_eq!( - s.get("KEY").expect("default config"), - Some("default_val".to_string()) - ); + fn axum_config_store_from_path_rejects_malformed_json() { + let temp = tempdir().expect("tempdir"); + let path = temp.path().join("local-config-bad.json"); + fs::write(&path, "{not json}").expect("write"); + + match AxumConfigStore::from_path(&path) { + Err(ConfigStoreError::Unavailable { .. }) => {} + Err(other) => panic!("expected Unavailable, got {other:?}"), + Ok(_) => panic!("malformed JSON must surface as error"), + } } #[test] - fn axum_config_store_from_env_reads_only_declared_keys() { - let s = AxumConfigStore::from_lookup( - [ - ("feature.new_checkout".to_string(), "false".to_string()), - ("service.timeout_ms".to_string(), "1500".to_string()), - ], - |key| match key { - "feature.new_checkout" => Some("true".to_string()), - "DATABASE_URL" => Some("postgres://secret".to_string()), - _ => None, - }, - ); + fn axum_config_store_from_path_rejects_non_string_values() { + let temp = tempdir().expect("tempdir"); + let path = temp.path().join("local-config-numeric.json"); + fs::write(&path, r#"{"greeting":42}"#).expect("write"); + + match AxumConfigStore::from_path(&path) { + Err(ConfigStoreError::Unavailable { .. }) => {} + Err(other) => panic!("expected Unavailable, got {other:?}"), + Ok(_) => panic!("non-string values must surface as error"), + } + } + #[test] + fn local_path_is_keyed_by_logical_id() { + let path = AxumConfigStore::local_path("app_config"); assert_eq!( - s.get("feature.new_checkout").expect("allowed env override"), - Some("true".to_string()) - ); - assert_eq!( - s.get("service.timeout_ms").expect("default fallback"), - Some("1500".to_string()) - ); - assert_eq!( - s.get("DATABASE_URL") - .expect("undeclared key should stay hidden"), - None + path, + PathBuf::from(".edgezero/local-config-app_config.json") ); } - - // Run the shared contract tests against AxumConfigStore (env path). - edgezero_core::config_store_contract_tests!(axum_config_store_env_contract, { - AxumConfigStore::new( - [ - ("contract.key.a".to_string(), "value_a".to_string()), - ("contract.key.b".to_string(), "value_b".to_string()), - ], - [], - ) - }); - - // Run the shared contract tests against AxumConfigStore (defaults path). - edgezero_core::config_store_contract_tests!(axum_config_store_defaults_contract, { - AxumConfigStore::new( - [], - [ - ("contract.key.a".to_string(), "value_a".to_string()), - ("contract.key.b".to_string(), "value_b".to_string()), - ], - ) - }); } diff --git a/crates/edgezero-adapter-axum/src/context.rs b/crates/edgezero-adapter-axum/src/context.rs index 6fc8d9eb..88e6f1e5 100644 --- a/crates/edgezero-adapter-axum/src/context.rs +++ b/crates/edgezero-adapter-axum/src/context.rs @@ -9,13 +9,15 @@ pub struct AxumRequestContext { } impl AxumRequestContext { - pub fn insert(request: &mut Request, context: AxumRequestContext) { - request.extensions_mut().insert(context); - } - + #[inline] pub fn get(request: &Request) -> Option<&AxumRequestContext> { request.extensions().get::() } + + #[inline] + pub fn insert(request: &mut Request, context: AxumRequestContext) { + request.extensions_mut().insert(context); + } } #[cfg(test)] diff --git a/crates/edgezero-adapter-axum/src/dev_server.rs b/crates/edgezero-adapter-axum/src/dev_server.rs index 0db35737..b12fb1be 100644 --- a/crates/edgezero-adapter-axum/src/dev_server.rs +++ b/crates/edgezero-adapter-axum/src/dev_server.rs @@ -1,22 +1,35 @@ +use std::fs; +#[cfg(test)] +use std::iter; use std::net::{SocketAddr, TcpListener as StdTcpListener}; use std::path::{Path, PathBuf}; +use std::str::FromStr as _; +use std::sync::Arc; -use anyhow::Context; +use anyhow::Context as _; use axum::Router; +use tokio::net::TcpListener as TokioTcpListener; use tokio::runtime::Builder as RuntimeBuilder; use tokio::signal; -use tower::{service_fn, Service}; +use tower::{service_fn, Service as _}; -use edgezero_core::app::Hooks; +use edgezero_core::addr; +use edgezero_core::app::{Hooks, StoreMetadata, StoresMetadata}; use edgezero_core::config_store::ConfigStoreHandle; +use edgezero_core::env_config::EnvConfig; use edgezero_core::key_value_store::KvHandle; -use edgezero_core::manifest::ManifestLoader; use edgezero_core::router::RouterService; use edgezero_core::secret_store::SecretHandle; +use edgezero_core::store_registry::{ + BoundSecretStore, ConfigRegistry, KvRegistry, SecretRegistry, StoreRegistry, +}; use log::LevelFilter; use simple_logger::SimpleLogger; +use std::collections::BTreeMap; use crate::config_store::AxumConfigStore; +use crate::key_value_store::PersistentKvStore; +use crate::secret_store::EnvSecretStore; use crate::service::EdgeZeroAxumService; #[derive(Clone, Copy, Debug, Eq, PartialEq)] @@ -25,7 +38,7 @@ enum KvInitRequirement { Required, } -/// Configuration used when running the dev server embedding EdgeZero into Axum. +/// Configuration used when running the dev server embedding `EdgeZero` into Axum. #[derive(Clone)] pub struct AxumDevServerConfig { pub addr: SocketAddr, @@ -33,12 +46,10 @@ pub struct AxumDevServerConfig { } impl Default for AxumDevServerConfig { + #[inline] fn default() -> Self { Self { - addr: SocketAddr::from(( - edgezero_core::addr::DEFAULT_HOST, - edgezero_core::addr::DEFAULT_PORT, - )), + addr: SocketAddr::from((addr::DEFAULT_HOST, addr::DEFAULT_PORT)), enable_ctrl_c: true, } } @@ -46,68 +57,39 @@ impl Default for AxumDevServerConfig { /// Optional store handles attached to every request processed by the dev server. /// -/// Build with struct init and `..Default::default()` for the fields you do not need: -/// -/// ```rust,ignore -/// let stores = Stores { kv: Some(kv_handle), ..Default::default() }; -/// ``` +/// Both single-handle fields and registry fields can be set; the service inserts +/// whichever are present. Registries take precedence in `RequestContext`. #[derive(Default)] struct Stores { + config_registry: Option, config_store: Option, kv: Option, + kv_registry: Option, + secret_registry: Option, secrets: Option, } -/// Blocking dev server runner used by the EdgeZero CLI. +/// Blocking dev server runner used by the `EdgeZero` CLI. pub struct AxumDevServer { - router: RouterService, config: AxumDevServerConfig, + router: RouterService, stores: Stores, } impl AxumDevServer { + #[must_use] + #[inline] pub fn new(router: RouterService) -> Self { Self { - router, config: AxumDevServerConfig::default(), - stores: Stores::default(), - } - } - - pub fn with_config(router: RouterService, config: AxumDevServerConfig) -> Self { - Self { router, - config, stores: Stores::default(), } } - #[must_use] - pub fn with_config_store(mut self, handle: ConfigStoreHandle) -> Self { - self.stores.config_store = Some(handle); - self - } - - /// Attach a KV store to the dev server. - /// - /// The handle is shared across all requests, making the `Kv` extractor - /// available in handlers. - #[must_use] - pub fn with_kv_handle(mut self, handle: KvHandle) -> Self { - self.stores.kv = Some(handle); - self - } - - /// Attach a secret store to the dev server. - /// - /// The handle is shared across all requests, making the `Secrets` extractor - /// available in handlers. - #[must_use] - pub fn with_secret_handle(mut self, handle: SecretHandle) -> Self { - self.stores.secrets = Some(handle); - self - } - + /// # Errors + /// Returns an error if the dev server fails to bind, the Tokio runtime fails to start, or the underlying request loop returns an error. + #[inline] pub fn run(self) -> anyhow::Result<()> { let runtime = RuntimeBuilder::new_multi_thread() .enable_all() @@ -125,20 +107,20 @@ impl AxumDevServer { } = self; // Allow binding to already-open listener if caller created one to surface errors early. - let listener = StdTcpListener::bind(config.addr) + let std_listener = StdTcpListener::bind(config.addr) .with_context(|| format!("failed to bind dev server to {}", config.addr))?; - listener + std_listener .set_nonblocking(true) .context("failed to set listener to non-blocking")?; - let listener = tokio::net::TcpListener::from_std(listener) + let listener = TokioTcpListener::from_std(std_listener) .context("failed to adopt std listener into tokio")?; serve_with_stores(router, listener, config.enable_ctrl_c, stores).await } #[cfg(test)] - async fn run_with_listener(self, listener: tokio::net::TcpListener) -> anyhow::Result<()> { + async fn run_with_listener(self, listener: TokioTcpListener) -> anyhow::Result<()> { let AxumDevServer { router, config, @@ -146,10 +128,72 @@ impl AxumDevServer { } = self; serve_with_stores(router, listener, config.enable_ctrl_c, stores).await } + + #[must_use] + #[inline] + pub fn with_config(router: RouterService, config: AxumDevServerConfig) -> Self { + Self { + config, + router, + stores: Stores::default(), + } + } + + #[must_use] + #[inline] + pub fn with_config_registry(mut self, registry: ConfigRegistry) -> Self { + self.stores.config_registry = Some(registry); + self + } + + #[must_use] + #[inline] + pub fn with_config_store(mut self, handle: ConfigStoreHandle) -> Self { + self.stores.config_store = Some(handle); + self + } + + /// Attach a KV store to the dev server. + /// + /// The handle is shared across all requests, making the `Kv` extractor + /// available in handlers. + #[must_use] + #[inline] + pub fn with_kv_handle(mut self, handle: KvHandle) -> Self { + self.stores.kv = Some(handle); + self + } + + /// Attach an id-keyed KV registry to the dev server. + #[must_use] + #[inline] + pub fn with_kv_registry(mut self, registry: KvRegistry) -> Self { + self.stores.kv_registry = Some(registry); + self + } + + /// Attach a secret store to the dev server. + /// + /// The handle is shared across all requests, making the `Secrets` extractor + /// available in handlers. + #[must_use] + #[inline] + pub fn with_secret_handle(mut self, handle: SecretHandle) -> Self { + self.stores.secrets = Some(handle); + self + } + + /// Attach an id-keyed secret registry to the dev server. + #[must_use] + #[inline] + pub fn with_secret_registry(mut self, registry: SecretRegistry) -> Self { + self.stores.secret_registry = Some(registry); + self + } } -fn kv_init_requirement(manifest: &edgezero_core::manifest::Manifest) -> KvInitRequirement { - if manifest.stores.kv.is_some() { +fn kv_init_requirement(stores: StoresMetadata) -> KvInitRequirement { + if stores.kv.is_some() { KvInitRequirement::Required } else { KvInitRequirement::Optional @@ -157,10 +201,12 @@ fn kv_init_requirement(manifest: &edgezero_core::manifest::Manifest) -> KvInitRe } fn kv_store_path(store_name: &str) -> PathBuf { - if store_name == edgezero_core::manifest::DEFAULT_KV_STORE_NAME { - return PathBuf::from(".edgezero/kv.redb"); - } - + // Every declared id gets its own slug-based filename. The + // pre-rewrite hard-coded `.edgezero/kv.redb` shortcut for + // store_name == "EDGEZERO_KV" is gone -- the runtime no longer + // hands out a default name; if you reach here you have a real + // declared id and the slug encoding handles every shape + // uniformly. PathBuf::from(".edgezero").join(format!( "kv-{}-{:016x}.redb", store_name_slug(store_name), @@ -174,18 +220,14 @@ fn store_name_slug(store_name: &str) -> String { let mut slug = String::with_capacity(MAX_SLUG_LEN); let mut last_was_separator = false; for ch in store_name.chars() { - let mapped = if ch.is_ascii_alphanumeric() { - Some(ch.to_ascii_lowercase()) - } else { - None - }; + let mapped = ch.is_ascii_alphanumeric().then(|| ch.to_ascii_lowercase()); match mapped { - Some(ch) => { + Some(lower_ch) => { if slug.len() == MAX_SLUG_LEN { break; } - slug.push(ch); + slug.push(lower_ch); last_was_separator = false; } None if !slug.is_empty() && !last_was_separator => { @@ -204,7 +246,7 @@ fn store_name_slug(store_name: &str) -> String { } if slug.is_empty() { - "store".to_string() + "store".to_owned() } else { slug } @@ -212,62 +254,65 @@ fn store_name_slug(store_name: &str) -> String { fn stable_store_name_hash(store_name: &str) -> u64 { // Deterministic FNV-1a keeps local KV file names stable across processes. - let mut hash = 0xcbf29ce484222325u64; + let mut hash = 0xcbf2_9ce4_8422_2325_u64; for byte in store_name.as_bytes() { hash ^= u64::from(*byte); - hash = hash.wrapping_mul(0x100000001b3); + hash = hash.wrapping_mul(0x0000_0001_0000_01b3); } hash } -fn kv_handle_from_path(kv_path: &Path) -> anyhow::Result { +fn kv_handle_from_path(kv_path: &Path) -> anyhow::Result { if let Some(parent) = kv_path.parent() { - std::fs::create_dir_all(parent).context("failed to create KV store directory")?; + fs::create_dir_all(parent).context("failed to create KV store directory")?; } - let kv_store = std::sync::Arc::new( - crate::key_value_store::PersistentKvStore::new(kv_path) - .context("failed to create KV store")?, - ); + let kv_store = Arc::new(PersistentKvStore::new(kv_path).context("failed to create KV store")?); log::info!("KV store: {}", kv_path.display()); - Ok(edgezero_core::key_value_store::KvHandle::new(kv_store)) + Ok(KvHandle::new(kv_store)) } async fn serve_with_stores( router: RouterService, - listener: tokio::net::TcpListener, + listener: TokioTcpListener, enable_ctrl_c: bool, stores: Stores, ) -> anyhow::Result<()> { - let mut service = EdgeZeroAxumService::new(router); - if let Some(handle) = stores.config_store { - service = service.with_config_store_handle(handle); - } - if let Some(handle) = stores.kv { - service = service.with_kv_handle(handle); - } - if let Some(handle) = stores.secrets { - service = service.with_secret_handle(handle); - } - - let service = service; - let router = Router::new().fallback_service(service_fn(move |req| { + let service = { + let mut service = EdgeZeroAxumService::new(router); + if let Some(registry) = stores.config_registry { + service = service.with_config_registry(registry); + } + if let Some(handle) = stores.config_store { + service = service.with_config_store_handle(handle); + } + if let Some(registry) = stores.kv_registry { + service = service.with_kv_registry(registry); + } + if let Some(handle) = stores.kv { + service = service.with_kv_handle(handle); + } + if let Some(registry) = stores.secret_registry { + service = service.with_secret_registry(registry); + } + if let Some(handle) = stores.secrets { + service = service.with_secret_handle(handle); + } + service + }; + let axum_router = Router::new().fallback_service(service_fn(move |req| { let mut svc = service.clone(); async move { svc.call(req).await } })); - let make_service = router.into_make_service_with_connect_info::(); + let make_service = axum_router.into_make_service_with_connect_info::(); - let shutdown = if enable_ctrl_c { - Some(async { - let _ = signal::ctrl_c().await; - }) - } else { - None - }; + let shutdown = enable_ctrl_c.then_some(async { + let _ctrl_c = signal::ctrl_c().await; + }); let server = axum::serve(listener, make_service); - if let Some(shutdown) = shutdown { - let server = server.with_graceful_shutdown(shutdown); - server.await.context("axum server error")?; + if let Some(shutdown_signal) = shutdown { + let graceful_server = server.with_graceful_shutdown(shutdown_signal); + graceful_server.await.context("axum server error")?; } else { server.await.context("axum server error")?; } @@ -275,27 +320,29 @@ async fn serve_with_stores( Ok(()) } -pub fn run_app(manifest_src: &str) -> anyhow::Result<()> { - let manifest = ManifestLoader::load_from_str(manifest_src); - let m = manifest.manifest(); - let logging = m.logging_or_default(edgezero_core::app::AXUM_ADAPTER); - let kv_init_requirement = kv_init_requirement(m); - let kv_store_name = m - .kv_store_name(edgezero_core::app::AXUM_ADAPTER) - .to_string(); - let kv_path = kv_store_path(&kv_store_name); - let has_secret_store = m.secret_store_enabled("axum"); - - let level: LevelFilter = logging.level.into(); - let level = if logging.echo_stdout.unwrap_or(true) { - level - } else { - LevelFilter::Off - }; - - SimpleLogger::new().with_level(level).init().ok(); - - let resolution = resolve_addr(m); +/// Entry point for an Axum dev-server application. +/// +/// Portable store config is baked into `A` by the `app!` macro; adapter-specific +/// values (platform store names, bind host/port, logging level) are read at +/// runtime from `EDGEZERO__*` environment variables. No `edgezero.toml` is +/// required. +/// +/// # Errors +/// Returns an error if the dev server fails to bind or any required store handle cannot be initialised. +#[inline] +pub fn run_app() -> anyhow::Result<()> { + let env = EnvConfig::from_env(); + let stores = A::stores(); + let kv_init_requirement = kv_init_requirement(stores); + + let level = env + .logging_level() + .and_then(|raw| LevelFilter::from_str(raw).ok()) + .unwrap_or(LevelFilter::Info); + + let _logger_init = SimpleLogger::new().with_level(level).init(); + + let resolution = resolve_addr(&env); for warning in &resolution.warnings { log::warn!("{warning}"); } @@ -303,7 +350,7 @@ pub fn run_app(manifest_src: &str) -> anyhow::Result<()> { let app = A::build_app(); let router = app.router().clone(); - println!("[edgezero] starting axum server on http://{}", addr); + log::info!("[edgezero] starting axum server on http://{addr}"); let runtime = RuntimeBuilder::new_multi_thread() .enable_all() @@ -311,89 +358,152 @@ pub fn run_app(manifest_src: &str) -> anyhow::Result<()> { .context("failed to build tokio runtime")?; runtime.block_on(async move { - let listener = StdTcpListener::bind(addr) - .with_context(|| format!("failed to bind dev server to {}", addr))?; - listener + let std_listener = StdTcpListener::bind(addr) + .with_context(|| format!("failed to bind dev server to {addr}"))?; + std_listener .set_nonblocking(true) .context("failed to set listener to non-blocking")?; - let listener = tokio::net::TcpListener::from_std(listener) + let listener = TokioTcpListener::from_std(std_listener) .context("failed to adopt std listener into tokio")?; - let kv_handle = match kv_handle_from_path(&kv_path) { - Ok(handle) => Some(handle), - Err(err) => { - match kv_init_requirement { - KvInitRequirement::Optional => { - log::warn!( - "KV store '{}' could not be initialized at {}: {}", - kv_store_name, - kv_path.display(), - err - ); - None - } - KvInitRequirement::Required => { - return Err(err.context(format!( - "KV store '{}' is explicitly configured for axum but could not be initialized at {}", - kv_store_name, - kv_path.display() - ))); - } - } - } - }; - // Axum always resolves the config store from the manifest only. - // Unlike Fastly and Cloudflare, it does not check A::config_store() first. - // If a user implements Hooks::config_store() without a [stores.config] section - // in edgezero.toml, the override is silently ignored on Axum. - if A::config_store().is_some() && m.stores.config.is_none() { - log::warn!("A::config_store() is set but [stores.config] is missing in the manifest. This override is ignored on Axum."); - } - let config_store_handle = m.stores.config.as_ref().map(|cfg| { - let defaults = cfg.config_store_defaults().clone(); - let store = AxumConfigStore::from_env(defaults); - ConfigStoreHandle::new(std::sync::Arc::new(store)) - }); - let secret = if has_secret_store { - log::info!("Secret store: reading from environment variables"); - Some(SecretHandle::new(std::sync::Arc::new( - crate::secret_store::EnvSecretStore::new(), - ))) - } else { - None - }; - let stores = Stores { - config_store: config_store_handle, - kv: kv_handle, - secrets: secret, + let kv_registry = build_kv_registry(stores.kv, &env, kv_init_requirement)?; + let config_registry = build_config_registry(stores.config); + let secret_registry = build_secret_registry(stores.secrets, &env); + + let request_stores = Stores { + config_registry, + kv_registry, + secret_registry, + ..Stores::default() }; - serve_with_stores(router, listener, true, stores).await + serve_with_stores(router, listener, true, request_stores).await }) } -/// Resolve the bind address from environment variables and manifest config. +/// Build the per-request KV registry from baked store metadata. /// -/// Precedence (highest wins): -/// 1. `EDGEZERO_HOST` / `EDGEZERO_PORT` environment variables -/// 2. `[adapters.axum.adapter]` host/port in the manifest -/// 3. Default: `127.0.0.1:8787` -pub(crate) fn resolve_addr( - manifest: &edgezero_core::manifest::Manifest, -) -> edgezero_core::addr::BindAddrResolution { - let env_host = std::env::var("EDGEZERO_HOST").ok(); - let env_port = std::env::var("EDGEZERO_PORT").ok(); - resolve_addr_from_parts(manifest, env_host.as_deref(), env_port.as_deref()) +/// Each declared id resolves to a [`PersistentKvStore`] at +/// `.edgezero/kv--.redb`, where the file name is derived from the +/// platform store name (`EDGEZERO__STORES__KV____NAME` or the id default). +fn build_kv_registry( + kv_meta: Option, + env: &EnvConfig, + init: KvInitRequirement, +) -> anyhow::Result> { + let Some(meta) = kv_meta else { + return Ok(None); + }; + + let mut by_id: BTreeMap = BTreeMap::new(); + for id in meta.ids { + let store_name = env.store_name("kv", id); + let kv_path = kv_store_path(&store_name); + let handle = match kv_handle_from_path(&kv_path) { + Ok(handle) => handle, + Err(err) => match init { + KvInitRequirement::Optional => { + log::warn!( + "KV store '{}' (id `{}`) could not be initialized at {}: {}", + store_name, + id, + kv_path.display(), + err + ); + continue; + } + KvInitRequirement::Required => { + return Err(err.context(format!( + "KV store '{}' (id `{}`) is explicitly configured for axum but could not be initialized at {}", + store_name, + id, + kv_path.display() + ))); + } + }, + }; + by_id.insert((*id).to_owned(), handle); + } + + let default_id = meta.default.to_owned(); + if !by_id.contains_key(&default_id) { + log::warn!( + "KV registry default id `{default_id}` failed to initialize; dropping the KV registry — \ + handlers will see no KV store" + ); + } + Ok(StoreRegistry::from_parts(by_id, default_id)) +} + +/// Build the per-request config registry from the per-id local-file stores. +/// +/// Each declared id reads `.edgezero/local-config-.json`. A missing +/// file yields an empty store for that id — the dev server stays usable +/// before any `config push` has populated the file. A malformed file logs a +/// warning and the id is dropped from the registry rather than failing +/// startup, matching the cloudflare config-binding behaviour. +fn build_config_registry(config_meta: Option) -> Option { + let meta = config_meta?; + let mut by_id: BTreeMap = BTreeMap::new(); + for id in meta.ids { + let store = match AxumConfigStore::from_local_file(id) { + Ok(store) => store, + Err(err) => { + log::warn!( + "config store for id `{}` could not be loaded from {}: {}; \ + dropping this id from the registry", + id, + AxumConfigStore::local_path(id).display(), + err + ); + continue; + } + }; + by_id.insert((*id).to_owned(), ConfigStoreHandle::new(Arc::new(store))); + } + let default_id = meta.default.to_owned(); + if !by_id.contains_key(&default_id) { + log::warn!( + "config registry default id `{default_id}` failed to load; dropping the config registry — \ + handlers will see no config store" + ); + } + StoreRegistry::from_parts(by_id, default_id) } -fn resolve_addr_from_parts( - manifest: &edgezero_core::manifest::Manifest, - env_host: Option<&str>, - env_port: Option<&str>, -) -> edgezero_core::addr::BindAddrResolution { - let adapter = manifest.adapters.get("axum"); - let config_host = adapter.and_then(|a| a.adapter.host.as_deref()); - let config_port = adapter.and_then(|a| a.adapter.port); - edgezero_core::addr::resolve_bind_addr(env_host, env_port, config_host, config_port) +/// Build the per-request secret registry. Axum is `Single` for secrets — every +/// declared id maps to the same env-backed [`EnvSecretStore`]. Each binding +/// captures the platform store name resolved from +/// `EDGEZERO__STORES__SECRETS____NAME` (defaulting to the logical id); +/// the axum env-secret backend ignores the name on lookup, so the binding +/// is observable only via [`BoundSecretStore::store_name`]. +fn build_secret_registry( + secret_meta: Option, + env: &EnvConfig, +) -> Option { + let meta = secret_meta?; + log::info!("Secret store: reading from environment variables"); + let handle = SecretHandle::new(Arc::new(EnvSecretStore::new())); + let mut by_id: BTreeMap = BTreeMap::new(); + for id in meta.ids { + let store_name = env.store_name("secrets", id); + by_id.insert( + (*id).to_owned(), + BoundSecretStore::new(handle.clone(), store_name), + ); + } + // Secret backends are infallible here, so the default id is always + // present in `by_id`; `from_parts` keeps the API symmetric with the + // KV / config builders without changing observable behaviour. + StoreRegistry::from_parts(by_id, meta.default.to_owned()) +} + +/// Resolve the bind address from `EDGEZERO__ADAPTER__*` environment config. +/// +/// Precedence (highest wins): +/// 1. `EDGEZERO__ADAPTER__HOST` / `EDGEZERO__ADAPTER__PORT` +/// 2. Default: `127.0.0.1:8787` +pub(crate) fn resolve_addr(env: &EnvConfig) -> addr::BindAddrResolution { + addr::resolve_bind_addr(env.adapter_host(), env.adapter_port(), None, None) } #[cfg(test)] @@ -404,7 +514,7 @@ mod tests { #[test] fn default_config_uses_expected_address() { let config = AxumDevServerConfig::default(); - assert_eq!(config.addr.ip(), IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1))); + assert_eq!(config.addr.ip(), IpAddr::V4(Ipv4Addr::LOCALHOST)); assert_eq!(config.addr.port(), 8787); } @@ -429,7 +539,7 @@ mod tests { addr, enable_ctrl_c: false, }; - assert_eq!(config.addr.ip(), IpAddr::V4(Ipv4Addr::new(0, 0, 0, 0))); + assert_eq!(config.addr.ip(), IpAddr::V4(Ipv4Addr::UNSPECIFIED)); assert_eq!(config.addr.port(), 3000); assert!(!config.enable_ctrl_c); } @@ -459,34 +569,51 @@ mod tests { } #[test] - fn default_store_name_uses_legacy_kv_path() { - assert_eq!( - kv_store_path(edgezero_core::manifest::DEFAULT_KV_STORE_NAME), - PathBuf::from(".edgezero/kv.redb") + fn every_store_name_gets_a_slug_based_path() { + // The pre-rewrite shortcut hard-coded `.edgezero/kv.redb` + // when the store name equalled the legacy `EDGEZERO_KV` + // constant. Hard cutoff: now every name -- including any + // historical value an operator might still set -- flows + // through the slug+hash encoder, so no name gets a + // special shortcut path. + let legacy = kv_store_path("EDGEZERO_KV"); + assert_ne!( + legacy, + PathBuf::from(".edgezero/kv.redb"), + "post-cutoff: the legacy default name no longer gets the bare `kv.redb` shortcut: {legacy:?}" + ); + assert!( + legacy.to_string_lossy().starts_with(".edgezero/kv-"), + "legacy name still gets a slug-based path: {legacy:?}" ); + let custom = kv_store_path("sessions"); + assert!( + custom.to_string_lossy().contains("sessions"), + "regular name gets a slug-based filename: {custom:?}" + ); + assert_ne!(legacy, custom); } #[test] fn implicit_default_kv_is_optional() { - let manifest = ManifestLoader::load_from_str(""); assert_eq!( - kv_init_requirement(manifest.manifest()), + kv_init_requirement(StoresMetadata::default()), KvInitRequirement::Optional ); } #[test] fn explicit_kv_config_is_required() { - let manifest = ManifestLoader::load_from_str( - r#" -[stores.kv] -name = "EDGEZERO_KV" -"#, - ); - assert_eq!( - kv_init_requirement(manifest.manifest()), - KvInitRequirement::Required - ); + use edgezero_core::app::StoreMetadata; + + let stores = StoresMetadata { + kv: Some(StoreMetadata { + default: "edgezero_kv", + ids: &["edgezero_kv"], + }), + ..StoresMetadata::default() + }; + assert_eq!(kv_init_requirement(stores), KvInitRequirement::Required); } #[test] @@ -522,74 +649,39 @@ name = "EDGEZERO_KV" } #[test] - fn resolve_addr_defaults_without_manifest_config() { - // Note: env var tests use resolve_addr_from_parts to avoid races. - let loader = ManifestLoader::load_from_str(""); - let resolution = resolve_addr_from_parts(loader.manifest(), None, None); + fn resolve_addr_defaults_without_env_config() { + let empty: [(&str, &str); 0] = []; + let resolution = resolve_addr(&EnvConfig::from_vars(empty)); assert_eq!(resolution.addr, SocketAddr::from(([127, 0, 0, 1], 8787))); assert!(resolution.warnings.is_empty()); } #[test] - fn resolve_addr_reads_manifest_host_and_port() { - let manifest = r#" -[adapters.axum.adapter] -host = "0.0.0.0" -port = 3000 -"#; - let loader = ManifestLoader::load_from_str(manifest); - let resolution = resolve_addr_from_parts(loader.manifest(), None, None); + fn resolve_addr_reads_env_host_and_port() { + let env = EnvConfig::from_vars([ + ("EDGEZERO__ADAPTER__HOST", "0.0.0.0"), + ("EDGEZERO__ADAPTER__PORT", "3000"), + ]); + let resolution = resolve_addr(&env); assert_eq!(resolution.addr, SocketAddr::from(([0, 0, 0, 0], 3000))); assert!(resolution.warnings.is_empty()); } - #[test] - fn resolve_addr_env_overrides_manifest() { - let manifest = r#" -[adapters.axum.adapter] -host = "127.0.0.1" -port = 3000 -"#; - let loader = ManifestLoader::load_from_str(manifest); - let resolution = resolve_addr_from_parts(loader.manifest(), Some("0.0.0.0"), Some("4000")); - assert_eq!(resolution.addr, SocketAddr::from(([0, 0, 0, 0], 4000))); - assert!(resolution.warnings.is_empty()); - } - #[test] fn resolve_addr_partial_env_override() { - let manifest = r#" -[adapters.axum.adapter] -port = 5000 -"#; - let loader = ManifestLoader::load_from_str(manifest); - let resolution = resolve_addr_from_parts(loader.manifest(), Some("0.0.0.0"), None); - assert_eq!(resolution.addr, SocketAddr::from(([0, 0, 0, 0], 5000))); + let env = EnvConfig::from_vars([("EDGEZERO__ADAPTER__HOST", "0.0.0.0")]); + let resolution = resolve_addr(&env); + assert_eq!(resolution.addr, SocketAddr::from(([0, 0, 0, 0], 8787))); assert!(resolution.warnings.is_empty()); } #[test] - fn resolve_addr_invalid_env_falls_back_to_manifest() { - let manifest = r#" -[adapters.axum.adapter] -host = "0.0.0.0" -port = 5000 -"#; - let loader = ManifestLoader::load_from_str(manifest); - let resolution = resolve_addr_from_parts(loader.manifest(), Some("not-an-ip"), Some("abc")); - assert_eq!(resolution.addr, SocketAddr::from(([0, 0, 0, 0], 5000))); - assert_eq!(resolution.warnings.len(), 2); - } - - #[test] - fn resolve_addr_invalid_manifest_falls_back_to_default() { - let manifest = r#" -[adapters.axum.adapter] -host = "localhost" -port = 0 -"#; - let loader = ManifestLoader::load_from_str(manifest); - let resolution = resolve_addr_from_parts(loader.manifest(), None, None); + fn resolve_addr_invalid_env_falls_back_to_default() { + let env = EnvConfig::from_vars([ + ("EDGEZERO__ADAPTER__HOST", "not-an-ip"), + ("EDGEZERO__ADAPTER__PORT", "abc"), + ]); + let resolution = resolve_addr(&env); assert_eq!(resolution.addr, SocketAddr::from(([127, 0, 0, 1], 8787))); assert_eq!(resolution.warnings.len(), 2); } @@ -603,16 +695,24 @@ mod integration_tests { use edgezero_core::error::EdgeError; use edgezero_core::extractor::Secrets; use edgezero_core::router::RouterService; + use edgezero_core::secret_store::SecretHandle as CoreSecretHandle; use std::time::{Duration, Instant}; + use tokio::task::{spawn_blocking, JoinHandle}; + use tokio::time::sleep; struct TestServer { - base_url: String, - handle: tokio::task::JoinHandle<()>, _temp_dir: tempfile::TempDir, + base_url: String, + handle: JoinHandle<()>, + } + + struct TestServerWithStore { + base_url: String, + handle: JoinHandle<()>, } async fn start_test_server(router: RouterService) -> TestServer { - let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + let listener = TokioTcpListener::bind("127.0.0.1:0") .await .expect("bind test server"); let addr = listener.local_addr().expect("local addr"); @@ -627,11 +727,11 @@ mod integration_tests { let server = AxumDevServer::with_config(router, config).with_kv_handle(kv_handle); let handle = tokio::spawn(async move { - let _ = server.run_with_listener(listener).await; + let _result = server.run_with_listener(listener).await; }); TestServer { - base_url: format!("http://{}", addr), + base_url: format!("http://{addr}"), handle, _temp_dir: temp_dir, } @@ -648,13 +748,14 @@ mod integration_tests { match make_request(client).send().await { Ok(response) => return response, Err(err) => { - if start.elapsed() >= timeout { - panic!("server did not respond before timeout: {}", err); - } + assert!( + start.elapsed() < timeout, + "server did not respond before timeout: {err}" + ); } } - tokio::time::sleep(Duration::from_millis(10)).await; + sleep(Duration::from_millis(10)).await; } } @@ -669,7 +770,7 @@ mod integration_tests { let client = reqwest::Client::new(); let url = format!("{}/test", server.base_url); - let response = send_with_retry(&client, |client| client.get(url.as_str())).await; + let response = send_with_retry(&client, |http_client| http_client.get(url.as_str())).await; assert_eq!(response.status(), reqwest::StatusCode::OK); assert_eq!(response.text().await.unwrap(), "hello from dev server"); @@ -684,7 +785,7 @@ mod integration_tests { let client = reqwest::Client::new(); let url = format!("{}/nonexistent", server.base_url); - let response = send_with_retry(&client, |client| client.get(url.as_str())).await; + let response = send_with_retry(&client, |http_client| http_client.get(url.as_str())).await; assert_eq!(response.status(), reqwest::StatusCode::NOT_FOUND); @@ -702,7 +803,7 @@ mod integration_tests { let client = reqwest::Client::new(); let url = format!("{}/submit", server.base_url); - let response = send_with_retry(&client, |client| client.get(url.as_str())).await; + let response = send_with_retry(&client, |http_client| http_client.get(url.as_str())).await; assert_eq!(response.status(), reqwest::StatusCode::METHOD_NOT_ALLOWED); @@ -716,9 +817,9 @@ mod integration_tests { .request() .headers() .get("x-custom") - .and_then(|v| v.to_str().ok()) + .and_then(|val| val.to_str().ok()) .unwrap_or("missing"); - Ok(value.to_string()) + Ok(value.to_owned()) } let router = RouterService::builder().get("/headers", handler).build(); @@ -726,8 +827,8 @@ mod integration_tests { let client = reqwest::Client::new(); let url = format!("{}/headers", server.base_url); - let response = send_with_retry(&client, |client| { - client.get(url.as_str()).header("x-custom", "my-value") + let response = send_with_retry(&client, |http_client| { + http_client.get(url.as_str()).header("x-custom", "my-value") }) .await; @@ -740,7 +841,7 @@ mod integration_tests { #[tokio::test(flavor = "multi_thread")] async fn server_fails_to_bind_to_used_port() { // First bind to a port - let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind first"); + let listener = StdTcpListener::bind("127.0.0.1:0").expect("bind first"); let addr = listener.local_addr().expect("listener addr"); // Try to start server on same port @@ -752,15 +853,14 @@ mod integration_tests { let server = AxumDevServer::with_config(router, config); // Run in blocking mode to capture the error - let result = tokio::task::spawn_blocking(move || server.run()).await; + let result = spawn_blocking(move || server.run()).await; match result { - Ok(Err(e)) => { - let err_str = e.to_string(); + Ok(Err(err)) => { + let err_str = err.to_string(); assert!( err_str.contains("bind") || err_str.contains("address"), - "expected bind error, got: {}", - err_str + "expected bind error, got: {err_str}" ); } _ => panic!("expected bind error"), @@ -772,14 +872,14 @@ mod integration_tests { #[tokio::test(flavor = "multi_thread")] async fn kv_store_persists_across_requests() { async fn write_handler(ctx: RequestContext) -> Result<&'static str, EdgeError> { - let store = ctx.kv_handle().expect("kv configured"); - store.put("counter", &42i32).await?; + let store = ctx.kv_store_default().expect("kv configured"); + store.put("counter", &42_i32).await?; Ok("written") } async fn read_handler(ctx: RequestContext) -> Result { - let store = ctx.kv_handle().expect("kv configured"); - let val: i32 = store.get_or("counter", 0).await?; + let store = ctx.kv_store_default().expect("kv configured"); + let val: i32 = store.get_or("counter", 0_i32).await?; Ok(val.to_string()) } @@ -793,15 +893,17 @@ mod integration_tests { // Write a value let write_url = format!("{}/write", server.base_url); - let response = send_with_retry(&client, |client| client.post(write_url.as_str())).await; - assert_eq!(response.status(), reqwest::StatusCode::OK); - assert_eq!(response.text().await.unwrap(), "written"); + let write_response = + send_with_retry(&client, |http_client| http_client.post(write_url.as_str())).await; + assert_eq!(write_response.status(), reqwest::StatusCode::OK); + assert_eq!(write_response.text().await.unwrap(), "written"); // Read it back — proves shared state across requests let read_url = format!("{}/read", server.base_url); - let response = send_with_retry(&client, |client| client.get(read_url.as_str())).await; - assert_eq!(response.status(), reqwest::StatusCode::OK); - assert_eq!(response.text().await.unwrap(), "42"); + let read_response = + send_with_retry(&client, |http_client| http_client.get(read_url.as_str())).await; + assert_eq!(read_response.status(), reqwest::StatusCode::OK); + assert_eq!(read_response.text().await.unwrap(), "42"); server.handle.abort(); } @@ -809,19 +911,19 @@ mod integration_tests { #[tokio::test(flavor = "multi_thread")] async fn kv_store_delete_across_requests() { async fn write_handler(ctx: RequestContext) -> Result<&'static str, EdgeError> { - let kv = ctx.kv_handle().expect("kv configured"); + let kv = ctx.kv_store_default().expect("kv configured"); kv.put("temp", &"to_delete").await?; Ok("written") } async fn delete_handler(ctx: RequestContext) -> Result<&'static str, EdgeError> { - let kv = ctx.kv_handle().expect("kv configured"); + let kv = ctx.kv_store_default().expect("kv configured"); kv.delete("temp").await?; Ok("deleted") } async fn check_handler(ctx: RequestContext) -> Result { - let kv = ctx.kv_handle().expect("kv configured"); + let kv = ctx.kv_store_default().expect("kv configured"); let exists = kv.exists("temp").await?; Ok(format!("exists={exists}")) } @@ -835,22 +937,23 @@ mod integration_tests { let client = reqwest::Client::new(); // Write - let url = format!("{}/write", server.base_url); - send_with_retry(&client, |c| c.post(url.as_str())).await; + let write_url = format!("{}/write", server.base_url); + send_with_retry(&client, |http_client| http_client.post(write_url.as_str())).await; // Verify exists - let url = format!("{}/check", server.base_url); - let resp = send_with_retry(&client, |c| c.get(url.as_str())).await; - assert_eq!(resp.text().await.unwrap(), "exists=true"); + let check_url = format!("{}/check", server.base_url); + let exists_before = + send_with_retry(&client, |http_client| http_client.get(check_url.as_str())).await; + assert_eq!(exists_before.text().await.unwrap(), "exists=true"); // Delete - let url = format!("{}/delete", server.base_url); - send_with_retry(&client, |c| c.post(url.as_str())).await; + let delete_url = format!("{}/delete", server.base_url); + send_with_retry(&client, |http_client| http_client.post(delete_url.as_str())).await; // Verify gone - let url = format!("{}/check", server.base_url); - let resp = send_with_retry(&client, |c| c.get(url.as_str())).await; - assert_eq!(resp.text().await.unwrap(), "exists=false"); + let exists_after = + send_with_retry(&client, |http_client| http_client.get(check_url.as_str())).await; + assert_eq!(exists_after.text().await.unwrap(), "exists=false"); server.handle.abort(); } @@ -858,8 +961,10 @@ mod integration_tests { #[tokio::test(flavor = "multi_thread")] async fn kv_store_update_across_requests() { async fn increment_handler(ctx: RequestContext) -> Result { - let kv = ctx.kv_handle().expect("kv configured"); - let val = kv.read_modify_write("counter", 0i32, |n| n + 1).await?; + let kv = ctx.kv_store_default().expect("kv configured"); + let val = kv + .read_modify_write("counter", 0_i32, |n| n + 1_i32) + .await?; Ok(val.to_string()) } @@ -871,8 +976,8 @@ mod integration_tests { let url = format!("{}/inc", server.base_url); // Increment 5 times, each should return incremented value - for expected in 1..=5i32 { - let resp = send_with_retry(&client, |c| c.post(url.as_str())).await; + for expected in 1_i32..=5_i32 { + let resp = send_with_retry(&client, |http_client| http_client.post(url.as_str())).await; assert_eq!( resp.text().await.unwrap(), expected.to_string(), @@ -886,8 +991,8 @@ mod integration_tests { #[tokio::test(flavor = "multi_thread")] async fn kv_store_returns_not_found_gracefully() { async fn read_handler(ctx: RequestContext) -> Result { - let kv = ctx.kv_handle().expect("kv configured"); - let val: i32 = kv.get_or("nonexistent", -1).await?; + let kv = ctx.kv_store_default().expect("kv configured"); + let val: i32 = kv.get_or("nonexistent", -1_i32).await?; Ok(val.to_string()) } @@ -896,7 +1001,7 @@ mod integration_tests { let client = reqwest::Client::new(); let url = format!("{}/read", server.base_url); - let resp = send_with_retry(&client, |c| c.get(url.as_str())).await; + let resp = send_with_retry(&client, |http_client| http_client.get(url.as_str())).await; assert_eq!(resp.status(), reqwest::StatusCode::OK); assert_eq!(resp.text().await.unwrap(), "-1"); @@ -909,15 +1014,15 @@ mod integration_tests { #[derive(Serialize, Deserialize, PartialEq, Debug)] struct UserProfile { - name: String, - age: u32, active: bool, + age: u32, + name: String, } async fn write_handler(ctx: RequestContext) -> Result<&'static str, EdgeError> { - let kv = ctx.kv_handle().expect("kv configured"); + let kv = ctx.kv_store_default().expect("kv configured"); let profile = UserProfile { - name: "Alice".to_string(), + name: "Alice".to_owned(), age: 30, active: true, }; @@ -926,11 +1031,11 @@ mod integration_tests { } async fn read_handler(ctx: RequestContext) -> Result { - let kv = ctx.kv_handle().expect("kv configured"); + let kv = ctx.kv_store_default().expect("kv configured"); let profile: Option = kv.get("user:alice").await?; match profile { - Some(p) => Ok(format!("{}:{}", p.name, p.age)), - None => Ok("not found".to_string()), + Some(found) => Ok(format!("{}:{}", found.name, found.age)), + None => Ok("not found".to_owned()), } } @@ -942,14 +1047,16 @@ mod integration_tests { let client = reqwest::Client::new(); // Save profile - let url = format!("{}/save", server.base_url); - let resp = send_with_retry(&client, |c| c.post(url.as_str())).await; - assert_eq!(resp.text().await.unwrap(), "saved"); + let save_url = format!("{}/save", server.base_url); + let save_resp = + send_with_retry(&client, |http_client| http_client.post(save_url.as_str())).await; + assert_eq!(save_resp.text().await.unwrap(), "saved"); // Load profile - let url = format!("{}/load", server.base_url); - let resp = send_with_retry(&client, |c| c.get(url.as_str())).await; - assert_eq!(resp.text().await.unwrap(), "Alice:30"); + let load_url = format!("{}/load", server.base_url); + let load_resp = + send_with_retry(&client, |http_client| http_client.get(load_url.as_str())).await; + assert_eq!(load_resp.text().await.unwrap(), "Alice:30"); server.handle.abort(); } @@ -958,16 +1065,11 @@ mod integration_tests { // Secret store helpers // ----------------------------------------------------------------------- - struct TestServerSecrets { - base_url: String, - handle: tokio::task::JoinHandle<()>, - } - - async fn start_test_server_with_secret_handle( + async fn start_test_server_with_store_handle( router: RouterService, - secret_handle: Option, - ) -> TestServerSecrets { - let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + secret_handle: Option, + ) -> TestServerWithStore { + let listener = TokioTcpListener::bind("127.0.0.1:0") .await .expect("bind secrets test server"); let addr = listener.local_addr().expect("local addr"); @@ -976,24 +1078,24 @@ mod integration_tests { enable_ctrl_c: false, }; let mut server = super::AxumDevServer::with_config(router, config); - if let Some(h) = secret_handle { - server = server.with_secret_handle(h); + if let Some(handle) = secret_handle { + server = server.with_secret_handle(handle); } let handle = tokio::spawn(async move { - let _ = server.run_with_listener(listener).await; + let _result = server.run_with_listener(listener).await; }); - TestServerSecrets { - base_url: format!("http://{}", addr), + TestServerWithStore { + base_url: format!("http://{addr}"), handle, } } #[action] - async fn secret_value_handler(Secrets(store): Secrets) -> Result { - store - .require_str("test-store", "API_KEY") - .await - .map_err(EdgeError::from) + async fn secret_value_handler(secrets: Secrets) -> Result { + let store = secrets + .default() + .ok_or_else(|| EdgeError::service_unavailable("no default secret store registered"))?; + store.require_str("API_KEY").await.map_err(EdgeError::from) } // ----------------------------------------------------------------------- @@ -1008,14 +1110,16 @@ mod integration_tests { let router = RouterService::builder() .get("/secret", secret_value_handler) .build(); - let store = - InMemorySecretStore::new([("test-store/API_KEY", bytes::Bytes::from("s3cr3t"))]); + // The legacy single-handle wiring binds under `"default"` (see + // `Secrets::from_request` fallback), so the in-memory store is + // keyed under that prefix. + let store = InMemorySecretStore::new([("default/API_KEY", bytes::Bytes::from("s3cr3t"))]); let handle = SecretHandle::new(Arc::new(store)); - let server = start_test_server_with_secret_handle(router, Some(handle)).await; + let server = start_test_server_with_store_handle(router, Some(handle)).await; let client = reqwest::Client::new(); let url = format!("{}/secret", server.base_url); - let response = send_with_retry(&client, |c| c.get(url.as_str())).await; + let response = send_with_retry(&client, |http_client| http_client.get(url.as_str())).await; assert_eq!(response.status(), reqwest::StatusCode::OK); assert_eq!(response.text().await.unwrap(), "s3cr3t"); @@ -1031,13 +1135,13 @@ mod integration_tests { let router = RouterService::builder() .get("/secret", secret_value_handler) .build(); - let store = InMemorySecretStore::new(std::iter::empty::<(&str, bytes::Bytes)>()); + let store = InMemorySecretStore::new(iter::empty::<(&str, bytes::Bytes)>()); let handle = SecretHandle::new(Arc::new(store)); - let server = start_test_server_with_secret_handle(router, Some(handle)).await; + let server = start_test_server_with_store_handle(router, Some(handle)).await; let client = reqwest::Client::new(); let url = format!("{}/secret", server.base_url); - let response = send_with_retry(&client, |c| c.get(url.as_str())).await; + let response = send_with_retry(&client, |http_client| http_client.get(url.as_str())).await; assert_eq!( response.status(), @@ -1055,11 +1159,11 @@ mod integration_tests { let router = RouterService::builder() .get("/secret", secret_value_handler) .build(); - let server = start_test_server_with_secret_handle(router, None).await; + let server = start_test_server_with_store_handle(router, None).await; let client = reqwest::Client::new(); let url = format!("{}/secret", server.base_url); - let response = send_with_retry(&client, |c| c.get(url.as_str())).await; + let response = send_with_retry(&client, |http_client| http_client.get(url.as_str())).await; assert_eq!( response.status(), diff --git a/crates/edgezero-adapter-axum/src/key_value_store.rs b/crates/edgezero-adapter-axum/src/key_value_store.rs index 190bf6ac..f8cfaa2a 100644 --- a/crates/edgezero-adapter-axum/src/key_value_store.rs +++ b/crates/edgezero-adapter-axum/src/key_value_store.rs @@ -50,11 +50,11 @@ use std::time::Duration; use async_trait::async_trait; use bytes::Bytes; use edgezero_core::key_value_store::{KvError, KvPage, KvStore}; -use redb::{Database, ReadableDatabase, ReadableTable, TableDefinition}; +use redb::{Database, ReadableDatabase as _, ReadableTable as _, TableDefinition}; use std::time::SystemTime; /// Table definition for the KV store. -/// Key: String, Value: (Bytes, Option) +/// Key: `String`, Value: `(Bytes, Option)`. const KV_TABLE: TableDefinition<&str, (&[u8], Option)> = TableDefinition::new("kv"); /// Type alias for a writable KV table handle. @@ -69,7 +69,10 @@ pub struct PersistentKvStore { } impl PersistentKvStore { - const LIST_SCAN_BATCH_SIZE: usize = 256; + /// Entries scanned per read transaction. Lowered under `cfg(test)` so the + /// scan-cap path is reachable with a small fixture; pagination correctness + /// does not depend on the batch size. + const LIST_SCAN_BATCH_SIZE: usize = if cfg!(test) { 16 } else { 256 }; /// Maximum number of scan batches before returning a partial page. /// /// Each batch scans up to `LIST_SCAN_BATCH_SIZE` entries, so this caps @@ -78,38 +81,50 @@ impl PersistentKvStore { /// accumulated large numbers of expired entries (common in long-running /// dev sessions) can produce unbounded scan latency. /// - /// When the limit is hit the partial page is returned with the last - /// live cursor, so callers can resume pagination normally on the next - /// call. A warning is logged once so operators know cleanup is needed. - const MAX_SCAN_BATCHES: usize = 100; - - /// Create a new persistent KV store at the given path. - /// - /// # Behavior + /// When the limit is hit the partial page is returned with a cursor + /// positioned at the last scanned key, so callers can resume pagination + /// instead of stopping. A warning is logged so operators know cleanup + /// is needed. /// - /// - If the file does not exist, a new database will be initialized - /// - If the file exists and is a valid redb database, it will be opened with existing data preserved - /// - If the file exists but is not a valid redb database, returns an error - pub fn new>(path: P) -> Result { - let db_path = path.as_ref().to_path_buf(); - let db = Database::create(path).map_err(|e| { - KvError::Internal(anyhow::anyhow!( - "Failed to open KV database at {:?}. If the file is corrupted or locked \ - by another process, try deleting it and restarting: {}", - db_path, - e - )) - })?; + /// Lowered under `cfg(test)` so the scan-cap path is reachable without + /// inserting tens of thousands of entries. + const MAX_SCAN_BATCHES: usize = if cfg!(test) { 2 } else { 100 }; - // Initialize the table - let store = Self { db }; - let write_txn = store.begin_write()?; + fn begin_write(&self) -> Result { + self.db + .begin_write() + .map_err(|err| KvError::Internal(anyhow::anyhow!("failed to begin write txn: {err}"))) + } + + fn cleanup_expired_keys(&self, expired_keys: &[String]) -> Result<(), KvError> { + if expired_keys.is_empty() { + return Ok(()); + } + + let write_txn = self.begin_write()?; { - let _table = Self::open_table(&write_txn)?; + let mut table = Self::open_table(&write_txn)?; + for key in expired_keys { + let still_expired = table + .get(key.as_str()) + .map_err(|err| KvError::Internal(anyhow::anyhow!("failed to get key: {err}")))? + .is_some_and(|entry| { + let (_, expires_at) = entry.value(); + Self::is_expired(expires_at) + }); + if still_expired { + table.remove(key.as_str()).map_err(|err| { + KvError::Internal(anyhow::anyhow!("failed to remove: {err}")) + })?; + } + } } - Self::commit(write_txn)?; + Self::commit(write_txn) + } - Ok(store) + fn commit(txn: redb::WriteTransaction) -> Result<(), KvError> { + txn.commit() + .map_err(|err| KvError::Internal(anyhow::anyhow!("failed to commit: {err}"))) } /// Check if an entry is expired based on its expiration timestamp. @@ -131,75 +146,83 @@ impl PersistentKvStore { } } - /// Convert SystemTime to milliseconds since UNIX epoch. + /// Create a new persistent KV store at the given path. /// - /// Returns 0 if the time is before UNIX epoch (should never happen in practice). - fn system_time_to_millis(time: SystemTime) -> u128 { - time.duration_since(SystemTime::UNIX_EPOCH) - .map(|d| d.as_millis()) - .unwrap_or(0) - } + /// # Behavior + /// + /// - If the file does not exist, a new database will be initialized + /// - If the file exists and is a valid redb database, it will be opened with existing data preserved + /// - If the file exists but is not a valid redb database, returns an error + /// + /// # Errors + /// Returns an error if the database file cannot be opened or initialised (corrupted file, locked by another process, or insufficient permissions). + #[inline] + pub fn new>(path: P) -> Result { + let db_path = path.as_ref().display().to_string(); + let db = Database::create(path).map_err(|err| { + KvError::Internal(anyhow::anyhow!( + "Failed to open KV database at {db_path}. If the file is corrupted or locked \ + by another process, try deleting it and restarting: {err}" + )) + })?; - // -- Transaction helpers ------------------------------------------------ + // Initialize the table + let store = Self { db }; + let write_txn = store.begin_write()?; + { + let _table = Self::open_table(&write_txn)?; + } + Self::commit(write_txn)?; - fn begin_write(&self) -> Result { - self.db - .begin_write() - .map_err(|e| KvError::Internal(anyhow::anyhow!("failed to begin write txn: {}", e))) + Ok(store) } - fn open_table<'txn>(txn: &'txn redb::WriteTransaction) -> Result, KvError> { + fn open_table(txn: &redb::WriteTransaction) -> Result, KvError> { txn.open_table(KV_TABLE) - .map_err(|e| KvError::Internal(anyhow::anyhow!("failed to open table: {}", e))) + .map_err(|err| KvError::Internal(anyhow::anyhow!("failed to open table: {err}"))) } - fn commit(txn: redb::WriteTransaction) -> Result<(), KvError> { - txn.commit() - .map_err(|e| KvError::Internal(anyhow::anyhow!("failed to commit: {}", e))) + /// Convert `SystemTime` to milliseconds since UNIX epoch. + /// + /// Returns 0 if the time is before UNIX epoch (should never happen in practice). + fn system_time_to_millis(time: SystemTime) -> u128 { + time.duration_since(SystemTime::UNIX_EPOCH) + .map_or(0, |duration| duration.as_millis()) } +} - fn cleanup_expired_keys(&self, expired_keys: &[String]) -> Result<(), KvError> { - if expired_keys.is_empty() { - return Ok(()); - } - +#[async_trait(?Send)] +impl KvStore for PersistentKvStore { + #[inline] + async fn delete(&self, key: &str) -> Result<(), KvError> { let write_txn = self.begin_write()?; - { - let mut table = Self::open_table(&write_txn)?; - for key in expired_keys { - let still_expired = table - .get(key.as_str()) - .map_err(|e| KvError::Internal(anyhow::anyhow!("failed to get key: {}", e)))? - .is_some_and(|entry| { - let (_, expires_at) = entry.value(); - Self::is_expired(expires_at) - }); - if still_expired { - table.remove(key.as_str()).map_err(|e| { - KvError::Internal(anyhow::anyhow!("failed to remove: {}", e)) - })?; - } - } - } + let mut table = Self::open_table(&write_txn)?; + table + .remove(key) + .map_err(|err| KvError::Internal(anyhow::anyhow!("failed to remove: {err}")))?; + drop(table); Self::commit(write_txn) } -} -#[async_trait(?Send)] -impl KvStore for PersistentKvStore { + #[inline] + async fn exists(&self, key: &str) -> Result { + Ok(self.get_bytes(key).await?.is_some()) + } + + #[inline] async fn get_bytes(&self, key: &str) -> Result, KvError> { let read_txn = self .db .begin_read() - .map_err(|e| KvError::Internal(anyhow::anyhow!("failed to begin read txn: {}", e)))?; + .map_err(|err| KvError::Internal(anyhow::anyhow!("failed to begin read txn: {err}")))?; let table = read_txn .open_table(KV_TABLE) - .map_err(|e| KvError::Internal(anyhow::anyhow!("failed to open table: {}", e)))?; + .map_err(|err| KvError::Internal(anyhow::anyhow!("failed to open table: {err}")))?; if let Some(entry) = table .get(key) - .map_err(|e| KvError::Internal(anyhow::anyhow!("failed to get key: {}", e)))? + .map_err(|err| KvError::Internal(anyhow::anyhow!("failed to get key: {err}")))? { let (value_bytes, expires_at) = entry.value(); @@ -212,22 +235,22 @@ impl KvStore for PersistentKvStore { // Delete the expired key let write_txn = self.begin_write()?; { - let mut table = Self::open_table(&write_txn)?; + let mut write_table = Self::open_table(&write_txn)?; // Re-check expiry inside write txn to avoid TOCTOU race: // a concurrent put_bytes may have overwritten the key with // a fresh value between our read and this write. - let still_expired = table + let still_expired = write_table .get(key) - .map_err(|e| { - KvError::Internal(anyhow::anyhow!("failed to get key: {}", e)) + .map_err(|err| { + KvError::Internal(anyhow::anyhow!("failed to get key: {err}")) })? - .is_some_and(|entry| { - let (_, exp) = entry.value(); + .is_some_and(|fresh_entry| { + let (_, exp) = fresh_entry.value(); Self::is_expired(exp) }); if still_expired { - table.remove(key).map_err(|e| { - KvError::Internal(anyhow::anyhow!("failed to remove: {}", e)) + write_table.remove(key).map_err(|err| { + KvError::Internal(anyhow::anyhow!("failed to remove: {err}")) })?; } } @@ -242,47 +265,7 @@ impl KvStore for PersistentKvStore { } } - async fn put_bytes(&self, key: &str, value: Bytes) -> Result<(), KvError> { - let write_txn = self.begin_write()?; - { - let mut table = Self::open_table(&write_txn)?; - table - .insert(key, (value.as_ref(), None)) - .map_err(|e| KvError::Internal(anyhow::anyhow!("failed to insert: {}", e)))?; - } - Self::commit(write_txn) - } - - async fn put_bytes_with_ttl( - &self, - key: &str, - value: Bytes, - ttl: Duration, - ) -> Result<(), KvError> { - let expires_at = SystemTime::now() + ttl; - let expires_at_millis = Self::system_time_to_millis(expires_at); - - let write_txn = self.begin_write()?; - { - let mut table = Self::open_table(&write_txn)?; - table - .insert(key, (value.as_ref(), Some(expires_at_millis))) - .map_err(|e| KvError::Internal(anyhow::anyhow!("failed to insert: {}", e)))?; - } - Self::commit(write_txn) - } - - async fn delete(&self, key: &str) -> Result<(), KvError> { - let write_txn = self.begin_write()?; - { - let mut table = Self::open_table(&write_txn)?; - table - .remove(key) - .map_err(|e| KvError::Internal(anyhow::anyhow!("failed to remove: {}", e)))?; - } - Self::commit(write_txn) - } - + #[inline] async fn list_keys_page( &self, prefix: &str, @@ -290,49 +273,53 @@ impl KvStore for PersistentKvStore { limit: usize, ) -> Result { let mut live_keys = Vec::with_capacity(limit.saturating_add(1)); - let mut scan_cursor = cursor.map(str::to_string); + let mut scan_cursor = cursor.map(str::to_owned); let mut reached_end = false; + let mut hit_scan_cap = false; let mut batch_count: usize = 0; - while live_keys.len() < limit + 1 && !reached_end { + while live_keys.len() < limit.saturating_add(1) && !reached_end { if batch_count >= Self::MAX_SCAN_BATCHES { log::warn!( "list_keys_page: scanned {} batches ({} entries) without filling the \ requested page; the database likely contains a large number of expired \ entries. Returning partial page. Run a KV cleanup to improve performance.", Self::MAX_SCAN_BATCHES, - Self::MAX_SCAN_BATCHES * Self::LIST_SCAN_BATCH_SIZE, + Self::MAX_SCAN_BATCHES.saturating_mul(Self::LIST_SCAN_BATCH_SIZE), ); + hit_scan_cap = true; break; } - batch_count += 1; + batch_count = batch_count.saturating_add(1); let mut expired_keys = Vec::new(); { - let read_txn = self.db.begin_read().map_err(|e| { - KvError::Internal(anyhow::anyhow!("failed to begin read txn: {}", e)) + let read_txn = self.db.begin_read().map_err(|err| { + KvError::Internal(anyhow::anyhow!("failed to begin read txn: {err}")) })?; - let table = read_txn.open_table(KV_TABLE).map_err(|e| { - KvError::Internal(anyhow::anyhow!("failed to open table: {}", e)) + let table = read_txn.open_table(KV_TABLE).map_err(|err| { + KvError::Internal(anyhow::anyhow!("failed to open table: {err}")) })?; let mut iter = if prefix.is_empty() { match scan_cursor.as_deref() { - Some(cursor) => { - table.range::<&str>((Bound::Excluded(cursor), Bound::Unbounded)) + Some(scan_from) => { + table.range::<&str>((Bound::Excluded(scan_from), Bound::Unbounded)) } None => table.iter(), } } else { match scan_cursor.as_deref() { - Some(cursor) if cursor >= prefix => { - table.range::<&str>((Bound::Excluded(cursor), Bound::Unbounded)) + Some(scan_from) if scan_from >= prefix => { + table.range::<&str>((Bound::Excluded(scan_from), Bound::Unbounded)) } _ => table.range(prefix..), } } - .map_err(|e| KvError::Internal(anyhow::anyhow!("failed to create range: {}", e)))?; + .map_err(|err| { + KvError::Internal(anyhow::anyhow!("failed to create range: {err}")) + })?; for _ in 0..Self::LIST_SCAN_BATCH_SIZE { let Some(entry) = iter.next() else { @@ -340,10 +327,10 @@ impl KvStore for PersistentKvStore { break; }; - let (key, value) = entry.map_err(|e| { - KvError::Internal(anyhow::anyhow!("failed to read range entry: {}", e)) + let (key_handle, value) = entry.map_err(|err| { + KvError::Internal(anyhow::anyhow!("failed to read range entry: {err}")) })?; - let key = key.value().to_string(); + let key = key_handle.value().to_owned(); if !prefix.is_empty() && !key.starts_with(prefix) { reached_end = true; @@ -359,7 +346,7 @@ impl KvStore for PersistentKvStore { } live_keys.push(key); - if live_keys.len() == limit + 1 { + if live_keys.len() == limit.saturating_add(1) { break; } } @@ -373,18 +360,84 @@ impl KvStore for PersistentKvStore { live_keys.truncate(limit); } + // Cursor resolution: + // - `has_more`: a full page plus one — resume from the last returned key. + // - `hit_scan_cap`: the page is under-filled because the scan cap was + // reached while skipping a long run of expired keys. There are still + // unscanned keys past `scan_cursor`; emit it so the caller resumes + // instead of stopping on a spurious `cursor: None`. + // - otherwise: the table (or prefix range) is genuinely exhausted. + let next_cursor = if has_more { + live_keys.last().cloned() + } else if hit_scan_cap { + scan_cursor + } else { + None + }; + Ok(KvPage { - cursor: has_more.then(|| live_keys.last().cloned()).flatten(), + cursor: next_cursor, keys: live_keys, }) } + + #[inline] + async fn put_bytes(&self, key: &str, value: Bytes) -> Result<(), KvError> { + let write_txn = self.begin_write()?; + let mut table = Self::open_table(&write_txn)?; + table + .insert(key, (value.as_ref(), None)) + .map_err(|err| KvError::Internal(anyhow::anyhow!("failed to insert: {err}")))?; + drop(table); + Self::commit(write_txn) + } + + #[inline] + async fn put_bytes_with_ttl( + &self, + key: &str, + value: Bytes, + ttl: Duration, + ) -> Result<(), KvError> { + let expires_at = SystemTime::now() + .checked_add(ttl) + .ok_or_else(|| KvError::Internal(anyhow::anyhow!("ttl overflows system time")))?; + let expires_at_millis = Self::system_time_to_millis(expires_at); + + let write_txn = self.begin_write()?; + let mut table = Self::open_table(&write_txn)?; + table + .insert(key, (value.as_ref(), Some(expires_at_millis))) + .map_err(|err| KvError::Internal(anyhow::anyhow!("failed to insert: {err}")))?; + drop(table); + Self::commit(write_txn) + } } #[cfg(test)] mod tests { + // Run the shared contract tests against PersistentKvStore. + // `Box::leak` intentionally extends the TempDir's lifetime to 'static so + // it remains alive for the duration of the test process. The directory is + // deleted when the process exits, unlike `.keep()` which leaves it behind + // permanently. + edgezero_core::key_value_store_contract_tests!(persistent_kv_contract, { + let dir = Box::leak(Box::new(tempfile::tempdir().unwrap())); + let db_path = dir.path().join("contract.redb"); + PersistentKvStore::new(db_path).unwrap() + }); + use super::*; use edgezero_core::key_value_store::KvHandle; + use futures::executor; use std::sync::Arc; + use std::thread; + + #[derive(serde::Serialize, serde::Deserialize, PartialEq, Debug)] + struct Config { + enabled: bool, + name: String, + } fn store() -> (KvHandle, tempfile::TempDir) { let temp_dir = tempfile::tempdir().unwrap(); @@ -393,216 +446,294 @@ mod tests { (KvHandle::new(Arc::new(store)), temp_dir) } - // -- Raw bytes ----------------------------------------------------------- - #[tokio::test] - async fn put_and_get_bytes() { - let (s, _dir) = store(); - s.put_bytes("k", Bytes::from("hello")).await.unwrap(); - assert_eq!(s.get_bytes("k").await.unwrap(), Some(Bytes::from("hello"))); + async fn put_bytes_with_ttl_propagates_overflow_as_internal_error() { + let temp_dir = tempfile::tempdir().unwrap(); + let db_path = temp_dir.path().join("ttl-overflow.redb"); + let store = PersistentKvStore::new(db_path).unwrap(); + + let err = store + .put_bytes_with_ttl("key", Bytes::from("value"), Duration::MAX) + .await + .expect_err("Duration::MAX must overflow SystemTime"); + assert!(matches!(err, KvError::Internal(_))); + assert!( + err.to_string().contains("ttl overflows system time"), + "expected ttl-overflow error message, got: {err}" + ); } #[tokio::test] - async fn get_missing_key_returns_none() { - let (s, _dir) = store(); - assert_eq!(s.get_bytes("missing").await.unwrap(), None); + async fn cleanup_expired_keys_does_not_delete_fresh_overwrite() { + let temp_dir = tempfile::tempdir().unwrap(); + let db_path = temp_dir.path().join("test.redb"); + let kv_store = PersistentKvStore::new(db_path).unwrap(); + + kv_store + .put_bytes_with_ttl("race/key", Bytes::from("stale"), Duration::from_millis(1)) + .await + .unwrap(); + thread::sleep(Duration::from_millis(200)); + kv_store + .put_bytes("race/key", Bytes::from("fresh")) + .await + .unwrap(); + + kv_store + .cleanup_expired_keys(&["race/key".to_owned()]) + .unwrap(); + + assert_eq!( + kv_store.get_bytes("race/key").await.unwrap(), + Some(Bytes::from("fresh")) + ); } - #[tokio::test] - async fn put_overwrites_existing() { - let (s, _dir) = store(); - s.put_bytes("k", Bytes::from("first")).await.unwrap(); - s.put_bytes("k", Bytes::from("second")).await.unwrap(); - assert_eq!(s.get_bytes("k").await.unwrap(), Some(Bytes::from("second"))); + #[test] + fn concurrent_writes_dont_panic() { + let temp_dir = tempfile::tempdir().unwrap(); + let db_path = temp_dir.path().join("test.redb"); + let kv_store = PersistentKvStore::new(db_path).unwrap(); + let handle = KvHandle::new(Arc::new(kv_store)); + + // KvHandle futures are !Send (async_trait(?Send) for WASM compat), so + // tokio::spawn is off-limits. Use OS threads instead — KvHandle is + // Send + Sync, so each thread moves its own clone and runs its own + // executor. This is genuinely concurrent at the OS level. + let threads: Vec<_> = (0_i32..100_i32) + .map(|idx| { + let kv_handle = handle.clone(); + thread::spawn(move || { + executor::block_on(async move { + let key = format!("key:{idx}"); + kv_handle.put(&key, &idx).await.unwrap(); + }); + }) + }) + .collect(); + + for thread in threads { + thread.join().expect("writer thread panicked"); + } + + // Verify all 100 keys survived concurrent writes with correct values. + executor::block_on(async { + for idx in 0_i32..100_i32 { + let key = format!("key:{idx}"); + let val: i32 = handle.get_or(&key, -1_i32).await.unwrap(); + assert_eq!( + val, idx, + "key:{idx} has wrong value after concurrent writes" + ); + } + }); } #[tokio::test] - async fn delete_removes_key() { - let (s, _dir) = store(); - s.put_bytes("k", Bytes::from("v")).await.unwrap(); - s.delete("k").await.unwrap(); - assert_eq!(s.get_bytes("k").await.unwrap(), None); + async fn data_persists_across_reopens() { + let temp_dir = tempfile::tempdir().unwrap(); + let db_path = temp_dir.path().join("test.redb"); + + // Write data + let store = PersistentKvStore::new(&db_path).unwrap(); + store + .put_bytes("persistent", Bytes::from("value")) + .await + .unwrap(); + drop(store); + + // Reopen and verify data persists + { + let reopened = PersistentKvStore::new(&db_path).unwrap(); + let value = reopened.get_bytes("persistent").await.unwrap(); + assert_eq!(value, Some(Bytes::from("value"))); + } } #[tokio::test] async fn delete_nonexistent_is_ok() { - let (s, _dir) = store(); - s.delete("nope").await.unwrap(); + let (kv_store, _dir) = store(); + kv_store.delete("nope").await.unwrap(); } #[tokio::test] - async fn ttl_expires_entry() { - // Use the store impl directly to bypass validation limits (min TTL 60s) - let temp_dir = tempfile::tempdir().unwrap(); - let db_path = temp_dir.path().join("test.redb"); - let s = PersistentKvStore::new(db_path).unwrap(); - s.put_bytes_with_ttl("temp", Bytes::from("val"), Duration::from_millis(1)) - .await - .unwrap(); - // 200ms gives the OS scheduler enough headroom on busy CI runners. - std::thread::sleep(Duration::from_millis(200)); - assert_eq!(s.get_bytes("temp").await.unwrap(), None); + async fn delete_removes_key() { + let (kv_store, _dir) = store(); + kv_store.put_bytes("k", Bytes::from("v")).await.unwrap(); + kv_store.delete("k").await.unwrap(); + assert_eq!(kv_store.get_bytes("k").await.unwrap(), None); } #[tokio::test] - async fn ttl_not_expired_returns_value() { - let (s, _dir) = store(); - s.put_bytes_with_ttl("temp", Bytes::from("val"), Duration::from_secs(60)) - .await - .unwrap(); - assert_eq!(s.get_bytes("temp").await.unwrap(), Some(Bytes::from("val"))); + async fn exists_helper() { + let (kv_store, _dir) = store(); + assert!(!kv_store.exists("nope").await.unwrap()); + kv_store.put_bytes("k", Bytes::from("v")).await.unwrap(); + assert!(kv_store.exists("k").await.unwrap()); + } + + #[tokio::test] + async fn get_missing_key_returns_none() { + let (kv_store, _dir) = store(); + assert_eq!(kv_store.get_bytes("missing").await.unwrap(), None); } #[tokio::test] async fn list_keys_page_skips_expired_entries() { let temp_dir = tempfile::tempdir().unwrap(); let db_path = temp_dir.path().join("test.redb"); - let s = PersistentKvStore::new(db_path).unwrap(); + let kv_store = PersistentKvStore::new(db_path).unwrap(); - s.put_bytes("app/live", Bytes::from("value")).await.unwrap(); - s.put_bytes_with_ttl("app/expired", Bytes::from("gone"), Duration::from_millis(1)) + kv_store + .put_bytes("app/live", Bytes::from("value")) + .await + .unwrap(); + kv_store + .put_bytes_with_ttl("app/expired", Bytes::from("gone"), Duration::from_millis(1)) .await .unwrap(); - std::thread::sleep(Duration::from_millis(200)); + thread::sleep(Duration::from_millis(200)); - let page = s.list_keys_page("app/", None, 10).await.unwrap(); - assert_eq!(page.keys, vec!["app/live".to_string()]); + let page = kv_store.list_keys_page("app/", None, 10).await.unwrap(); + assert_eq!(page.keys, vec!["app/live".to_owned()]); assert_eq!(page.cursor, None); } #[tokio::test] - async fn cleanup_expired_keys_does_not_delete_fresh_overwrite() { + async fn list_keys_page_returns_resume_cursor_when_scan_cap_is_hit() { + // Under `cfg(test)` the scan cap is 2 batches × 16 entries = 32. Insert + // 40 expired keys (so the cap is hit before the table is exhausted) + // followed by 3 live keys that sort after them. let temp_dir = tempfile::tempdir().unwrap(); let db_path = temp_dir.path().join("test.redb"); - let s = PersistentKvStore::new(db_path).unwrap(); + let kv_store = PersistentKvStore::new(db_path).unwrap(); + + for index in 0_i32..40_i32 { + kv_store + .put_bytes_with_ttl( + &format!("expired-{index:02}"), + Bytes::from("gone"), + Duration::from_millis(1), + ) + .await + .unwrap(); + } + for index in 0_i32..3_i32 { + kv_store + .put_bytes(&format!("live-{index}"), Bytes::from("value")) + .await + .unwrap(); + } + thread::sleep(Duration::from_millis(200)); + + // First page: the cap is hit while skipping the expired run, so no live + // keys are collected — but the cursor must be `Some` so the caller + // resumes instead of stopping on a spurious `None`. + let first = kv_store.list_keys_page("", None, 10).await.unwrap(); + assert!( + first.keys.is_empty(), + "expected an under-filled page, got {:?}", + first.keys + ); + let resume = first + .cursor + .expect("scan-cap page must carry a resume cursor"); - s.put_bytes_with_ttl("race/key", Bytes::from("stale"), Duration::from_millis(1)) + // Resuming from the cursor reaches the live keys past the expired run. + let second = kv_store + .list_keys_page("", Some(&resume), 10) .await .unwrap(); - std::thread::sleep(Duration::from_millis(200)); - s.put_bytes("race/key", Bytes::from("fresh")).await.unwrap(); - - s.cleanup_expired_keys(&["race/key".to_string()]).unwrap(); - assert_eq!( - s.get_bytes("race/key").await.unwrap(), - Some(Bytes::from("fresh")) + second.keys, + vec![ + "live-0".to_owned(), + "live-1".to_owned(), + "live-2".to_owned() + ], ); + assert_eq!(second.cursor, None); } - // -- Typed helpers via KvHandle ---------------------------------------- - - #[derive(serde::Serialize, serde::Deserialize, PartialEq, Debug)] - struct Config { - name: String, - enabled: bool, + #[tokio::test] + async fn new_store_is_empty() { + let (kv_store, _dir) = store(); + assert!(!kv_store.exists("anything").await.unwrap()); } #[tokio::test] - async fn typed_roundtrip() { - let (s, _dir) = store(); - let cfg = Config { - name: "test".into(), - enabled: true, - }; - s.put("config", &cfg).await.unwrap(); - let out: Option = s.get("config").await.unwrap(); - assert_eq!(out, Some(cfg)); + async fn put_and_get_bytes() { + let (kv_store, _dir) = store(); + kv_store.put_bytes("k", Bytes::from("hello")).await.unwrap(); + assert_eq!( + kv_store.get_bytes("k").await.unwrap(), + Some(Bytes::from("hello")) + ); } #[tokio::test] - async fn update_helper() { - let (s, _dir) = store(); - s.put("counter", &0i32).await.unwrap(); - let val = s - .read_modify_write("counter", 0i32, |n| n + 5) + async fn put_overwrites_existing() { + let (kv_store, _dir) = store(); + kv_store.put_bytes("k", Bytes::from("first")).await.unwrap(); + kv_store + .put_bytes("k", Bytes::from("second")) .await .unwrap(); - assert_eq!(val, 5); + assert_eq!( + kv_store.get_bytes("k").await.unwrap(), + Some(Bytes::from("second")) + ); } #[tokio::test] - async fn exists_helper() { - let (s, _dir) = store(); - assert!(!s.exists("nope").await.unwrap()); - s.put_bytes("k", Bytes::from("v")).await.unwrap(); - assert!(s.exists("k").await.unwrap()); + async fn ttl_expires_entry() { + // Use the store impl directly to bypass validation limits (min TTL 60s) + let temp_dir = tempfile::tempdir().unwrap(); + let db_path = temp_dir.path().join("test.redb"); + let kv_store = PersistentKvStore::new(db_path).unwrap(); + kv_store + .put_bytes_with_ttl("temp", Bytes::from("val"), Duration::from_millis(1)) + .await + .unwrap(); + // 200ms gives the OS scheduler enough headroom on busy CI runners. + thread::sleep(Duration::from_millis(200)); + assert_eq!(kv_store.get_bytes("temp").await.unwrap(), None); } #[tokio::test] - async fn new_store_is_empty() { - let (s, _dir) = store(); - assert!(!s.exists("anything").await.unwrap()); + async fn ttl_not_expired_returns_value() { + let (kv_store, _dir) = store(); + kv_store + .put_bytes_with_ttl("temp", Bytes::from("val"), Duration::from_mins(1)) + .await + .unwrap(); + assert_eq!( + kv_store.get_bytes("temp").await.unwrap(), + Some(Bytes::from("val")) + ); } - #[test] - fn concurrent_writes_dont_panic() { - let temp_dir = tempfile::tempdir().unwrap(); - let db_path = temp_dir.path().join("test.redb"); - let s = PersistentKvStore::new(db_path).unwrap(); - let handle = KvHandle::new(Arc::new(s)); - - // KvHandle futures are !Send (async_trait(?Send) for WASM compat), so - // tokio::spawn is off-limits. Use OS threads instead — KvHandle is - // Send + Sync, so each thread moves its own clone and runs its own - // executor. This is genuinely concurrent at the OS level. - let threads: Vec<_> = (0..100i32) - .map(|i| { - let h = handle.clone(); - std::thread::spawn(move || { - futures::executor::block_on(async move { - let key = format!("key:{i}"); - h.put(&key, &i).await.unwrap(); - }); - }) - }) - .collect(); - - for t in threads { - t.join().expect("writer thread panicked"); - } - - // Verify all 100 keys survived concurrent writes with correct values. - futures::executor::block_on(async { - for i in 0..100i32 { - let key = format!("key:{i}"); - let val: i32 = handle.get_or(&key, -1).await.unwrap(); - assert_eq!(val, i, "key:{i} has wrong value after concurrent writes"); - } - }); + #[tokio::test] + async fn typed_roundtrip() { + let (kv_store, _dir) = store(); + let cfg = Config { + enabled: true, + name: "test".into(), + }; + kv_store.put("config", &cfg).await.unwrap(); + let out: Option = kv_store.get("config").await.unwrap(); + assert_eq!(out, Some(cfg)); } #[tokio::test] - async fn data_persists_across_reopens() { - let temp_dir = tempfile::tempdir().unwrap(); - let db_path = temp_dir.path().join("test.redb"); - - // Write data - { - let store = PersistentKvStore::new(&db_path).unwrap(); - store - .put_bytes("persistent", Bytes::from("value")) - .await - .unwrap(); - } - - // Reopen and verify data persists - { - let store = PersistentKvStore::new(&db_path).unwrap(); - let value = store.get_bytes("persistent").await.unwrap(); - assert_eq!(value, Some(Bytes::from("value"))); - } + async fn update_helper() { + let (kv_store, _dir) = store(); + kv_store.put("counter", &0_i32).await.unwrap(); + let val = kv_store + .read_modify_write("counter", 0_i32, |num| num + 5_i32) + .await + .unwrap(); + assert_eq!(val, 5_i32); } - - // Run the shared contract tests against PersistentKvStore. - // `Box::leak` intentionally extends the TempDir's lifetime to 'static so - // it remains alive for the duration of the test process. The directory is - // deleted when the process exits, unlike `.keep()` which leaves it behind - // permanently. - edgezero_core::key_value_store_contract_tests!(persistent_kv_contract, { - let dir = Box::leak(Box::new(tempfile::tempdir().unwrap())); - let db_path = dir.path().join("contract.redb"); - PersistentKvStore::new(db_path).unwrap() - }); } diff --git a/crates/edgezero-adapter-axum/src/lib.rs b/crates/edgezero-adapter-axum/src/lib.rs index ae9e539d..d4cedf97 100644 --- a/crates/edgezero-adapter-axum/src/lib.rs +++ b/crates/edgezero-adapter-axum/src/lib.rs @@ -1,45 +1,26 @@ -//! Axum adapter for EdgeZero routers and applications. +//! Axum adapter for `EdgeZero` routers and applications. #[cfg(feature = "axum")] pub mod config_store; #[cfg(feature = "axum")] -mod context; +pub mod context; #[cfg(feature = "axum")] -mod dev_server; +pub mod dev_server; #[cfg(feature = "axum")] pub mod key_value_store; #[cfg(feature = "axum")] -mod proxy; +pub mod proxy; #[cfg(feature = "axum")] -mod request; +pub mod request; #[cfg(feature = "axum")] -mod response; +pub mod response; #[cfg(feature = "axum")] pub mod secret_store; #[cfg(feature = "axum")] -mod service; +pub mod service; #[cfg(feature = "cli")] pub mod cli; #[cfg(test)] pub mod test_utils; - -#[cfg(feature = "axum")] -pub use config_store::AxumConfigStore; -#[cfg(feature = "axum")] -pub use context::AxumRequestContext; -#[cfg(feature = "axum")] -pub use dev_server::{run_app, AxumDevServer, AxumDevServerConfig}; -#[cfg(feature = "axum")] -pub use key_value_store::PersistentKvStore; -#[cfg(feature = "axum")] -pub use proxy::AxumProxyClient; -#[cfg(feature = "axum")] -pub use request::into_core_request; -#[cfg(feature = "axum")] -pub use response::into_axum_response; -#[cfg(feature = "axum")] -pub use secret_store::EnvSecretStore; -#[cfg(feature = "axum")] -pub use service::EdgeZeroAxumService; diff --git a/crates/edgezero-adapter-axum/src/proxy.rs b/crates/edgezero-adapter-axum/src/proxy.rs index 60149556..8a1d404b 100644 --- a/crates/edgezero-adapter-axum/src/proxy.rs +++ b/crates/edgezero-adapter-axum/src/proxy.rs @@ -5,31 +5,40 @@ use edgezero_core::body::Body; use edgezero_core::error::EdgeError; use edgezero_core::http::{HeaderName, HeaderValue, Method, StatusCode}; use edgezero_core::proxy::{ProxyClient, ProxyRequest, ProxyResponse}; -use futures_util::StreamExt; +use futures_util::StreamExt as _; use reqwest::{header, Client}; pub struct AxumProxyClient { client: Client, } -impl Default for AxumProxyClient { - fn default() -> Self { - let client = Client::builder() - .timeout(Duration::from_secs(30)) - .build() - .expect("reqwest client"); - Self { client } +impl AxumProxyClient { + /// Construct a proxy client with the workspace-default 30-second timeout. + /// + /// **Breaking change (pre-1.0):** previously `AxumProxyClient` implemented + /// `Default` and panicked if reqwest's TLS backend could not be initialised. + /// Construction is now fallible so callers can decide how to handle a + /// missing or misconfigured TLS backend. + /// + /// # Errors + /// Returns the underlying [`reqwest::Error`] if `reqwest::Client::builder().build()` + /// fails — typically because the TLS backend cannot be initialised on this target. + #[inline] + pub fn try_new() -> Result { + let client = Client::builder().timeout(Duration::from_secs(30)).build()?; + Ok(Self { client }) } } #[async_trait(?Send)] impl ProxyClient for AxumProxyClient { + #[inline] async fn send(&self, request: ProxyRequest) -> Result { let (method, uri, headers, body, _extensions) = request.into_parts(); let reqwest_method = reqwest_method(&method)?; let mut builder = self.client.request(reqwest_method, uri.to_string()); - for (name, value) in headers.iter() { + for (name, value) in &headers { let header_name = header::HeaderName::from_bytes(name.as_str().as_bytes()) .map_err(EdgeError::internal)?; let header_value = @@ -41,8 +50,8 @@ impl ProxyClient for AxumProxyClient { Body::Once(bytes) => builder.body(bytes.to_vec()), Body::Stream(mut stream) => { let mut buf = Vec::new(); - while let Some(chunk) = stream.next().await { - let chunk = chunk.map_err(EdgeError::internal)?; + while let Some(result) = stream.next().await { + let chunk = result.map_err(EdgeError::internal)?; buf.extend_from_slice(&chunk); } builder.body(buf) @@ -54,7 +63,7 @@ impl ProxyClient for AxumProxyClient { StatusCode::from_u16(response.status().as_u16()).map_err(EdgeError::internal)?; let mut proxy_response = ProxyResponse::new(status, Body::empty()); - for (name, value) in response.headers().iter() { + for (name, value) in response.headers() { let header_name = HeaderName::from_bytes(name.as_str().as_bytes()).map_err(EdgeError::internal)?; let header_value = @@ -78,6 +87,7 @@ fn reqwest_method(method: &Method) -> Result { #[cfg(test)] mod tests { use super::*; + use std::mem; #[test] fn converts_method_to_reqwest() { @@ -105,18 +115,21 @@ mod tests { #[test] fn default_client_creates_successfully() { - let client = AxumProxyClient::default(); + let client = AxumProxyClient::try_new().expect("reqwest client init"); // Just verify it builds without panicking - assert!(std::mem::size_of_val(&client) > 0); + assert!(mem::size_of_val(&client) > 0); } } #[cfg(test)] mod integration_tests { use super::*; - use axum::{routing::get, routing::post, Router}; + use axum::body::Bytes as AxumBytes; + use axum::http::header::CONTENT_TYPE; + use axum::http::{HeaderMap as AxumHeaderMap, StatusCode as AxumStatusCode}; + use axum::routing::{delete, get, patch, post, put}; + use axum::Router; use edgezero_core::http::Uri; - use edgezero_core::proxy::ProxyClient; use tokio::net::TcpListener; async fn start_test_server(router: Router) -> String { @@ -125,7 +138,7 @@ mod integration_tests { tokio::spawn(async move { axum::serve(listener, router).await.unwrap(); }); - format!("http://{}", addr) + format!("http://{addr}") } #[tokio::test] @@ -133,8 +146,8 @@ mod integration_tests { let app = Router::new().route("/test", get(|| async { "hello from server" })); let base_url = start_test_server(app).await; - let client = AxumProxyClient::default(); - let uri: Uri = format!("{}/test", base_url).parse().unwrap(); + let client = AxumProxyClient::try_new().expect("reqwest client init"); + let uri: Uri = format!("{base_url}/test").parse().unwrap(); let request = ProxyRequest::new(Method::GET, uri); let response = client.send(request).await.expect("response"); @@ -142,17 +155,17 @@ mod integration_tests { match response.body() { Body::Once(bytes) => assert_eq!(bytes.as_ref(), b"hello from server"), - _ => panic!("expected buffered body"), + Body::Stream(_) => panic!("expected buffered body"), } } #[tokio::test] async fn proxy_client_sends_post_with_body() { - let app = Router::new().route("/echo", post(|body: axum::body::Bytes| async move { body })); + let app = Router::new().route("/echo", post(|body: AxumBytes| async move { body })); let base_url = start_test_server(app).await; - let client = AxumProxyClient::default(); - let uri: Uri = format!("{}/echo", base_url).parse().unwrap(); + let client = AxumProxyClient::try_new().expect("reqwest client init"); + let uri: Uri = format!("{base_url}/echo").parse().unwrap(); let mut request = ProxyRequest::new(Method::POST, uri); *request.body_mut() = Body::from("request body data"); @@ -161,7 +174,7 @@ mod integration_tests { match response.body() { Body::Once(bytes) => assert_eq!(bytes.as_ref(), b"request body data"), - _ => panic!("expected buffered body"), + Body::Stream(_) => panic!("expected buffered body"), } } @@ -169,18 +182,18 @@ mod integration_tests { async fn proxy_client_forwards_request_headers() { let app = Router::new().route( "/headers", - get(|headers: axum::http::HeaderMap| async move { + get(|headers: AxumHeaderMap| async move { headers .get("x-custom-header") - .and_then(|v| v.to_str().ok()) + .and_then(|val| val.to_str().ok()) .unwrap_or("missing") - .to_string() + .to_owned() }), ); let base_url = start_test_server(app).await; - let client = AxumProxyClient::default(); - let uri: Uri = format!("{}/headers", base_url).parse().unwrap(); + let client = AxumProxyClient::try_new().expect("reqwest client init"); + let uri: Uri = format!("{base_url}/headers").parse().unwrap(); let mut request = ProxyRequest::new(Method::GET, uri); request .headers_mut() @@ -191,7 +204,7 @@ mod integration_tests { match response.body() { Body::Once(bytes) => assert_eq!(bytes.as_ref(), b"custom-value"), - _ => panic!("expected buffered body"), + Body::Stream(_) => panic!("expected buffered body"), } } @@ -199,17 +212,12 @@ mod integration_tests { async fn proxy_client_receives_response_headers() { let app = Router::new().route( "/with-headers", - get(|| async { - ( - [(axum::http::header::CONTENT_TYPE, "application/json")], - "{}", - ) - }), + get(|| async { ([(CONTENT_TYPE, "application/json")], "{}") }), ); let base_url = start_test_server(app).await; - let client = AxumProxyClient::default(); - let uri: Uri = format!("{}/with-headers", base_url).parse().unwrap(); + let client = AxumProxyClient::try_new().expect("reqwest client init"); + let uri: Uri = format!("{base_url}/with-headers").parse().unwrap(); let request = ProxyRequest::new(Method::GET, uri); let response = client.send(request).await.expect("response"); @@ -218,7 +226,7 @@ mod integration_tests { let content_type = response .headers() .get("content-type") - .and_then(|v| v.to_str().ok()); + .and_then(|val| val.to_str().ok()); assert_eq!(content_type, Some("application/json")); } @@ -227,8 +235,8 @@ mod integration_tests { let app = Router::new(); let base_url = start_test_server(app).await; - let client = AxumProxyClient::default(); - let uri: Uri = format!("{}/nonexistent", base_url).parse().unwrap(); + let client = AxumProxyClient::try_new().expect("reqwest client init"); + let uri: Uri = format!("{base_url}/nonexistent").parse().unwrap(); let request = ProxyRequest::new(Method::GET, uri); let response = client.send(request).await.expect("response"); @@ -239,12 +247,12 @@ mod integration_tests { async fn proxy_client_handles_500() { let app = Router::new().route( "/error", - get(|| async { (axum::http::StatusCode::INTERNAL_SERVER_ERROR, "error") }), + get(|| async { (AxumStatusCode::INTERNAL_SERVER_ERROR, "error") }), ); let base_url = start_test_server(app).await; - let client = AxumProxyClient::default(); - let uri: Uri = format!("{}/error", base_url).parse().unwrap(); + let client = AxumProxyClient::try_new().expect("reqwest client init"); + let uri: Uri = format!("{base_url}/error").parse().unwrap(); let request = ProxyRequest::new(Method::GET, uri); let response = client.send(request).await.expect("response"); @@ -256,12 +264,12 @@ mod integration_tests { let app = Router::new() .route("/method", get(|| async { "GET" })) .route("/method", post(|| async { "POST" })) - .route("/method", axum::routing::put(|| async { "PUT" })) - .route("/method", axum::routing::delete(|| async { "DELETE" })) - .route("/method", axum::routing::patch(|| async { "PATCH" })); + .route("/method", put(|| async { "PUT" })) + .route("/method", delete(|| async { "DELETE" })) + .route("/method", patch(|| async { "PATCH" })); let base_url = start_test_server(app).await; - let client = AxumProxyClient::default(); + let client = AxumProxyClient::try_new().expect("reqwest client init"); for (method, expected_body) in [ (Method::GET, "GET"), @@ -270,26 +278,28 @@ mod integration_tests { (Method::DELETE, "DELETE"), (Method::PATCH, "PATCH"), ] { - let uri: Uri = format!("{}/method", base_url).parse().unwrap(); + let uri: Uri = format!("{base_url}/method").parse().unwrap(); let request = ProxyRequest::new(method, uri); let response = client.send(request).await.expect("response"); assert_eq!(response.status(), StatusCode::OK); match response.body() { Body::Once(bytes) => assert_eq!(bytes.as_ref(), expected_body.as_bytes()), - _ => panic!("expected buffered body"), + Body::Stream(_) => panic!("expected buffered body"), } } } #[tokio::test] async fn proxy_client_handles_connection_refused() { - let client = AxumProxyClient::default(); + let client = AxumProxyClient::try_new().expect("reqwest client init"); // Use a port that's unlikely to have anything running let uri: Uri = "http://127.0.0.1:1".parse().unwrap(); let request = ProxyRequest::new(Method::GET, uri); - let result = client.send(request).await; - assert!(result.is_err()); + client + .send(request) + .await + .expect_err("expected connection refused"); } #[tokio::test] @@ -297,14 +307,11 @@ mod integration_tests { use bytes::Bytes; use futures::stream; - let app = Router::new().route( - "/stream-echo", - post(|body: axum::body::Bytes| async move { body }), - ); + let app = Router::new().route("/stream-echo", post(|body: AxumBytes| async move { body })); let base_url = start_test_server(app).await; - let client = AxumProxyClient::default(); - let uri: Uri = format!("{}/stream-echo", base_url).parse().unwrap(); + let client = AxumProxyClient::try_new().expect("reqwest client init"); + let uri: Uri = format!("{base_url}/stream-echo").parse().unwrap(); let mut request = ProxyRequest::new(Method::POST, uri); // Create a streaming body - Body::stream expects Stream @@ -321,7 +328,7 @@ mod integration_tests { match response.body() { Body::Once(bytes) => assert_eq!(bytes.as_ref(), b"chunk1chunk2chunk3"), - _ => panic!("expected buffered body"), + Body::Stream(_) => panic!("expected buffered body"), } } } diff --git a/crates/edgezero-adapter-axum/src/request.rs b/crates/edgezero-adapter-axum/src/request.rs index e1e973d4..91a905e1 100644 --- a/crates/edgezero-adapter-axum/src/request.rs +++ b/crates/edgezero-adapter-axum/src/request.rs @@ -1,6 +1,6 @@ use std::net::SocketAddr; -use axum::body::Body as AxumBody; +use axum::body::{to_bytes, Body as AxumBody}; use axum::extract::connect_info::ConnectInfo; use axum::http::Request; use edgezero_core::body::Body; @@ -12,20 +12,24 @@ use edgezero_core::proxy::ProxyHandle; use crate::context::AxumRequestContext; use crate::proxy::AxumProxyClient; -/// Convert an Axum/Hyper request into an EdgeZero core request while preserving streaming bodies +/// Convert an Axum/Hyper request into an `EdgeZero` core request while preserving streaming bodies /// and exposing connection metadata through `AxumRequestContext`. +/// +/// # Errors +/// Returns an error if a buffered (`application/json`) body cannot be read into memory. +#[inline] pub async fn into_core_request(request: Request) -> Result { - let (parts, body) = request.into_parts(); + let (parts, axum_body) = request.into_parts(); let body = match parts.headers.get(CONTENT_TYPE) { Some(value) if is_json_content_type(value) => { - let bytes = axum::body::to_bytes(body, usize::MAX) + let bytes = to_bytes(axum_body, usize::MAX) .await - .map_err(|e| format!("Failed to convert body into bytes: {e}"))?; + .map_err(|err| format!("Failed to convert body into bytes: {err}"))?; Body::from_bytes(bytes) } _ => { - let stream = body.into_data_stream(); + let stream = axum_body.into_data_stream(); Body::from_stream(stream) } }; @@ -48,9 +52,11 @@ pub async fn into_core_request(request: Request) -> Result bool { return false; }; - let media_type = raw.split(';').next().map(str::trim).unwrap_or(""); + let media_type = raw.split(';').next().map_or("", str::trim); if media_type.eq_ignore_ascii_case("application/json") { return true; } - let Some((ty, subtype)) = media_type.split_once('/') else { + let Some((ty, raw_subtype)) = media_type.split_once('/') else { return false; }; @@ -73,8 +79,13 @@ fn is_json_content_type(value: &HeaderValue) -> bool { return false; } - let subtype = subtype.trim(); - subtype.len() >= 5 && subtype[subtype.len() - 5..].eq_ignore_ascii_case("+json") + let subtype = raw_subtype.trim(); + let Some(suffix_start) = subtype.len().checked_sub(5) else { + return false; + }; + subtype + .get(suffix_start..) + .is_some_and(|suffix| suffix.eq_ignore_ascii_case("+json")) } #[cfg(test)] @@ -168,7 +179,7 @@ mod tests { } #[test] - fn test_is_json_content_type() { + fn json_content_type_detection() { assert!(is_json_content_type(&HeaderValue::from_static( "application/json" ))); diff --git a/crates/edgezero-adapter-axum/src/response.rs b/crates/edgezero-adapter-axum/src/response.rs index 46dc38ff..9ad56d0b 100644 --- a/crates/edgezero-adapter-axum/src/response.rs +++ b/crates/edgezero-adapter-axum/src/response.rs @@ -1,25 +1,27 @@ use axum::body::Body as AxumBody; -use axum::http::{Response, StatusCode}; +use axum::http::header::CONTENT_TYPE; +use axum::http::{HeaderValue, Response, StatusCode}; use futures::executor::block_on; -use futures_util::{pin_mut, StreamExt}; +use futures_util::{pin_mut, StreamExt as _}; use tracing::error; use edgezero_core::body::Body; use edgezero_core::http::Response as CoreResponse; -/// Convert an EdgeZero response into one consumable by Axum/Hyper. +/// Convert an `EdgeZero` response into one consumable by Axum/Hyper. /// /// Streaming responses are collected into an in-memory buffer. While this sacrifices /// incremental flushing, it keeps the adapter compatible with the non-`Send` streaming type used by /// `edgezero_core::Body` and works well for local development. +/// +#[inline] pub fn into_axum_response(response: CoreResponse) -> Response { - let (parts, body) = response.into_parts(); - let body = match body { + let (parts, core_body) = response.into_parts(); + let body = match core_body { Body::Once(bytes) => AxumBody::from(bytes), Body::Stream(stream) => { let result = block_on(async { let mut buf = Vec::new(); - let stream = stream; pin_mut!(stream); while let Some(chunk) = stream.next().await { let bytes = chunk?; @@ -31,16 +33,7 @@ pub fn into_axum_response(response: CoreResponse) -> Response { Ok(buf) => AxumBody::from(buf), Err(err) => { error!("streaming response error: {err}"); - let body = AxumBody::from("streaming response error"); - let mut response = Response::builder() - .status(StatusCode::INTERNAL_SERVER_ERROR) - .body(body) - .expect("error response"); - response.headers_mut().insert( - axum::http::header::CONTENT_TYPE, - axum::http::HeaderValue::from_static("text/plain; charset=utf-8"), - ); - return response; + return error_response_500("streaming response error"); } } } @@ -49,6 +42,18 @@ pub fn into_axum_response(response: CoreResponse) -> Response { Response::from_parts(parts, body) } +/// Build a minimal 500 response without any builder steps that could fail. +/// Used as a fallback on the request path so we never panic on synthesis. +fn error_response_500(message: &'static str) -> Response { + let mut response = Response::new(AxumBody::from(message)); + *response.status_mut() = StatusCode::INTERNAL_SERVER_ERROR; + response.headers_mut().insert( + CONTENT_TYPE, + HeaderValue::from_static("text/plain; charset=utf-8"), + ); + response +} + #[cfg(test)] mod tests { use super::*; @@ -83,9 +88,9 @@ mod tests { let collected = block_on(async { let mut data = Vec::new(); - let mut stream = axum_response.into_body().into_data_stream(); - while let Some(chunk) = stream.next().await { - let chunk = chunk.expect("chunk"); + let mut body_stream = axum_response.into_body().into_data_stream(); + while let Some(result) = body_stream.next().await { + let chunk = result.expect("chunk"); data.extend_from_slice(&chunk); } data diff --git a/crates/edgezero-adapter-axum/src/secret_store.rs b/crates/edgezero-adapter-axum/src/secret_store.rs index 1d216c81..80e2eb7f 100644 --- a/crates/edgezero-adapter-axum/src/secret_store.rs +++ b/crates/edgezero-adapter-axum/src/secret_store.rs @@ -4,9 +4,11 @@ //! variables before starting the dev server: //! //! ```bash -//! API_KEY=mysecret cargo edgezero dev +//! API_KEY=mysecret edgezero serve --adapter axum //! ``` +use std::env; + use async_trait::async_trait; use bytes::Bytes; use edgezero_core::secret_store::{SecretError, SecretStore}; @@ -18,12 +20,15 @@ use edgezero_core::secret_store::{SecretError, SecretStore}; pub struct EnvSecretStore; impl EnvSecretStore { + #[must_use] + #[inline] pub fn new() -> Self { Self } } impl Default for EnvSecretStore { + #[inline] fn default() -> Self { Self::new() } @@ -31,12 +36,13 @@ impl Default for EnvSecretStore { #[async_trait(?Send)] impl SecretStore for EnvSecretStore { + #[inline] async fn get_bytes(&self, _store_name: &str, key: &str) -> Result, SecretError> { #[cfg(unix)] { - use std::os::unix::ffi::OsStringExt; + use std::os::unix::ffi::OsStringExt as _; - match std::env::var_os(key) { + match env::var_os(key) { Some(value) => Ok(Some(Bytes::from(value.into_vec()))), None => Ok(None), } @@ -44,12 +50,14 @@ impl SecretStore for EnvSecretStore { #[cfg(not(unix))] { - match std::env::var(key) { + use std::env::VarError; + + match env::var(key) { Ok(value) => Ok(Some(Bytes::from(value.into_bytes()))), - Err(std::env::VarError::NotPresent) => Ok(None), - Err(std::env::VarError::NotUnicode(_)) => Err(SecretError::Internal( - anyhow::anyhow!("secret store returned an invalid Unicode value"), - )), + Err(VarError::NotPresent) => Ok(None), + Err(VarError::NotUnicode(_)) => Err(SecretError::Internal(anyhow::anyhow!( + "secret store returned an invalid Unicode value" + ))), } } } @@ -57,63 +65,63 @@ impl SecretStore for EnvSecretStore { #[cfg(test)] mod tests { + // Contract tests: use InMemorySecretStoreProvider since EnvSecretStore needs + // real env vars, which are unsafe in parallel tests. + // The EnvSecretStore is tested individually above. + secret_store_contract_tests!(env_secret_contract, { + InMemorySecretStore::new([ + ("mystore/contract_key", Bytes::from("contract_value")), + ("mystore/contract_key_2", Bytes::from("another_value")), + ]) + }); + use super::*; use crate::test_utils::{env_guard, EnvOverride}; use bytes::Bytes; + use edgezero_core::secret_store::InMemorySecretStore; + use edgezero_core::secret_store_contract_tests; #[cfg(unix)] use std::ffi::OsString; + #[cfg(unix)] #[tokio::test(flavor = "current_thread")] - async fn get_bytes_returns_none_when_var_not_set() { + async fn get_bytes_preserves_non_utf8_secret_values() { + use std::os::unix::ffi::OsStringExt as _; + let _guard = env_guard().lock().await; - let _env = EnvOverride::clear("__EDGEZERO_TEST_MISSING_VAR_XYZ__"); + let _env = EnvOverride::set( + "__EDGEZERO_TEST_BINARY_SECRET__", + OsString::from_vec(vec![0xff, 0x61]), + ); let store = EnvSecretStore::new(); let result = store - .get_bytes("env", "__EDGEZERO_TEST_MISSING_VAR_XYZ__") + .get_bytes("env", "__EDGEZERO_TEST_BINARY_SECRET__") .await .unwrap(); - assert!(result.is_none()); + assert_eq!(result, Some(Bytes::from_static(&[0xff, 0x61]))); } #[tokio::test(flavor = "current_thread")] - async fn get_bytes_returns_value_when_var_set() { + async fn get_bytes_returns_none_when_var_not_set() { let _guard = env_guard().lock().await; - let _env = EnvOverride::set("__EDGEZERO_TEST_SECRET__", "test_value_123"); + let _env = EnvOverride::clear("__EDGEZERO_TEST_MISSING_VAR_XYZ__"); let store = EnvSecretStore::new(); let result = store - .get_bytes("env", "__EDGEZERO_TEST_SECRET__") + .get_bytes("env", "__EDGEZERO_TEST_MISSING_VAR_XYZ__") .await .unwrap(); - assert_eq!(result, Some(Bytes::from("test_value_123"))); + assert!(result.is_none()); } - #[cfg(unix)] #[tokio::test(flavor = "current_thread")] - async fn get_bytes_preserves_non_utf8_secret_values() { - use std::os::unix::ffi::OsStringExt; - + async fn get_bytes_returns_value_when_var_set() { let _guard = env_guard().lock().await; - let _env = EnvOverride::set( - "__EDGEZERO_TEST_BINARY_SECRET__", - OsString::from_vec(vec![0xff, 0x61]), - ); + let _env = EnvOverride::set("__EDGEZERO_TEST_SECRET__", "test_value_123"); let store = EnvSecretStore::new(); let result = store - .get_bytes("env", "__EDGEZERO_TEST_BINARY_SECRET__") + .get_bytes("env", "__EDGEZERO_TEST_SECRET__") .await .unwrap(); - assert_eq!(result, Some(Bytes::from_static(&[0xff, 0x61]))); + assert_eq!(result, Some(Bytes::from("test_value_123"))); } - - // Contract tests: use InMemorySecretStoreProvider since EnvSecretStore needs - // real env vars, which are unsafe in parallel tests. - // The EnvSecretStore is tested individually above. - use edgezero_core::secret_store_contract_tests; - - secret_store_contract_tests!(env_secret_contract, { - edgezero_core::InMemorySecretStore::new([ - ("mystore/contract_key", Bytes::from("contract_value")), - ("mystore/contract_key_2", Bytes::from("another_value")), - ]) - }); } diff --git a/crates/edgezero-adapter-axum/src/service.rs b/crates/edgezero-adapter-axum/src/service.rs index cf6ba27f..d726ba06 100644 --- a/crates/edgezero-adapter-axum/src/service.rs +++ b/crates/edgezero-adapter-axum/src/service.rs @@ -10,36 +10,60 @@ use edgezero_core::http::StatusCode; use edgezero_core::key_value_store::KvHandle; use edgezero_core::router::RouterService; use edgezero_core::secret_store::SecretHandle; +use edgezero_core::store_registry::{BoundSecretStore, ConfigRegistry, KvRegistry, SecretRegistry}; use tokio::{runtime::Handle, task}; use tower::Service; use crate::request::into_core_request; use crate::response::into_axum_response; -/// Tower service that adapts EdgeZero router requests to Axum/Hyper compatible responses. +/// Tower service that adapts `EdgeZero` router requests to Axum/Hyper compatible responses. #[derive(Clone)] pub struct EdgeZeroAxumService { - router: RouterService, + config_registry: Option, config_store_handle: Option, kv_handle: Option, + kv_registry: Option, + router: RouterService, secret_handle: Option, + secret_registry: Option, } impl EdgeZeroAxumService { + #[must_use] + #[inline] pub fn new(router: RouterService) -> Self { Self { - router, + config_registry: None, config_store_handle: None, kv_handle: None, + kv_registry: None, + router, secret_handle: None, + secret_registry: None, } } + /// Attach an id-keyed config-store registry to this service. + #[must_use] + #[inline] + pub fn with_config_registry(mut self, registry: ConfigRegistry) -> Self { + self.config_registry = Some(registry); + self + } + /// Attach a shared config store to this service. /// - /// The handle is cloned into every request's extensions, making - /// `ctx.config_store()` available in handlers. + /// Single-handle setter; the dispatcher synthesises a one-id + /// `ConfigRegistry` keyed under `"default"`. Handlers read it + /// via `ctx.config_store_default()` or the `Config` extractor + /// (the pre-rewrite `ctx.config_handle()` accessor is gone -- + /// see the runtime-store-API hard-cutoff in + /// docs/guide/manifest-store-migration.md). New code that + /// declares multiple ids should use [`Self::with_config_registry`] + /// directly. #[must_use] + #[inline] pub fn with_config_store_handle(mut self, handle: ConfigStoreHandle) -> Self { self.config_store_handle = Some(handle); self @@ -47,86 +71,157 @@ impl EdgeZeroAxumService { /// Attach a shared KV store to this service. /// - /// The handle is cloned into every request's extensions, making - /// the `Kv` extractor available in handlers. + /// Single-handle setter; the dispatcher synthesises a one-id + /// `KvRegistry` keyed under `"default"`. Handlers read it via + /// `ctx.kv_store_default()` or the `Kv` extractor (the + /// pre-rewrite `ctx.kv_handle()` accessor is gone -- see the + /// runtime-store-API hard-cutoff in + /// docs/guide/manifest-store-migration.md). New code that + /// declares multiple ids should use [`Self::with_kv_registry`] + /// directly. #[must_use] + #[inline] pub fn with_kv_handle(mut self, handle: KvHandle) -> Self { self.kv_handle = Some(handle); self } + /// Attach an id-keyed KV registry to this service. + #[must_use] + #[inline] + pub fn with_kv_registry(mut self, registry: KvRegistry) -> Self { + self.kv_registry = Some(registry); + self + } + /// Attach a shared secret store to this service. /// - /// The handle is cloned into every request's extensions, making - /// the `Secrets` extractor available in handlers. + /// Single-handle setter; the dispatcher synthesises a one-id + /// `SecretRegistry` keyed under `"default"` (the handle is + /// bound to the platform store name `"default"`). Handlers + /// read it via `ctx.secret_store_default()` or the `Secrets` + /// extractor (the pre-rewrite `ctx.secret_handle()` accessor + /// is gone -- see the runtime-store-API hard-cutoff in + /// docs/guide/manifest-store-migration.md). New code that + /// declares multiple ids should use + /// [`Self::with_secret_registry`] directly. #[must_use] + #[inline] pub fn with_secret_handle(mut self, handle: SecretHandle) -> Self { self.secret_handle = Some(handle); self } + + /// Attach an id-keyed secret-store registry to this service. + #[must_use] + #[inline] + pub fn with_secret_registry(mut self, registry: SecretRegistry) -> Self { + self.secret_registry = Some(registry); + self + } } impl Service> for EdgeZeroAxumService { - type Response = Response; type Error = Infallible; type Future = Pin> + Send>>; + type Response = Response; - fn poll_ready(&mut self, _cx: &mut Context<'_>) -> Poll> { - Poll::Ready(Ok(())) - } - - fn call(&mut self, request: Request) -> Self::Future { + #[inline] + fn call(&mut self, req: Request) -> Self::Future { let router = self.router.clone(); - let config_store_handle = self.config_store_handle.clone(); - let kv_handle = self.kv_handle.clone(); - let secret_handle = self.secret_handle.clone(); + // Hard-cutoff: legacy bare `KvHandle` / + // `ConfigStoreHandle` / `SecretHandle` entries are NO + // LONGER inserted into request extensions. The legacy + // `with_*_handle` constructors still take a single + // handle, but the dispatcher synthesises a one-id + // `Registry` under the conventional `"default"` + // id from that handle — and only the registry goes into + // extensions. Handlers must use the registry-aware + // `RequestContext` accessors (`kv_store_default`, + // `config_store_default`, `secret_store_default`) or + // the `Kv` / `Config` / `Secrets` extractors. The + // pre-rewrite `ctx.kv_handle()` / `config_handle()` / + // `secret_handle()` accessors are gone (spec + // hard-cutoff). + let config_registry = self.config_registry.clone().or_else(|| { + self.config_store_handle + .clone() + .map(|handle| ConfigRegistry::single_id("default".to_owned(), handle)) + }); + let kv_registry = self.kv_registry.clone().or_else(|| { + self.kv_handle + .clone() + .map(|handle| KvRegistry::single_id("default".to_owned(), handle)) + }); + let secret_registry = self.secret_registry.clone().or_else(|| { + self.secret_handle.clone().map(|handle| { + SecretRegistry::single_id( + "default".to_owned(), + BoundSecretStore::new(handle, "default".to_owned()), + ) + }) + }); Box::pin(async move { - let mut core_request = match into_core_request(request).await { - Ok(req) => req, - Err(e) => { - let mut err_response = Response::new(AxumBody::from(e.to_string())); + let mut core_request = match into_core_request(req).await { + Ok(converted) => converted, + Err(err) => { + let mut err_response = Response::new(AxumBody::from(err.clone())); *err_response.status_mut() = StatusCode::INTERNAL_SERVER_ERROR; return Ok(err_response); } }; - if let Some(handle) = config_store_handle { - core_request.extensions_mut().insert(handle); + if let Some(registry) = config_registry { + core_request.extensions_mut().insert(registry); } - - if let Some(handle) = kv_handle { - core_request.extensions_mut().insert(handle); + if let Some(registry) = kv_registry { + core_request.extensions_mut().insert(registry); } - - if let Some(secret_handle) = secret_handle { - core_request.extensions_mut().insert(secret_handle); + if let Some(registry) = secret_registry { + core_request.extensions_mut().insert(registry); } let core_response = task::block_in_place(move || { Handle::current().block_on(router.oneshot(core_request)) }); - let response = into_axum_response(core_response); + let response = match core_response { + Ok(response) => into_axum_response(response), + Err(err) => { + let body = AxumBody::from(format!("internal error: {err}")); + let mut fallback = Response::new(body); + *fallback.status_mut() = StatusCode::INTERNAL_SERVER_ERROR; + fallback + } + }; Ok(response) }) } + + #[inline] + fn poll_ready(&mut self, _cx: &mut Context<'_>) -> Poll> { + Poll::Ready(Ok(())) + } } #[cfg(test)] mod tests { use super::*; + use axum::body::to_bytes; use edgezero_core::body::Body; use edgezero_core::config_store::{ConfigStore, ConfigStoreError, ConfigStoreHandle}; use edgezero_core::context::RequestContext; use edgezero_core::error::EdgeError; use edgezero_core::http::{response_builder, StatusCode}; + use edgezero_core::key_value_store::KvStore; use std::sync::Arc; - use tower::ServiceExt; + use tower::ServiceExt as _; struct FixedConfigStore(String); + #[async_trait::async_trait(?Send)] impl ConfigStore for FixedConfigStore { - fn get(&self, _key: &str) -> Result, ConfigStoreError> { + async fn get(&self, _key: &str) -> Result, ConfigStoreError> { Ok(Some(self.0.clone())) } } @@ -151,13 +246,20 @@ mod tests { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn with_config_store_handle_injects_into_request() { - let handle = ConfigStoreHandle::new(Arc::new(FixedConfigStore("injected".to_string()))); + // Hard-cutoff: legacy `ctx.config_handle()` is + // gone. The service synthesises a one-id `ConfigRegistry` + // from the wired handle at the dispatch boundary, so + // `ctx.config_store_default()` resolves the same store. + let handle = ConfigStoreHandle::new(Arc::new(FixedConfigStore("injected".to_owned()))); let router = RouterService::builder() .get("/check", |ctx: RequestContext| async move { - let store = ctx.config_store().expect("config store should be present"); + let store = ctx + .config_store_default() + .expect("config store should be present"); let val = store .get("any_key") + .await .expect("config lookup should succeed") .unwrap_or_default(); let response = response_builder() @@ -176,10 +278,8 @@ mod tests { let response = service.ready().await.unwrap().call(request).await.unwrap(); assert_eq!(response.status(), StatusCode::OK); - let body = axum::body::to_bytes(response.into_body(), usize::MAX) - .await - .unwrap(); - assert_eq!(&body[..], b"injected"); + let body = to_bytes(response.into_body(), usize::MAX).await.unwrap(); + assert_eq!(&*body, b"injected"); } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] @@ -188,13 +288,15 @@ mod tests { let temp_dir = tempfile::tempdir().unwrap(); let db_path = temp_dir.path().join("test.redb"); - let store = Arc::new(PersistentKvStore::new(db_path).unwrap()); - let handle = KvHandle::new(store.clone()); + let store: Arc = Arc::new(PersistentKvStore::new(db_path).unwrap()); + let handle = KvHandle::new(Arc::clone(&store)); handle.put("test_key", &"injected").await.unwrap(); let router = RouterService::builder() .get("/check", |ctx: RequestContext| async move { - let kv = ctx.kv_handle().expect("kv handle should be present"); + // Hard-cutoff: see + // `with_config_store_handle_injects_into_request`. + let kv = ctx.kv_store_default().expect("kv handle should be present"); let val: String = kv.get_or("test_key", String::new()).await.unwrap(); let response = response_builder() .status(StatusCode::OK) @@ -212,17 +314,148 @@ mod tests { let response = service.ready().await.unwrap().call(request).await.unwrap(); assert_eq!(response.status(), StatusCode::OK); - let body = axum::body::to_bytes(response.into_body(), usize::MAX) + let body = to_bytes(response.into_body(), usize::MAX).await.unwrap(); + assert_eq!(&*body, b"injected"); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn kv_registry_wins_over_bare_handle_when_both_wired() { + // Documents the precedence rule baked into the dispatcher: + // `self.kv_registry.clone().or_else(|| self.kv_handle.map(...single_id))`. + // If a caller wires BOTH `.with_kv_registry(...)` and + // `.with_kv_handle(...)`, the registry wins outright -- the + // bare handle is NOT used as a fallback for ids the registry + // doesn't define, and is NOT synthesised into a "default" + // entry alongside the registry's ids. + use crate::key_value_store::PersistentKvStore; + use edgezero_core::store_registry::{KvRegistry, StoreRegistry}; + use std::collections::BTreeMap; + + let temp_dir = tempfile::tempdir().unwrap(); + let registry_store: Arc = + Arc::new(PersistentKvStore::new(temp_dir.path().join("registry.redb")).unwrap()); + let registry_handle = KvHandle::new(Arc::clone(®istry_store)); + registry_handle + .put("marker", &"from_registry") .await .unwrap(); - assert_eq!(&body[..], b"injected"); + + let handle_store: Arc = + Arc::new(PersistentKvStore::new(temp_dir.path().join("handle.redb")).unwrap()); + let bare_handle = KvHandle::new(Arc::clone(&handle_store)); + bare_handle.put("marker", &"from_bare").await.unwrap(); + + // Registry binds only `sessions` (NOT `default`). If the + // dispatcher merged in the bare handle, `default` would + // resolve to the bare-handle store; the test asserts it does + // NOT. + let by_id: BTreeMap = [("sessions".to_owned(), registry_handle)] + .into_iter() + .collect(); + let registry: KvRegistry = StoreRegistry::new(by_id, "sessions".to_owned()); + + let router = RouterService::builder() + .get("/probe", |ctx: RequestContext| async move { + // Registry's id resolves to the registry's store. + let named = ctx.kv_store("sessions").expect("registry binding"); + let from_named: String = named.get_or("marker", String::new()).await.unwrap(); + // Default ALSO resolves to the registry (registry's + // own declared default), NOT the bare handle. + let default = ctx.kv_store_default().expect("registry default"); + let from_default: String = default.get_or("marker", String::new()).await.unwrap(); + // The bare handle's synthesised `default` id is NOT + // exposed -- registry wins outright. + let bare_default_visible = ctx.kv_store("default").is_some(); + let response = response_builder() + .status(StatusCode::OK) + .body(Body::from(format!( + "named={from_named} default={from_default} bare_default={bare_default_visible}" + ))) + .expect("response"); + Ok::<_, EdgeError>(response) + }) + .build(); + // Wire BOTH: registry first, then a bare handle. The bare + // handle would synthesise a "default" id under the legacy + // path; the dispatcher's `or_else` precedence must skip it. + let mut service = EdgeZeroAxumService::new(router) + .with_kv_registry(registry) + .with_kv_handle(bare_handle); + + let request = Request::builder() + .uri("/probe") + .body(AxumBody::empty()) + .unwrap(); + let response = service.ready().await.unwrap().call(request).await.unwrap(); + assert_eq!(response.status(), StatusCode::OK); + + let body = to_bytes(response.into_body(), usize::MAX).await.unwrap(); + assert_eq!( + &*body, b"named=from_registry default=from_registry bare_default=false", + "registry must win: bare handle is neither merged in nor a fallback" + ); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn with_kv_handle_synthesises_one_id_registry_under_default() { + // Verifies the one-id-registry contract for the setup API: + // `with_kv_handle(h)` wraps `h` in a `KvRegistry` with the + // logical id `"default"`. So in a handler: + // - `ctx.kv_store_default()` must resolve. + // - `ctx.kv_store("default")` must resolve to the same handle. + // - `ctx.kv_store("any-other-id")` must return None (the + // registry has only one id; named lookups for anything + // else are misses, not silent fallbacks). + // This is the precedence guarantee that lets handlers use + // the named-lookup path uniformly across adapters with one + // or many declared stores. + use crate::key_value_store::PersistentKvStore; + + let temp_dir = tempfile::tempdir().unwrap(); + let db_path = temp_dir.path().join("test.redb"); + let store: Arc = Arc::new(PersistentKvStore::new(db_path).unwrap()); + let handle = KvHandle::new(Arc::clone(&store)); + handle.put("k", &"v").await.unwrap(); + + let router = RouterService::builder() + .get("/probe", |ctx: RequestContext| async move { + let by_default = ctx.kv_store_default().is_some(); + let by_default_name = ctx.kv_store("default").is_some(); + let unknown = ctx.kv_store("custom-id").is_none(); + let response = response_builder() + .status(StatusCode::OK) + .body(Body::from(format!( + "default={by_default} named_default={by_default_name} unknown_is_none={unknown}" + ))) + .expect("response"); + Ok::<_, EdgeError>(response) + }) + .build(); + let mut service = EdgeZeroAxumService::new(router).with_kv_handle(handle); + + let request = Request::builder() + .uri("/probe") + .body(AxumBody::empty()) + .unwrap(); + let response = service.ready().await.unwrap().call(request).await.unwrap(); + assert_eq!(response.status(), StatusCode::OK); + + let body = to_bytes(response.into_body(), usize::MAX).await.unwrap(); + assert_eq!( + &*body, b"default=true named_default=true unknown_is_none=true", + "synthesised one-id registry: default + named-`default` resolve; unknown id misses" + ); } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn service_without_config_store_handle_still_works() { let router = RouterService::builder() .get("/no-config", |ctx: RequestContext| async move { - let has_config = ctx.config_store().is_some(); + // Hard-cutoff: with no handle and no + // registry wired, the registry-aware accessor + // returns None — same observable result as the + // legacy `config_handle().is_some()` check. + let has_config = ctx.config_store_default().is_some(); let response = response_builder() .status(StatusCode::OK) .body(Body::from(format!("has_config={has_config}"))) @@ -239,10 +472,8 @@ mod tests { let response = service.ready().await.unwrap().call(request).await.unwrap(); assert_eq!(response.status(), StatusCode::OK); - let body = axum::body::to_bytes(response.into_body(), usize::MAX) - .await - .unwrap(); - assert_eq!(&body[..], b"has_config=false"); + let body = to_bytes(response.into_body(), usize::MAX).await.unwrap(); + assert_eq!(&*body, b"has_config=false"); } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] @@ -251,20 +482,28 @@ mod tests { use edgezero_core::secret_store::{InMemorySecretStore, SecretHandle}; use std::sync::Arc; + // Hard-cutoff: the service synthesises a one-id + // `SecretRegistry` from `with_secret_handle`, binding the + // handle under the platform store name `"default"`. The + // fixture keys mirror that bound name (`"default/"`) + // so the registry-aware lookup resolves. let handle = SecretHandle::new(Arc::new(InMemorySecretStore::new([( - "env/__EDGEZERO_SERVICE_TEST_SECRET__", + "default/__EDGEZERO_SERVICE_TEST_SECRET__", Bytes::from("injected_value"), )]))); let router = RouterService::builder() .get("/check", |ctx: RequestContext| async move { + // `BoundSecretStore::get_bytes(key)` is single-arg — + // the platform store name is bound by the + // dispatcher's synthesis. let secrets = ctx - .secret_handle() - .expect("secret handle should be present"); + .secret_store_default() + .expect("secret store should be present"); let val = secrets - .get_bytes("env", "__EDGEZERO_SERVICE_TEST_SECRET__") + .get_bytes("__EDGEZERO_SERVICE_TEST_SECRET__") .await .unwrap() - .map(|b| String::from_utf8_lossy(&b).into_owned()) + .map(|bytes| String::from_utf8_lossy(&bytes).into_owned()) .unwrap_or_default(); let response = response_builder() .status(StatusCode::OK) @@ -281,17 +520,17 @@ mod tests { .unwrap(); let response = service.ready().await.unwrap().call(request).await.unwrap(); assert_eq!(response.status(), StatusCode::OK); - let body = axum::body::to_bytes(response.into_body(), usize::MAX) - .await - .unwrap(); - assert_eq!(&body[..], b"injected_value"); + let body = to_bytes(response.into_body(), usize::MAX).await.unwrap(); + assert_eq!(&*body, b"injected_value"); } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn service_without_kv_handle_still_works() { let router = RouterService::builder() .get("/no-kv", |ctx: RequestContext| async move { - let has_kv = ctx.kv_handle().is_some(); + // Hard-cutoff: see + // `service_without_config_store_handle_still_works`. + let has_kv = ctx.kv_store_default().is_some(); let response = response_builder() .status(StatusCode::OK) .body(Body::from(format!("has_kv={has_kv}"))) @@ -308,9 +547,134 @@ mod tests { let response = service.ready().await.unwrap().call(request).await.unwrap(); assert_eq!(response.status(), StatusCode::OK); - let body = axum::body::to_bytes(response.into_body(), usize::MAX) + let body = to_bytes(response.into_body(), usize::MAX).await.unwrap(); + assert_eq!(&*body, b"has_kv=false"); + } + + /// Two-id KV registry: `ctx.kv_store("sessions")` and + /// `ctx.kv_store("cache")` must each resolve to their own backing store. + /// `ctx.kv_store_default()` must resolve to the registered default id. + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn with_kv_registry_resolves_named_and_default() { + use crate::key_value_store::PersistentKvStore; + use edgezero_core::store_registry::{KvRegistry, StoreRegistry}; + use std::collections::BTreeMap; + + let temp_dir = tempfile::tempdir().unwrap(); + + let sessions_store: Arc = + Arc::new(PersistentKvStore::new(temp_dir.path().join("sessions.redb")).unwrap()); + let sessions_handle = KvHandle::new(Arc::clone(&sessions_store)); + sessions_handle + .put("greeting", &"hello-from-sessions") .await .unwrap(); - assert_eq!(&body[..], b"has_kv=false"); + + let cache_store: Arc = + Arc::new(PersistentKvStore::new(temp_dir.path().join("cache.redb")).unwrap()); + let cache_handle = KvHandle::new(Arc::clone(&cache_store)); + cache_handle + .put("greeting", &"hello-from-cache") + .await + .unwrap(); + + let by_id: BTreeMap = [ + ("sessions".to_owned(), sessions_handle), + ("cache".to_owned(), cache_handle), + ] + .into_iter() + .collect(); + let registry: KvRegistry = StoreRegistry::new(by_id, "sessions".to_owned()); + + let router = RouterService::builder() + .get("/named/{id}", |ctx: RequestContext| async move { + let id = ctx + .path_params() + .get("id") + .map(ToOwned::to_owned) + .unwrap_or_default(); + let store = ctx + .kv_store(&id) + .ok_or_else(|| EdgeError::not_found(format!("kv id `{id}` not registered")))?; + let value: String = store.get_or("greeting", String::new()).await.unwrap(); + let response = response_builder() + .status(StatusCode::OK) + .body(Body::from(value)) + .expect("response"); + Ok::<_, EdgeError>(response) + }) + .get("/default", |ctx: RequestContext| async move { + let store = ctx + .kv_store_default() + .expect("default kv store is registered"); + let value: String = store.get_or("greeting", String::new()).await.unwrap(); + let response = response_builder() + .status(StatusCode::OK) + .body(Body::from(value)) + .expect("response"); + Ok::<_, EdgeError>(response) + }) + .build(); + let service = EdgeZeroAxumService::new(router).with_kv_registry(registry); + + assert_eq!( + body_at(&service, "/named/sessions").await, + "hello-from-sessions" + ); + assert_eq!(body_at(&service, "/named/cache").await, "hello-from-cache"); + assert_eq!(body_at(&service, "/default").await, "hello-from-sessions"); + } + + /// Unknown ids on a wired registry yield `None` — strict lookup, no + /// fallback to the default. The handler returns 404 in that case. + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn kv_registry_lookup_is_strict_for_unknown_ids() { + use crate::key_value_store::PersistentKvStore; + use edgezero_core::store_registry::{KvRegistry, StoreRegistry}; + use std::collections::BTreeMap; + + let temp_dir = tempfile::tempdir().unwrap(); + let only_store: Arc = + Arc::new(PersistentKvStore::new(temp_dir.path().join("only.redb")).unwrap()); + let only_handle = KvHandle::new(Arc::clone(&only_store)); + + let by_id: BTreeMap = + [("only".to_owned(), only_handle)].into_iter().collect(); + let registry: KvRegistry = StoreRegistry::new(by_id, "only".to_owned()); + + let router = RouterService::builder() + .get("/lookup/{id}", |ctx: RequestContext| async move { + let id = ctx + .path_params() + .get("id") + .map(ToOwned::to_owned) + .unwrap_or_default(); + let present = ctx.kv_store(&id).is_some(); + let response = response_builder() + .status(StatusCode::OK) + .body(Body::from(format!("present={present}"))) + .expect("response"); + Ok::<_, EdgeError>(response) + }) + .build(); + let service = EdgeZeroAxumService::new(router).with_kv_registry(registry); + + assert_eq!(body_at(&service, "/lookup/only").await, "present=true"); + assert_eq!(body_at(&service, "/lookup/missing").await, "present=false"); + } + + /// Send a GET request through `service` and return the response body as a UTF-8 string. + /// Lifted out of the registry-aware tests so each can stay flat (clippy + /// `items_after_statements` rejects nested `async fn` definitions). + async fn body_at(service: &EdgeZeroAxumService, path: &str) -> String { + let request = Request::builder() + .uri(path) + .body(AxumBody::empty()) + .unwrap(); + let mut svc = service.clone(); + let response = svc.ready().await.unwrap().call(request).await.unwrap(); + assert_eq!(response.status(), StatusCode::OK); + let body = to_bytes(response.into_body(), usize::MAX).await.unwrap(); + String::from_utf8(body.to_vec()).unwrap() } } diff --git a/crates/edgezero-adapter-axum/src/templates/Cargo.toml.hbs b/crates/edgezero-adapter-axum/src/templates/Cargo.toml.hbs index a41ca255..d8d120a1 100644 --- a/crates/edgezero-adapter-axum/src/templates/Cargo.toml.hbs +++ b/crates/edgezero-adapter-axum/src/templates/Cargo.toml.hbs @@ -4,6 +4,9 @@ version = "0.1.0" edition = "2021" publish = false +[lints] +workspace = true + [[bin]] name = "{{proj_axum}}" path = "src/main.rs" diff --git a/crates/edgezero-adapter-axum/src/templates/src/main.rs.hbs b/crates/edgezero-adapter-axum/src/templates/src/main.rs.hbs index 5a4b5329..a73eb876 100644 --- a/crates/edgezero-adapter-axum/src/templates/src/main.rs.hbs +++ b/crates/edgezero-adapter-axum/src/templates/src/main.rs.hbs @@ -1,8 +1,6 @@ -use {{proj_core_mod}}::App; +use edgezero_adapter_axum::dev_server::run_app; -fn main() { - if let Err(err) = edgezero_adapter_axum::run_app::(include_str!("../../../edgezero.toml")) { - eprintln!("axum adapter failed: {err}"); - std::process::exit(1); - } +fn main() -> anyhow::Result<()> { + run_app::<{{proj_core_mod}}::App>()?; + Ok(()) } diff --git a/crates/edgezero-adapter-axum/src/test_utils.rs b/crates/edgezero-adapter-axum/src/test_utils.rs index ce4e39d6..32ea67f6 100644 --- a/crates/edgezero-adapter-axum/src/test_utils.rs +++ b/crates/edgezero-adapter-axum/src/test_utils.rs @@ -1,16 +1,8 @@ -use std::ffi::OsString; +use std::env; +use std::ffi::{OsStr, OsString}; use std::sync::OnceLock; use tokio::sync::Mutex; -/// Returns a process-wide mutex used to serialize tests that mutate environment variables. -/// -/// Both `secret_store` and `service` tests share this lock to avoid data races across -/// test threads when setting or clearing environment variables. -pub fn env_guard() -> &'static Mutex<()> { - static GUARD: OnceLock> = OnceLock::new(); - GUARD.get_or_init(|| Mutex::new(())) -} - /// RAII guard that sets an environment variable for the duration of a test and /// restores the original value (or removes the variable) on drop. pub struct EnvOverride { @@ -19,25 +11,39 @@ pub struct EnvOverride { } impl EnvOverride { - pub fn set(key: &'static str, value: impl AsRef) -> Self { - let original = std::env::var_os(key); - std::env::set_var(key, value); + #[must_use] + #[inline] + pub fn clear(key: &'static str) -> Self { + let original = env::var_os(key); + env::remove_var(key); Self { key, original } } - pub fn clear(key: &'static str) -> Self { - let original = std::env::var_os(key); - std::env::remove_var(key); + #[inline] + pub fn set(key: &'static str, value: impl AsRef) -> Self { + let original = env::var_os(key); + env::set_var(key, value); Self { key, original } } } impl Drop for EnvOverride { + #[inline] fn drop(&mut self) { - if let Some(ref original) = self.original { - std::env::set_var(self.key, original); + if let Some(original) = &self.original { + env::set_var(self.key, original); } else { - std::env::remove_var(self.key); + env::remove_var(self.key); } } } + +/// Returns a process-wide mutex used to serialize tests that mutate environment variables. +/// +/// Both `secret_store` and `service` tests share this lock to avoid data races across +/// test threads when setting or clearing environment variables. +#[inline] +pub fn env_guard() -> &'static Mutex<()> { + static GUARD: OnceLock> = OnceLock::new(); + GUARD.get_or_init(|| Mutex::new(())) +} diff --git a/crates/edgezero-adapter-cloudflare/Cargo.toml b/crates/edgezero-adapter-cloudflare/Cargo.toml index 89a692ce..71deb247 100644 --- a/crates/edgezero-adapter-cloudflare/Cargo.toml +++ b/crates/edgezero-adapter-cloudflare/Cargo.toml @@ -5,6 +5,9 @@ version = { workspace = true } authors = { workspace = true } license = { workspace = true } +[lints] +workspace = true + [features] default = [] cloudflare = ["dep:worker", "dep:serde_json"] @@ -12,6 +15,9 @@ cli = [ "dep:edgezero-adapter", "edgezero-adapter/cli", "dep:ctor", + "dep:serde_json", + "dep:tempfile", + "dep:toml_edit", "dep:walkdir", ] @@ -30,11 +36,14 @@ futures-util = { workspace = true } log = { workspace = true } ctor = { workspace = true, optional = true } serde_json = { workspace = true, optional = true } +tempfile = { workspace = true, optional = true } +toml_edit = { workspace = true, optional = true } worker = { version = "0.8", default-features = false, features = ["http"], optional = true } walkdir = { workspace = true, optional = true } [dev-dependencies] edgezero-core = { path = "../edgezero-core", features = ["test-utils"] } +tempfile = { workspace = true } wasm-bindgen-test = "0.3" web-sys = { version = "0.3", features = [ "Window", diff --git a/crates/edgezero-adapter-cloudflare/src/cli.rs b/crates/edgezero-adapter-cloudflare/src/cli.rs index a84eaa40..3106b59d 100644 --- a/crates/edgezero-adapter-cloudflare/src/cli.rs +++ b/crates/edgezero-adapter-cloudflare/src/cli.rs @@ -1,135 +1,79 @@ +use std::collections::BTreeSet; +use std::env; use std::fs; +use std::io::ErrorKind; use std::path::{Path, PathBuf}; use std::process::Command; use ctor::ctor; use edgezero_adapter::cli_support::{ - find_manifest_upwards, find_workspace_root, path_distance, read_package_name, + find_manifest_upwards, find_workspace_root, path_distance, read_package_name, run_native_cli, +}; +use edgezero_adapter::registry::{ + register_adapter, Adapter, AdapterAction, AdapterPushContext, ProvisionStores, ResolvedStoreId, }; use edgezero_adapter::scaffold::{ register_adapter_blueprint, AdapterBlueprint, AdapterFileSpec, CommandTemplates, DependencySpec, LoggingDefaults, ManifestSpec, ReadmeInfo, TemplateRegistration, }; -use edgezero_adapter::{register_adapter, Adapter, AdapterAction}; use walkdir::WalkDir; -const TARGET_TRIPLE: &str = "wasm32-unknown-unknown"; - -pub fn build() -> Result { - let manifest = find_wrangler_manifest( - std::env::current_dir() - .map_err(|e| e.to_string())? - .as_path(), - )?; - let manifest_dir = manifest - .parent() - .ok_or_else(|| "wrangler manifest has no parent directory".to_string())?; - let cargo_manifest = manifest_dir.join("Cargo.toml"); - let crate_name = read_package_name(&cargo_manifest)?; - - let status = Command::new("cargo") - .args([ - "build", - "--release", - "--target", - TARGET_TRIPLE, - "--manifest-path", - cargo_manifest - .to_str() - .ok_or("invalid Cargo manifest path")?, - ]) - .status() - .map_err(|e| format!("failed to run cargo build: {e}"))?; - if !status.success() { - return Err(format!("cargo build failed with status {status}")); - } - - let workspace_root = find_workspace_root(manifest_dir); - let artifact = locate_artifact(&workspace_root, manifest_dir, &crate_name)?; - let pkg_dir = workspace_root.join("pkg"); - fs::create_dir_all(&pkg_dir) - .map_err(|e| format!("failed to create {}: {e}", pkg_dir.display()))?; - let dest = pkg_dir.join(format!("{}.wasm", crate_name.replace('-', "_"))); - fs::copy(&artifact, &dest) - .map_err(|e| format!("failed to copy artifact to {}: {e}", dest.display()))?; - - Ok(dest) -} - -pub fn deploy(extra_args: &[String]) -> Result<(), String> { - let manifest = find_wrangler_manifest( - std::env::current_dir() - .map_err(|e| e.to_string())? - .as_path(), - )?; - let manifest_dir = manifest - .parent() - .ok_or_else(|| "wrangler manifest has no parent directory".to_string())?; - let config = manifest - .to_str() - .ok_or_else(|| "invalid wrangler config path".to_string())?; - - let status = Command::new("wrangler") - .args(["deploy", "--config", config]) - .args(extra_args) - .current_dir(manifest_dir) - .status() - .map_err(|e| format!("failed to run wrangler CLI: {e}"))?; - if !status.success() { - return Err(format!("wrangler deploy failed with status {status}")); - } - - Ok(()) -} - -pub fn serve(extra_args: &[String]) -> Result<(), String> { - let manifest = find_wrangler_manifest( - std::env::current_dir() - .map_err(|e| e.to_string())? - .as_path(), - )?; - let manifest_dir = manifest - .parent() - .ok_or_else(|| "wrangler manifest has no parent directory".to_string())?; - let config = manifest - .to_str() - .ok_or_else(|| "invalid wrangler config path".to_string())?; - - let status = Command::new("wrangler") - .args(["dev", "--config", config]) - .args(extra_args) - .current_dir(manifest_dir) - .status() - .map_err(|e| format!("failed to run wrangler CLI: {e}"))?; - if !status.success() { - return Err(format!("wrangler dev failed with status {status}")); - } - - Ok(()) -} - -struct CloudflareCliAdapter; +static CLOUDFLARE_ADAPTER: CloudflareCliAdapter = CloudflareCliAdapter; -static CLOUDFLARE_TEMPLATE_REGISTRATIONS: &[TemplateRegistration] = &[ - TemplateRegistration { - name: "cf_Cargo_toml", - contents: include_str!("templates/Cargo.toml.hbs"), +static CLOUDFLARE_BLUEPRINT: AdapterBlueprint = AdapterBlueprint { + id: "cloudflare", + display_name: "Cloudflare Workers", + crate_suffix: "adapter-cloudflare", + dependency_crate: "edgezero-adapter-cloudflare", + dependency_repo_path: "crates/edgezero-adapter-cloudflare", + template_registrations: CLOUDFLARE_TEMPLATE_REGISTRATIONS, + files: CLOUDFLARE_FILE_SPECS, + extra_dirs: &["src", ".cargo"], + dependencies: CLOUDFLARE_DEPENDENCIES, + manifest: ManifestSpec { + manifest_filename: "wrangler.toml", + build_target: "wasm32-unknown-unknown", + build_profile: "release", + build_features: &["cloudflare"], }, - TemplateRegistration { - name: "cf_src_lib_rs", - contents: include_str!("templates/src/lib.rs.hbs"), + commands: CommandTemplates { + build: "wrangler build --cwd {crate_dir}", + deploy: "wrangler deploy --cwd {crate_dir}", + serve: "wrangler dev --cwd {crate_dir}", }, - TemplateRegistration { - name: "cf_src_main_rs", - contents: include_str!("templates/src/main.rs.hbs"), + logging: LoggingDefaults { + endpoint: None, + level: "info", + echo_stdout: None, }, - TemplateRegistration { - name: "cf_cargo_config_toml", - contents: include_str!("templates/.cargo/config.toml.hbs"), + readme: ReadmeInfo { + description: "{display} entrypoint.", + dev_heading: "{display} (local)", + dev_steps: &["`edgezero serve --adapter cloudflare`"], }, - TemplateRegistration { - name: "cf_wrangler_toml", - contents: include_str!("templates/wrangler.toml.hbs"), + run_module: "edgezero_adapter_cloudflare", +}; + +static CLOUDFLARE_DEPENDENCIES: &[DependencySpec] = &[ + DependencySpec { + key: "dep_edgezero_core_cloudflare", + repo_crate: "crates/edgezero-core", + fallback: "edgezero-core = { git = \"https://git@github.com/stackpop/edgezero.git\", package = \"edgezero-core\", default-features = false }", + features: &[], + }, + DependencySpec { + key: "dep_edgezero_adapter_cloudflare", + repo_crate: "crates/edgezero-adapter-cloudflare", + fallback: + "edgezero-adapter-cloudflare = { git = \"https://git@github.com/stackpop/edgezero.git\", package = \"edgezero-adapter-cloudflare\", default-features = false }", + features: &[], + }, + DependencySpec { + key: "dep_edgezero_adapter_cloudflare_wasm", + repo_crate: "crates/edgezero-adapter-cloudflare", + fallback: + "edgezero-adapter-cloudflare = { git = \"https://git@github.com/stackpop/edgezero.git\", package = \"edgezero-adapter-cloudflare\", default-features = false, features = [\"cloudflare\"] }", + features: &["cloudflare"], }, ]; @@ -156,92 +100,732 @@ static CLOUDFLARE_FILE_SPECS: &[AdapterFileSpec] = &[ }, ]; -static CLOUDFLARE_DEPENDENCIES: &[DependencySpec] = &[ - DependencySpec { - key: "dep_edgezero_core_cloudflare", - repo_crate: "crates/edgezero-core", - fallback: "edgezero-core = { git = \"https://git@github.com/stackpop/edgezero.git\", package = \"edgezero-core\", default-features = false }", - features: &[], - }, - DependencySpec { - key: "dep_edgezero_adapter_cloudflare", - repo_crate: "crates/edgezero-adapter-cloudflare", - fallback: - "edgezero-adapter-cloudflare = { git = \"https://git@github.com/stackpop/edgezero.git\", package = \"edgezero-adapter-cloudflare\", default-features = false }", - features: &[], - }, - DependencySpec { - key: "dep_edgezero_adapter_cloudflare_wasm", - repo_crate: "crates/edgezero-adapter-cloudflare", - fallback: - "edgezero-adapter-cloudflare = { git = \"https://git@github.com/stackpop/edgezero.git\", package = \"edgezero-adapter-cloudflare\", default-features = false, features = [\"cloudflare\"] }", - features: &["cloudflare"], +static CLOUDFLARE_TEMPLATE_REGISTRATIONS: &[TemplateRegistration] = &[ + TemplateRegistration { + name: "cf_Cargo_toml", + contents: include_str!("templates/Cargo.toml.hbs"), }, -]; - -static CLOUDFLARE_BLUEPRINT: AdapterBlueprint = AdapterBlueprint { - id: "cloudflare", - display_name: "Cloudflare Workers", - crate_suffix: "adapter-cloudflare", - dependency_crate: "edgezero-adapter-cloudflare", - dependency_repo_path: "crates/edgezero-adapter-cloudflare", - template_registrations: CLOUDFLARE_TEMPLATE_REGISTRATIONS, - files: CLOUDFLARE_FILE_SPECS, - extra_dirs: &["src", ".cargo"], - dependencies: CLOUDFLARE_DEPENDENCIES, - manifest: ManifestSpec { - manifest_filename: "wrangler.toml", - build_target: "wasm32-unknown-unknown", - build_profile: "release", - build_features: &["cloudflare"], + TemplateRegistration { + name: "cf_src_lib_rs", + contents: include_str!("templates/src/lib.rs.hbs"), }, - commands: CommandTemplates { - build: "wrangler build --cwd {crate_dir}", - deploy: "wrangler deploy --cwd {crate_dir}", - serve: "wrangler dev --cwd {crate_dir}", + TemplateRegistration { + name: "cf_src_main_rs", + contents: include_str!("templates/src/main.rs.hbs"), }, - logging: LoggingDefaults { - endpoint: None, - level: "info", - echo_stdout: None, + TemplateRegistration { + name: "cf_cargo_config_toml", + contents: include_str!("templates/.cargo/config.toml.hbs"), }, - readme: ReadmeInfo { - description: "{display} entrypoint.", - dev_heading: "{display} (local)", - dev_steps: &["`edgezero-cli serve --adapter cloudflare`"], + TemplateRegistration { + name: "cf_wrangler_toml", + contents: include_str!("templates/wrangler.toml.hbs"), }, - run_module: "edgezero_adapter_cloudflare", -}; +]; -static CLOUDFLARE_ADAPTER: CloudflareCliAdapter = CloudflareCliAdapter; +const TARGET_TRIPLE: &str = "wasm32-unknown-unknown"; -impl Adapter for CloudflareCliAdapter { - fn name(&self) -> &'static str { - "cloudflare" - } +const WRANGLER_INSTALL_HINT: &str = + "install the Cloudflare CLI (`npm install -g wrangler`) and try again"; +struct CloudflareCliAdapter; + +#[expect( + clippy::missing_trait_methods, + reason = "cloudflare has no validate_app_config_keys / validate_adapter_manifest / validate_typed_secrets requirements; those three trait defaults are intentionally inherited. `single_store_kinds` IS overridden below (returns `&[\"secrets\"]`)." +)] +impl Adapter for CloudflareCliAdapter { fn execute(&self, action: AdapterAction, args: &[String]) -> Result<(), String> { match action { - AdapterAction::Build => build().map(|artifact| { - println!( + // `wrangler` is the native sign-in surface for Cloudflare + // Workers. EdgeZero stores no credentials — this is a thin + // shell-out. + AdapterAction::AuthLogin => { + run_native_cli("wrangler", &["login"], WRANGLER_INSTALL_HINT) + } + AdapterAction::AuthLogout => { + run_native_cli("wrangler", &["logout"], WRANGLER_INSTALL_HINT) + } + AdapterAction::AuthStatus => { + run_native_cli("wrangler", &["whoami"], WRANGLER_INSTALL_HINT) + } + AdapterAction::Build => build(args).map(|artifact| { + log::info!( "[edgezero] Cloudflare build artifact -> {}", artifact.display() ); }), AdapterAction::Deploy => deploy(args), AdapterAction::Serve => serve(args), + other => Err(format!("cloudflare adapter does not support {other:?}")), } } + + fn name(&self) -> &'static str { + "cloudflare" + } + + fn provision( + &self, + manifest_root: &Path, + adapter_manifest_path: Option<&str>, + _component_selector: Option<&str>, + stores: &ProvisionStores<'_>, + dry_run: bool, + ) -> Result, String> { + //: KV ids and config ids both back to Cloudflare KV + // namespaces. Secrets are runtime-managed via + // `wrangler secret put` — provision is a no-op for them. + let Some(rel) = adapter_manifest_path else { + return Err( + "[adapters.cloudflare.adapter].manifest must point at wrangler.toml for provision" + .to_owned(), + ); + }; + let wrangler_path = manifest_root.join(rel); + + let mut out = Vec::new(); + for store in stores.kv.iter().chain(stores.config.iter()) { + let logical = &store.logical; + // The Cloudflare KV binding name is what the runtime + // calls `env.kv(...)` with -- it's resolved at request + // time from `EDGEZERO__STORES______NAME` + // (default = logical id). Provision must write the + // resolved PLATFORM name into wrangler.toml, otherwise + // the runtime will look up a binding the CLI never + // created. + let binding = &store.platform; + // Idempotency check BEFORE shelling out: if a + // [[kv_namespaces]] entry with `binding = ` + // is already present and has a real namespace id, skip. + // Without this guard a re-run of provision would invoke + // `wrangler kv namespace create` again and orphan the + // previously-created namespace -- wasting account quota. + // A placeholder id (anything that isn't a 32-char + // lowercase hex string, like the + // `local-dev-placeholder` the scaffold wrangler.toml + // writes) is treated as "not yet provisioned" so the + // entry gets rewritten with the real id. + // + // We deliberately do NOT cross-check the stored id + // against Cloudflare's API (e.g. by calling `wrangler + // kv namespace list` to confirm the id still exists). + // Verifying every entry on every provision run would + // add a network round-trip per id and require parsing + // yet another wrangler subcommand output. The skip + // line names the existing id explicitly so the operator + // can verify it themselves and, if the Cloudflare-side + // namespace was deleted out-of-band, remove the stale + // entry by hand before re-running provision. + let existing = existing_real_namespace_id(&wrangler_path, binding)?; + if let Some(existing_id) = existing { + out.push(format!( + "binding `{binding}` (logical id `{logical}`) already provisioned (id={existing_id} in {}); skipping. To force a fresh namespace: delete the [[kv_namespaces]] entry for binding `{binding}` AND run `wrangler kv namespace delete --namespace-id={existing_id}` (the old remote namespace lingers otherwise), then re-run provision.", + wrangler_path.display() + )); + continue; + } + if dry_run { + out.push(format!( + "would run `wrangler kv namespace create {binding}` and append [[kv_namespaces]] binding = \"{binding}\" to {} (logical id `{logical}`)", + wrangler_path.display() + )); + continue; + } + let namespace_id = create_kv_namespace(binding)?; + upsert_kv_namespace(&wrangler_path, binding, &namespace_id)?; + out.push(format!( + "created KV namespace `{binding}` (logical id `{logical}`, namespace id={namespace_id}); written to {}", + wrangler_path.display() + )); + } + for store in stores.secrets { + let logical = &store.logical; + let platform = &store.platform; + out.push(format!( + "cloudflare secret `{platform}` (logical id `{logical}`) is runtime-managed via `wrangler secret put`; nothing to provision" + )); + } + if out.is_empty() { + out.push("cloudflare has no declared stores to provision".to_owned()); + } + Ok(out) + } + + fn push_config_entries( + &self, + manifest_root: &Path, + adapter_manifest_path: Option<&str>, + _component_selector: Option<&str>, + store: &ResolvedStoreId, + entries: &[(String, String)], + _push_ctx: &AdapterPushContext<'_>, + dry_run: bool, + ) -> Result, String> { + //: read namespace id from wrangler.toml (matched by + // `binding = `), then `wrangler kv bulk put + // --namespace-id=`. Keys in dotted + // form — the CLI already flattened them. + let Some(rel) = adapter_manifest_path else { + return Err( + "[adapters.cloudflare.adapter].manifest must point at wrangler.toml for config push" + .to_owned(), + ); + }; + let wrangler_path = manifest_root.join(rel); + let binding = store.platform.as_str(); + let logical = store.logical.as_str(); + // Dry-run is lenient about a missing/unresolved binding so + // operators can preview the keyset BEFORE running provision. + // Real runs still err loudly so we don't silently push to + // a non-existent namespace. + if dry_run { + let header = find_namespace_id(&wrangler_path, binding).map_or_else( + |_| format!( + "would run `wrangler kv bulk put --namespace-id=` with {} entries for binding `{binding}` (logical id `{logical}`, binding not yet provisioned -- run `edgezero provision --adapter cloudflare` to resolve the namespace id)", + entries.len() + ), + |ns_id| format!( + "would run `wrangler kv bulk put --namespace-id={ns_id}` with {} entries for binding `{binding}` (logical id `{logical}`)", + entries.len() + ), + ); + let mut out = vec![header]; + for (key, _) in entries { + out.push(format!(" would create entry `{key}`")); + } + return Ok(out); + } + let namespace_id = find_namespace_id(&wrangler_path, binding)?; + if entries.is_empty() { + return Ok(vec![format!( + "no config entries to push to KV namespace `{binding}` (logical id `{logical}`, id={namespace_id})" + )]); + } + let payload = bulk_payload(entries)?; + let temp = tempfile::Builder::new() + .prefix("edgezero-cf-push-") + .suffix(".json") + .tempfile() + .map_err(|err| { + format!("failed to create temp file for wrangler bulk payload: {err}") + })?; + fs::write(temp.path(), payload.as_bytes()) + .map_err(|err| format!("failed to write {}: {err}", temp.path().display()))?; + let temp_arg = temp + .path() + .to_str() + .ok_or_else(|| format!("temp file path {} is not UTF-8", temp.path().display()))?; + let namespace_arg = format!("--namespace-id={namespace_id}"); + let output = Command::new("wrangler") + .args(["kv", "bulk", "put", temp_arg, namespace_arg.as_str()]) + .output() + .map_err(|err| { + if err.kind() == ErrorKind::NotFound { + format!("`wrangler` not found on PATH; {WRANGLER_INSTALL_HINT}") + } else { + format!("failed to spawn `wrangler`: {err}") + } + })?; + if !output.status.success() { + return Err(format!( + "`wrangler kv bulk put` exited with status {}\nstderr: {}", + output.status, + String::from_utf8_lossy(&output.stderr).trim() + )); + } + Ok(vec![format!( + "pushed {} entries to KV namespace `{binding}` (logical id `{logical}`, id={namespace_id})", + entries.len() + )]) + } + + fn push_config_entries_local( + &self, + manifest_root: &Path, + adapter_manifest_path: Option<&str>, + _component_selector: Option<&str>, + store: &ResolvedStoreId, + entries: &[(String, String)], + _push_ctx: &AdapterPushContext<'_>, + dry_run: bool, + ) -> Result, String> { + // Same flow as the prod push but with `--local` appended to + // the wrangler invocation. Wrangler writes the entries into + // `.wrangler/state//kv//...` so a follow-up + // `wrangler dev --local` (or `edgezero serve --adapter + // cloudflare`) reads them from the local emulator instead + // of the live account. + let Some(rel) = adapter_manifest_path else { + return Err( + "[adapters.cloudflare.adapter].manifest must point at wrangler.toml for config push --local" + .to_owned(), + ); + }; + let wrangler_path = manifest_root.join(rel); + let binding = store.platform.as_str(); + let logical = store.logical.as_str(); + if dry_run { + let header = find_namespace_id(&wrangler_path, binding).map_or_else( + |_| format!( + "would run `wrangler kv bulk put --namespace-id= --local` with {} entries for binding `{binding}` (logical id `{logical}`, binding not yet provisioned -- run `edgezero provision --adapter cloudflare` to resolve the namespace id)", + entries.len() + ), + |ns_id| format!( + "would run `wrangler kv bulk put --namespace-id={ns_id} --local` with {} entries for binding `{binding}` (logical id `{logical}`)", + entries.len() + ), + ); + let mut out = vec![header]; + for (key, _) in entries { + out.push(format!(" would create local entry `{key}`")); + } + return Ok(out); + } + let namespace_id = find_namespace_id(&wrangler_path, binding)?; + if entries.is_empty() { + return Ok(vec![format!( + "no config entries to push to local KV namespace `{binding}` (logical id `{logical}`, id={namespace_id})" + )]); + } + let payload = bulk_payload(entries)?; + let temp = tempfile::Builder::new() + .prefix("edgezero-cf-push-local-") + .suffix(".json") + .tempfile() + .map_err(|err| { + format!("failed to create temp file for wrangler bulk payload: {err}") + })?; + fs::write(temp.path(), payload.as_bytes()) + .map_err(|err| format!("failed to write {}: {err}", temp.path().display()))?; + let temp_arg = temp + .path() + .to_str() + .ok_or_else(|| format!("temp file path {} is not UTF-8", temp.path().display()))?; + let namespace_arg = format!("--namespace-id={namespace_id}"); + let output = Command::new("wrangler") + .args([ + "kv", + "bulk", + "put", + temp_arg, + namespace_arg.as_str(), + "--local", + ]) + .output() + .map_err(|err| { + if err.kind() == ErrorKind::NotFound { + format!("`wrangler` not found on PATH; {WRANGLER_INSTALL_HINT}") + } else { + format!("failed to spawn `wrangler`: {err}") + } + })?; + if !output.status.success() { + return Err(format!( + "`wrangler kv bulk put --local` exited with status {}\nstderr: {}", + output.status, + String::from_utf8_lossy(&output.stderr).trim() + )); + } + Ok(vec![format!( + "pushed {} entries to local KV namespace `{binding}` (logical id `{logical}`, id={namespace_id}); `.wrangler/state` updated", + entries.len() + )]) + } + + fn single_store_kinds(&self) -> &'static [&'static str] { + //: cloudflare is Multi for KV (KV namespaces) and + // Config (KV namespaces), Single for Secrets (Worker + // Secrets is a single flat bag). + &["secrets"] + } } -pub fn register() { - register_adapter(&CLOUDFLARE_ADAPTER); - register_adapter_blueprint(&CLOUDFLARE_BLUEPRINT); +/// Shell out to `wrangler kv namespace create `, capture +/// stdout, and parse the resulting namespace id. The CLI's +/// `provision` command resolves this against the user's +/// `wrangler.toml` and writes the `[[kv_namespaces]]` entry. +/// +/// # Errors +/// Returns an error if `wrangler` isn't on `PATH`, the child fails +/// to spawn, the exit status is non-zero, or stdout doesn't +/// include a parseable `id = "..."` line. +fn create_kv_namespace(binding: &str) -> Result { + let output = Command::new("wrangler") + .args(["kv", "namespace", "create", binding]) + .output() + .map_err(|err| { + if err.kind() == ErrorKind::NotFound { + format!("`wrangler` not found on PATH; {WRANGLER_INSTALL_HINT}") + } else { + format!("failed to spawn `wrangler`: {err}") + } + })?; + if !output.status.success() { + return Err(format!( + "`wrangler kv namespace create {binding}` exited with status {}\nstderr: {}", + output.status, + String::from_utf8_lossy(&output.stderr).trim() + )); + } + let stdout = String::from_utf8_lossy(&output.stdout); + extract_namespace_id(&stdout).ok_or_else(|| { + format!( + "wrangler created `{binding}` but stdout did not include a parseable `id = \"...\"` line -- wrangler may have changed its output format; pin a known-compatible wrangler version or file an issue. Raw stdout:\n{stdout}" + ) + }) } -#[ctor] -fn register_ctor() { - register(); +/// Pull the namespace id out of `wrangler kv namespace create` +/// stdout. Wrangler 3+ prints (something like): +/// +/// ```text +/// 🌀 Creating namespace with title "..." +/// ✨ Success! +/// Add the following to your configuration file in your kv_namespaces array: +/// [[kv_namespaces]] +/// binding = "my-kv" +/// id = "abc123..." +/// ``` +/// +/// We tolerate leading whitespace + surrounding decoration. To +/// avoid grabbing a stray informational line like +/// `id = ""` printed somewhere else in wrangler +/// output (or a hypothetical future `id = ...` line that names a +/// non-KV resource), we anchor to the `[[kv_namespaces]]` table +/// header AND require the value to be 32-char lowercase hex +/// (Cloudflare's actual namespace-id shape). The scan walks +/// lines top-down: when we see `[[kv_namespaces]]` we set a +/// scope flag; the next `id = "<32-char-hex>"` line within that +/// scope is the result. A new top-level header resets the scope. +fn extract_namespace_id(stdout: &str) -> Option { + let mut in_kv_namespaces = false; + for line in stdout.lines() { + let trimmed = line.trim(); + if trimmed == "[[kv_namespaces]]" { + in_kv_namespaces = true; + continue; + } + // Any other table header ends the scope so we don't reach + // forward into a sibling block. + if trimmed.starts_with('[') && trimmed.ends_with(']') { + in_kv_namespaces = false; + continue; + } + if !in_kv_namespaces { + continue; + } + let Some(after_id_kw) = trimmed.strip_prefix("id") else { + continue; + }; + let Some(after_eq) = after_id_kw.trim_start().strip_prefix('=') else { + continue; + }; + let Some(quoted) = after_eq.trim_start().strip_prefix('"') else { + continue; + }; + let Some((id, _)) = quoted.split_once('"') else { + continue; + }; + if is_real_namespace_id(id) { + return Some(id.to_owned()); + } + } + None +} + +/// Heuristic: is `id` a real Cloudflare KV namespace id (32-char +/// lowercase hex), as opposed to a scaffold placeholder like +/// `local-dev-placeholder`? Cloudflare's API consistently returns +/// 32-char lowercase hex, so we use that as a tight cheap signal. +/// +/// Additionally rejects hex-shape sentinels that LOOK like real +/// ids but are obviously hand-typed placeholders: anything with +/// fewer than 6 distinct hex characters (catches all-zeros, +/// all-`a`, `deadbeefdeadbeefdeadbeefdeadbeef`, etc.). A real id +/// generated by Cloudflare's API has effectively uniform random +/// hex distribution: expected distinct chars over 32 draws from +/// 16 symbols is ~14, and the dominant term P(=5 distinct) is on +/// the order of 10^-13 -- so false rejections of real ids are +/// astronomically unlikely. +fn is_real_namespace_id(id: &str) -> bool { + if id.len() != 32 { + return false; + } + if !id + .bytes() + .all(|byte| byte.is_ascii_hexdigit() && !byte.is_ascii_uppercase()) + { + return false; + } + // Distinct-byte count via a BTreeSet: 32 inserts is trivial, + // and the set form avoids the arithmetic-side-effect / + // silent-as / indexing-panic shapes the project's clippy + // profile rejects. + let distinct: BTreeSet = id.bytes().collect(); + distinct.len() >= 6 +} + +/// If `path` already declares a `[[kv_namespaces]]` entry with +/// `binding = binding` AND its `id` looks like a real Cloudflare +/// namespace id, return that id. Returns `Ok(None)` if the binding +/// is absent OR present with a placeholder id (so provision can +/// treat both cases as "needs (re-)create"). A failure to read / +/// parse the file is a hard error -- provision needs an authoritative +/// answer. +fn existing_real_namespace_id(path: &Path, binding: &str) -> Result, String> { + let Some(existing) = read_namespace_id(path, binding)? else { + return Ok(None); + }; + if is_real_namespace_id(&existing) { + Ok(Some(existing)) + } else { + Ok(None) + } +} + +/// Internal: look up `binding`'s `id` in `wrangler.toml` without +/// the "did you run provision?" error path that `find_namespace_id` +/// adds. Missing file -> `Ok(None)`. Returns the raw id whether or +/// not it looks like a real Cloudflare id. +/// +/// Errors loudly if `kv_namespaces` exists but is neither an +/// array-of-tables nor an inline-array (e.g. the operator typed +/// `kv_namespaces = "oops"`). Silently returning `None` there +/// surfaces downstream as "did you run provision?" -- misleading, +/// because the actual problem is a malformed manifest. +fn read_namespace_id(path: &Path, binding: &str) -> Result, String> { + use toml_edit::{DocumentMut, Item, Value}; + + let raw = match fs::read_to_string(path) { + Ok(raw) => raw, + Err(err) if err.kind() == ErrorKind::NotFound => return Ok(None), + Err(err) => return Err(format!("failed to read {}: {err}", path.display())), + }; + let doc: DocumentMut = raw + .parse() + .map_err(|err| format!("failed to parse {}: {err}", path.display()))?; + let id = match doc.get("kv_namespaces") { + Some(Item::ArrayOfTables(arr)) => arr.iter().find_map(|table| { + if table.get("binding").and_then(Item::as_str) == Some(binding) { + table.get("id").and_then(Item::as_str).map(str::to_owned) + } else { + None + } + }), + Some(Item::Value(Value::Array(arr))) => arr.iter().find_map(|item| { + let table = item.as_inline_table()?; + if table.get("binding").and_then(Value::as_str) == Some(binding) { + table.get("id").and_then(Value::as_str).map(str::to_owned) + } else { + None + } + }), + Some(other) => { + return Err(format!( + "{}: `kv_namespaces` exists but is neither `[[kv_namespaces]]` (array-of-tables) nor an inline array of `{{ binding, id }}` records; got TOML item of type `{}`", + path.display(), + item_kind(other) + )); + } + None => None, + }; + Ok(id) +} + +/// One-line label for a `toml_edit::Item` (for diagnostic +/// messages -- not a canonical TOML type description). +fn item_kind(item: &toml_edit::Item) -> &'static str { + use toml_edit::{Item, Value}; + match item { + Item::None => "none", + Item::Value(Value::String(_)) => "string", + Item::Value(Value::Integer(_)) => "integer", + Item::Value(Value::Float(_)) => "float", + Item::Value(Value::Boolean(_)) => "boolean", + Item::Value(Value::Datetime(_)) => "datetime", + Item::Value(Value::Array(_)) => "array", + Item::Value(Value::InlineTable(_)) => "inline-table", + Item::Table(_) => "table", + Item::ArrayOfTables(_) => "array-of-tables", + } +} + +/// Insert OR update the `[[kv_namespaces]]` entry for `binding`, +/// rewriting `id` if the binding already exists (e.g. provision +/// is replacing a `local-dev-placeholder`). Used by provision so +/// re-running on a scaffolded wrangler.toml replaces the placeholder +/// with the real id instead of silently skipping. +/// +/// Caveat: `toml_edit::Table::insert` replaces the value's `Item`, +/// which drops any trailing inline comment that was attached to +/// the prior `id = "..."` line (e.g. `id = "old" # delete me`). +/// Sibling fields under the same `[[kv_namespaces]]` table are +/// preserved verbatim -- only the `id` line's decor is lost. +/// +/// Concurrency: provision is NOT safe to run concurrently against +/// the same `wrangler.toml`. Two concurrent runs may both miss the +/// idempotency check, both call `wrangler kv namespace create` +/// remotely, then race the file write -- the loser's namespace +/// becomes an orphan in the Cloudflare account. `EdgeZero` does not +/// take a lockfile; operators must serialise provision themselves. +fn upsert_kv_namespace(path: &Path, binding: &str, id: &str) -> Result<(), String> { + use toml_edit::{value, ArrayOfTables, DocumentMut, Item, Table}; + + // Treat NotFound as "start with empty document" symmetrically with + // `read_namespace_id` so the orphan-namespace hazard goes away: if + // wrangler.toml is missing entirely (e.g. operator deleted it + // between scaffold and provision), the upsert that follows a + // successful `wrangler kv namespace create` would otherwise error + // out, leaving the remote namespace orphaned. + let raw = match fs::read_to_string(path) { + Ok(text) => text, + Err(err) if err.kind() == ErrorKind::NotFound => String::new(), + Err(err) => return Err(format!("failed to read {}: {err}", path.display())), + }; + let mut doc: DocumentMut = raw + .parse() + .map_err(|err| format!("failed to parse {}: {err}", path.display()))?; + + let entry = doc + .entry("kv_namespaces") + .or_insert_with(|| Item::ArrayOfTables(ArrayOfTables::new())); + let arr_of_tables = entry.as_array_of_tables_mut().ok_or_else(|| { + format!( + "{}: `kv_namespaces` exists but is not an array-of-tables (`[[kv_namespaces]]`); convert it manually before re-running provision", + path.display() + ) + })?; + + let existing_idx = arr_of_tables + .iter() + .position(|table| table.get("binding").and_then(Item::as_str) == Some(binding)); + if let Some(idx) = existing_idx { + if let Some(existing) = arr_of_tables.get_mut(idx) { + existing.insert("id", value(id)); + } + } else { + let mut new_table = Table::new(); + new_table.insert("binding", value(binding)); + new_table.insert("id", value(id)); + arr_of_tables.push(new_table); + } + + fs::write(path, doc.to_string()) + .map_err(|err| format!("failed to write {}: {err}", path.display()))?; + Ok(()) +} + +/// Render the entries as the `[{"key": "...", "value": "..."}, …]` +/// JSON wrangler expects for `kv bulk put`. Keys arrive pre-flattened +/// from the CLI (dotted form,); cloudflare passes them through. +fn bulk_payload(entries: &[(String, String)]) -> Result { + let payload: Vec = entries + .iter() + .map(|(key, value)| serde_json::json!({ "key": key, "value": value })) + .collect(); + serde_json::to_string(&payload) + .map_err(|err| format!("failed to serialize wrangler bulk payload: {err}")) +} + +/// # Errors +/// Returns an error if the Cloudflare wrangler build command fails. +#[inline] +pub fn build(extra_args: &[String]) -> Result { + let manifest = + find_wrangler_manifest(env::current_dir().map_err(|err| err.to_string())?.as_path())?; + let manifest_dir = manifest + .parent() + .ok_or_else(|| "wrangler manifest has no parent directory".to_owned())?; + let cargo_manifest = manifest_dir.join("Cargo.toml"); + let crate_name = read_package_name(&cargo_manifest)?; + + let status = Command::new("cargo") + .args([ + "build", + "--release", + "--target", + TARGET_TRIPLE, + "--manifest-path", + cargo_manifest + .to_str() + .ok_or("invalid Cargo manifest path")?, + ]) + .args(extra_args) + .status() + .map_err(|err| format!("failed to run cargo build: {err}"))?; + if !status.success() { + return Err(format!("cargo build failed with status {status}")); + } + + let workspace_root = find_workspace_root(manifest_dir); + let artifact = locate_artifact(&workspace_root, manifest_dir, &crate_name)?; + let pkg_dir = workspace_root.join("pkg"); + fs::create_dir_all(&pkg_dir) + .map_err(|err| format!("failed to create {}: {err}", pkg_dir.display()))?; + let dest = pkg_dir.join(format!("{}.wasm", crate_name.replace('-', "_"))); + fs::copy(&artifact, &dest) + .map_err(|err| format!("failed to copy artifact to {}: {err}", dest.display()))?; + + Ok(dest) +} + +/// # Errors +/// Returns an error if the Cloudflare wrangler deploy command fails. +#[inline] +pub fn deploy(extra_args: &[String]) -> Result<(), String> { + let manifest = + find_wrangler_manifest(env::current_dir().map_err(|err| err.to_string())?.as_path())?; + let manifest_dir = manifest + .parent() + .ok_or_else(|| "wrangler manifest has no parent directory".to_owned())?; + let config = manifest + .to_str() + .ok_or_else(|| "invalid wrangler config path".to_owned())?; + + let status = Command::new("wrangler") + .args(["deploy", "--config", config]) + .args(extra_args) + .current_dir(manifest_dir) + .status() + .map_err(|err| format!("failed to run wrangler CLI: {err}"))?; + if !status.success() { + return Err(format!("wrangler deploy failed with status {status}")); + } + + Ok(()) +} + +/// Look up the namespace id wrangler.toml has bound to `binding`, +/// rejecting placeholder ids (anything that isn't a 32-char +/// lowercase hex Cloudflare API id). +/// +/// Accepts both `[[kv_namespaces]]` (array-of-tables, what +/// `provision` writes and wrangler's own post-create hint prints) +/// and the inline-array form. Returns Err with a "did you run +/// provision?" hint if the binding is absent OR holds a placeholder +/// like `local-dev-placeholder` — without this check `push` would +/// shell out to `wrangler kv bulk put --namespace-id=`, +/// which fails at wrangler with a less actionable error. +fn find_namespace_id(wrangler_path: &Path, binding: &str) -> Result { + // read_namespace_id returns Ok(None) for both + // missing-file AND binding-not-present; for `find_namespace_id` + // the user wants a "did you run provision?" hint in both cases, + // so collapse them into the same error message. + let raw = read_namespace_id(wrangler_path, binding)?.ok_or_else(|| { + format!( + "{}: no [[kv_namespaces]] entry with binding = {binding:?} (did you run `edgezero provision --adapter cloudflare`?)", + wrangler_path.display() + ) + })?; + if is_real_namespace_id(&raw) { + Ok(raw) + } else { + Err(format!( + "{}: binding {binding:?} has id {raw:?}, which doesn't look like a real Cloudflare KV namespace id (expected 32-char lowercase hex). This is usually a scaffold placeholder -- run `edgezero provision --adapter cloudflare` to create a real namespace and overwrite the entry.", + wrangler_path.display() + )) + } } fn find_wrangler_manifest(start: &Path) -> Result { @@ -257,18 +841,15 @@ fn find_wrangler_manifest(start: &Path) -> Result { .filter_map(Result::ok) .map(|entry| entry.path().to_path_buf()) .filter(|path| { - path.file_name() - .map(|n| n == "wrangler.toml") - .unwrap_or(false) + path.file_name().is_some_and(|n| n == "wrangler.toml") && path .parent() - .map(|dir| dir.join("Cargo.toml").exists()) - .unwrap_or(false) + .is_some_and(|dir| dir.join("Cargo.toml").exists()) }) .collect(); if candidates.is_empty() { - return Err("could not locate wrangler.toml".to_string()); + return Err("could not locate wrangler.toml".to_owned()); } candidates.sort_by_key(|path| { @@ -286,7 +867,7 @@ fn locate_artifact( ) -> Result { let release_name = format!("{}.wasm", crate_name.replace('-', "_")); - if let Some(custom) = std::env::var_os("CARGO_TARGET_DIR") { + if let Some(custom) = env::var_os("CARGO_TARGET_DIR") { let candidate = PathBuf::from(custom) .join(TARGET_TRIPLE) .join("release") @@ -315,7 +896,768 @@ fn locate_artifact( } Err(format!( - "compiled artifact not found for {} (looked in manifest and workspace target directories)", - crate_name + "compiled artifact not found for {crate_name} (looked in manifest and workspace target directories)" )) } + +#[inline] +pub fn register() { + register_adapter(&CLOUDFLARE_ADAPTER); + register_adapter_blueprint(&CLOUDFLARE_BLUEPRINT); +} + +#[ctor(unsafe)] +fn register_ctor() { + register(); +} + +/// # Errors +/// Returns an error if the Cloudflare wrangler dev command fails. +#[inline] +pub fn serve(extra_args: &[String]) -> Result<(), String> { + let manifest = + find_wrangler_manifest(env::current_dir().map_err(|err| err.to_string())?.as_path())?; + let manifest_dir = manifest + .parent() + .ok_or_else(|| "wrangler manifest has no parent directory".to_owned())?; + let config = manifest + .to_str() + .ok_or_else(|| "invalid wrangler config path".to_owned())?; + + let status = Command::new("wrangler") + .args(["dev", "--config", config]) + .args(extra_args) + .current_dir(manifest_dir) + .status() + .map_err(|err| format!("failed to run wrangler CLI: {err}"))?; + if !status.success() { + return Err(format!("wrangler dev failed with status {status}")); + } + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::tempdir; + + // Shared fixture names. Pinning these as consts (instead of + // inline `"sessions"` / `"app_config"` per call site) keeps the + // setup-vs-assertion pair in sync -- a typo in one place no + // longer silently divorces from the other, because both reference + // the same const. Also names the intent: these are the LOGICAL + // store ids the cloudflare adapter operates on, not arbitrary + // strings. + const TEST_KV_ID: &str = "sessions"; + const TEST_KV_ID_ALT: &str = "cache"; + const TEST_CONFIG_ID: &str = "app_config"; + const TEST_SECRET_ID: &str = "default"; + + // ---------- extract_namespace_id ---------- + + #[test] + fn extract_namespace_id_parses_wrangler_3_output() { + // wrangler decorates these lines with unicode glyphs in real + // output; we drop them from the fixture to keep the source + // file ASCII-only (clippy::non_ascii_literal). The parser + // requires both the `[[kv_namespaces]]` anchor and a + // 32-char-lowercase-hex id. + let stdout = r#"Creating namespace with title "my-kv" +Success! +Add the following to your configuration file in your kv_namespaces array: +[[kv_namespaces]] +binding = "my-kv" +id = "00112233445566778899aabbccddeeff" +"#; + assert_eq!( + extract_namespace_id(stdout).as_deref(), + Some("00112233445566778899aabbccddeeff") + ); + } + + #[test] + fn extract_namespace_id_tolerates_extra_whitespace() { + let stdout = "[[kv_namespaces]]\n id = \"00112233445566778899aabbccddeeff\" \n"; + assert_eq!( + extract_namespace_id(stdout).as_deref(), + Some("00112233445566778899aabbccddeeff") + ); + } + + #[test] + fn extract_namespace_id_returns_none_on_missing_id_line() { + assert!(extract_namespace_id("nothing to see here").is_none()); + assert!(extract_namespace_id("").is_none()); + assert!( + extract_namespace_id("[[kv_namespaces]]\nid = \"\"").is_none(), + "empty value not a real id" + ); + } + + #[test] + fn extract_namespace_id_ignores_unrelated_lines_starting_with_id() { + // `identifier = "..."` doesn't match -- we strip exactly the + // prefix `id` then require `=`. Also doesn't match because + // there's no `[[kv_namespaces]]` anchor. + assert!(extract_namespace_id("[[kv_namespaces]]\nidentifier = \"x\"").is_none()); + } + + #[test] + fn extract_namespace_id_requires_kv_namespaces_anchor() { + // A bare `id = "<32-char-hex>"` line that isn't preceded by + // `[[kv_namespaces]]` must not match -- otherwise a future + // wrangler info line like `id = ""` printed + // somewhere else in stdout would be picked up as the + // namespace id and silently corrupt wrangler.toml on writeback. + let unanchored = "id = \"00112233445566778899aabbccddeeff\"\n"; + assert!(extract_namespace_id(unanchored).is_none()); + + // A different table header BEFORE the `id` line scopes us + // out of the kv-namespaces context. + let other_block = "[[d1_databases]]\nid = \"00112233445566778899aabbccddeeff\"\n"; + assert!(extract_namespace_id(other_block).is_none()); + } + + #[test] + fn extract_namespace_id_rejects_non_real_id_inside_kv_namespaces_anchor() { + // Even with the anchor, the value must look like a real + // Cloudflare id (32-char lowercase hex with the diversity + // floor). Shorter or non-hex values are skipped, not + // returned -- forces the operator to investigate stdout + // drift rather than silently writing a bogus id. + let stdout = "[[kv_namespaces]]\nbinding = \"my-kv\"\nid = \"abc123\"\n"; + assert!(extract_namespace_id(stdout).is_none()); + } + + fn write_wrangler(dir: &Path, contents: &str) -> PathBuf { + let path = dir.join("wrangler.toml"); + fs::write(&path, contents).expect("write wrangler.toml"); + path + } + + // ---------- is_real_namespace_id ---------- + + #[test] + fn is_real_namespace_id_accepts_32_char_lowercase_hex_with_sufficient_diversity() { + // 16-distinct-char fixture: maximum diversity. + assert!(is_real_namespace_id("00112233445566778899aabbccddeeff")); + // Realistic randomish fixture: 14 distinct chars. + assert!(is_real_namespace_id("4a8f3c2b9e1d5670adef2839c4b6e1f0")); + } + + #[test] + fn is_real_namespace_id_rejects_placeholder_or_short_id() { + assert!(!is_real_namespace_id("local-dev-placeholder")); + assert!(!is_real_namespace_id("abc123")); + assert!(!is_real_namespace_id("")); + } + + #[test] + fn is_real_namespace_id_rejects_uppercase_or_non_hex() { + // Uppercase rejected: Cloudflare's API returns lowercase. + assert!(!is_real_namespace_id("00112233445566778899AABBCCDDEEFF")); + // Non-hex digits rejected. + assert!(!is_real_namespace_id("z0112233445566778899aabbccddeeff")); + } + + #[test] + fn is_real_namespace_id_rejects_hex_shape_sentinels() { + // 32-char lowercase hex but obvious hand-typed placeholder: + // distinct-hex-digit count is below the diversity floor. + // Real Cloudflare ids have effectively uniform random hex, + // so collisions with this guard are astronomical. + assert!( + !is_real_namespace_id("00000000000000000000000000000000"), + "all-zeros rejected" + ); + assert!( + !is_real_namespace_id("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"), + "all-a rejected" + ); + assert!( + !is_real_namespace_id("deadbeefdeadbeefdeadbeefdeadbeef"), + "deadbeef rejected (only 5 distinct chars: d,e,a,b,f)" + ); + // Boundary: a real-looking id with the diversity floor or + // more must still pass. + assert!( + is_real_namespace_id("00112233445566778899aabbccddeeff"), + "16-distinct-char fixture must still pass" + ); + // Exactly 6 distinct chars (a,b,c,d,e,f): on the boundary, + // must pass. + assert!( + is_real_namespace_id("aabbccddeeffaabbccddeeffaabbccdd"), + "6-distinct-char fixture (boundary) passes" + ); + } + + // ---------- read_namespace_id ---------- + + #[test] + fn read_namespace_id_errors_when_kv_namespaces_is_non_array_value() { + // `kv_namespaces = "oops"` is a malformed manifest. Silently + // returning None there bubbles up as "did you run provision?" + // -- a misleading error. The right surface is "manifest + // doesn't match the expected shape". + let dir = tempdir().expect("tempdir"); + let path = write_wrangler(dir.path(), "name = \"demo\"\nkv_namespaces = \"oops\"\n"); + let err = read_namespace_id(&path, TEST_CONFIG_ID) + .expect_err("non-array kv_namespaces must error"); + assert!( + err.contains("array-of-tables") || err.contains("inline array"), + "error names the expected shapes: {err}" + ); + assert!( + err.contains("string"), + "error names the offending kind: {err}" + ); + } + + // ---------- extract_namespace_id (pinning behaviour) ---------- + + #[test] + fn extract_namespace_id_returns_first_real_match_inside_kv_namespaces_anchor() { + // Pin: top-down scan, first qualifying line inside the + // `[[kv_namespaces]]` anchor wins. Real wrangler output has + // exactly one. A hypothetical future format with multiple + // qualifying lines would surface the earliest, but only + // values that look like real Cloudflare ids count. + let stdout = "[[kv_namespaces]]\n\ + id = \"00112233445566778899aabbccddeeff\"\n\ + id = \"ffeeddccbbaa99887766554433221100\"\n"; + assert_eq!( + extract_namespace_id(stdout).as_deref(), + Some("00112233445566778899aabbccddeeff") + ); + } + + // ---------- upsert_kv_namespace ---------- + + #[test] + fn upsert_kv_namespace_replaces_placeholder_id_for_existing_binding() { + let dir = tempdir().expect("tempdir"); + let path = write_wrangler( + dir.path(), + "[[kv_namespaces]]\nbinding = \"sessions\"\nid = \"local-dev-placeholder\"\n", + ); + upsert_kv_namespace(&path, TEST_KV_ID, "00112233445566778899aabbccddeeff").expect("upsert"); + let after = fs::read_to_string(&path).expect("read"); + assert!( + after.contains("id = \"00112233445566778899aabbccddeeff\""), + "placeholder replaced: {after}" + ); + assert!( + !after.contains("local-dev-placeholder"), + "placeholder removed: {after}" + ); + assert_eq!( + after.matches("binding = \"sessions\"").count(), + 1, + "no duplicate binding: {after}" + ); + } + + #[test] + fn upsert_kv_namespace_appends_when_binding_absent() { + let dir = tempdir().expect("tempdir"); + let path = write_wrangler(dir.path(), "name = \"demo\"\n"); + upsert_kv_namespace(&path, TEST_KV_ID, "00112233445566778899aabbccddeeff").expect("upsert"); + let after = fs::read_to_string(&path).expect("read"); + assert!( + after.contains("binding = \"sessions\"") + && after.contains("id = \"00112233445566778899aabbccddeeff\""), + "appended new entry: {after}" + ); + assert!( + after.contains("name = \"demo\""), + "preserved original keys: {after}" + ); + } + + #[test] + fn upsert_kv_namespace_appends_next_to_existing_entries() { + let dir = tempdir().expect("tempdir"); + let path = write_wrangler( + dir.path(), + "[[kv_namespaces]]\nbinding = \"cache\"\nid = \"old\"\n", + ); + upsert_kv_namespace(&path, TEST_KV_ID, "00112233445566778899aabbccddeeff").expect("upsert"); + let after = fs::read_to_string(&path).expect("read"); + assert!( + after.contains("binding = \"cache\"") && after.contains("id = \"old\""), + "existing entry kept: {after}" + ); + assert!( + after.contains("binding = \"sessions\""), + "new entry added: {after}" + ); + assert_eq!( + after.matches("[[kv_namespaces]]").count(), + 2, + "two entries: {after}" + ); + } + + #[test] + fn upsert_kv_namespace_preserves_top_comments() { + let dir = tempdir().expect("tempdir"); + let path = write_wrangler( + dir.path(), + "# managed by hand -- please keep this line\nname = \"my-worker\"\n", + ); + upsert_kv_namespace(&path, TEST_KV_ID, "00112233445566778899aabbccddeeff").expect("upsert"); + let after = fs::read_to_string(&path).expect("read"); + assert!( + after.contains("# managed by hand"), + "preserved comment: {after}" + ); + } + + #[test] + fn upsert_kv_namespace_preserves_sibling_fields_on_existing_entry() { + // toml_edit replaces only the `id` Item when we update it; + // sibling fields on the same `[[kv_namespaces]]` table + // (e.g. `preview_id`, custom annotations the user added) + // must survive the rewrite. Pinning this so a future + // toml_edit upgrade or a refactor can't silently drop + // operator data. + let dir = tempdir().expect("tempdir"); + let path = write_wrangler( + dir.path(), + "[[kv_namespaces]]\nbinding = \"sessions\"\nid = \"local-dev-placeholder\"\npreview_id = \"local-preview\"\ndescription = \"hand-added by ops\"\n", + ); + upsert_kv_namespace(&path, TEST_KV_ID, "00112233445566778899aabbccddeeff").expect("upsert"); + let after = fs::read_to_string(&path).expect("read"); + assert!( + after.contains("id = \"00112233445566778899aabbccddeeff\""), + "id rewritten: {after}" + ); + assert!( + after.contains("preview_id = \"local-preview\""), + "preserved preview_id: {after}" + ); + assert!( + after.contains("description = \"hand-added by ops\""), + "preserved description: {after}" + ); + } + + #[test] + fn upsert_kv_namespace_creates_file_when_wrangler_toml_missing() { + // Orphan-namespace hazard: if `wrangler kv namespace create` + // succeeds but wrangler.toml is missing at writeback time, + // erroring here would leave the remote namespace orphaned + // with no local reference. Symmetric with read_namespace_id's + // NotFound -> Ok(None) behaviour: upsert treats NotFound as + // "start with empty document" and writes the entry. + let dir = tempdir().expect("tempdir"); + let path = dir.path().join("missing.toml"); + assert!(!path.exists(), "precondition: file must not exist"); + upsert_kv_namespace(&path, TEST_KV_ID, "00112233445566778899aabbccddeeff") + .expect("missing file is permissive"); + let after = fs::read_to_string(&path).expect("file now exists"); + assert!( + after.contains("binding = \"sessions\""), + "created file with new entry: {after}" + ); + assert!( + after.contains("id = \"00112233445566778899aabbccddeeff\""), + "id written: {after}" + ); + } + + // ---------- provision (dry-run + error path) ---------- + + #[test] + fn provision_dry_run_does_not_invoke_wrangler() { + let dir = tempdir().expect("tempdir"); + write_wrangler(dir.path(), "name = \"demo\"\n"); + let kv_ids: Vec = + ResolvedStoreId::from_logicals(&[TEST_KV_ID, TEST_KV_ID_ALT]); + let config_ids: Vec = ResolvedStoreId::from_logicals(&[TEST_CONFIG_ID]); + let secret_ids: Vec = ResolvedStoreId::from_logicals(&[TEST_SECRET_ID]); + let stores = ProvisionStores { + config: &config_ids, + kv: &kv_ids, + secrets: &secret_ids, + }; + let out = CloudflareCliAdapter + .provision(dir.path(), Some("wrangler.toml"), None, &stores, true) + .expect("dry-run succeeds"); + // 2 KV + 1 config + 1 secret = 4 status lines. + assert_eq!(out.len(), 4); + assert!(out[0].contains("would run `wrangler kv namespace create sessions`")); + assert!(out[1].contains("would run `wrangler kv namespace create cache`")); + assert!(out[2].contains("would run `wrangler kv namespace create app_config`")); + assert!(out[3].contains("runtime-managed via `wrangler secret put`")); + // Manifest untouched. + let after = fs::read_to_string(dir.path().join("wrangler.toml")).expect("read"); + assert_eq!(after, "name = \"demo\"\n", "dry-run mutated wrangler.toml"); + } + + #[test] + fn provision_dry_run_writes_resolved_platform_name_into_binding() { + // Regression: provision used to receive only logical ids + // and write them verbatim into wrangler.toml. With the + // platform-name flow, an operator who sets + // `EDGEZERO__STORES__CONFIG__APP_CONFIG__NAME=prod_config` + // sees `prod_config` land as the binding name (matching what + // the runtime resolves via `env.kv(...)`), with the logical + // id still mentioned for human-facing wording. + let dir = tempdir().expect("tempdir"); + write_wrangler(dir.path(), "name = \"demo\"\n"); + let config_ids = vec![ResolvedStoreId::new(TEST_CONFIG_ID, "prod_config")]; + let stores = ProvisionStores { + config: &config_ids, + kv: &[], + secrets: &[], + }; + let out = CloudflareCliAdapter + .provision(dir.path(), Some("wrangler.toml"), None, &stores, true) + .expect("dry-run succeeds"); + assert_eq!(out.len(), 1); + assert!( + out[0].contains("wrangler kv namespace create prod_config"), + "dry-run uses platform name in the `wrangler` invocation: {out:?}" + ); + assert!( + out[0].contains("binding = \"prod_config\""), + "dry-run writes platform name as the binding: {out:?}" + ); + assert!( + out[0].contains("logical id `app_config`"), + "logical id is preserved for operator wording: {out:?}" + ); + } + + #[test] + fn provision_errors_when_adapter_manifest_path_missing() { + let dir = tempdir().expect("tempdir"); + let kv_ids: Vec = ResolvedStoreId::from_logicals(&[TEST_KV_ID]); + let stores = ProvisionStores { + config: &[], + kv: &kv_ids, + secrets: &[], + }; + let err = CloudflareCliAdapter + .provision(dir.path(), None, None, &stores, true) + .expect_err("missing adapter manifest path must error"); + assert!( + err.contains("wrangler.toml"), + "error names what's missing: {err}" + ); + } + + #[test] + fn provision_dry_run_skips_bindings_already_provisioned_with_real_id() { + let dir = tempdir().expect("tempdir"); + // 32-char lowercase hex id == real Cloudflare namespace id. + let path = write_wrangler( + dir.path(), + "name = \"demo\"\n[[kv_namespaces]]\nbinding = \"sessions\"\nid = \"00112233445566778899aabbccddeeff\"\n", + ); + let kv_ids: Vec = ResolvedStoreId::from_logicals(&[TEST_KV_ID]); + let stores = ProvisionStores { + config: &[], + kv: &kv_ids, + secrets: &[], + }; + let out = CloudflareCliAdapter + .provision(dir.path(), Some("wrangler.toml"), None, &stores, true) + .expect("dry-run succeeds"); + assert_eq!(out.len(), 1); + assert!( + out[0].contains("already provisioned") + && out[0].contains("00112233445566778899aabbccddeeff"), + "skip line names the existing id: {out:?}" + ); + let after = fs::read_to_string(&path).expect("read"); + assert!( + after.contains("00112233445566778899aabbccddeeff"), + "did not touch existing id: {after}" + ); + } + + #[test] + fn provision_dry_run_treats_placeholder_id_as_unprovisioned() { + // A scaffolded wrangler.toml ships with placeholder ids the + // user is expected to overwrite by running provision. + // Dry-run should report the would-be create call, NOT the + // already-provisioned skip. + let dir = tempdir().expect("tempdir"); + write_wrangler( + dir.path(), + "name = \"demo\"\n[[kv_namespaces]]\nbinding = \"sessions\"\nid = \"local-dev-placeholder\"\n", + ); + let kv_ids: Vec = ResolvedStoreId::from_logicals(&[TEST_KV_ID]); + let stores = ProvisionStores { + config: &[], + kv: &kv_ids, + secrets: &[], + }; + let out = CloudflareCliAdapter + .provision(dir.path(), Some("wrangler.toml"), None, &stores, true) + .expect("dry-run succeeds"); + assert_eq!(out.len(), 1); + assert!( + out[0].contains("would run `wrangler kv namespace create sessions`"), + "placeholder id is treated as unprovisioned: {out:?}" + ); + } + + #[test] + fn provision_with_no_declared_stores_says_so() { + let dir = tempdir().expect("tempdir"); + write_wrangler(dir.path(), "name = \"demo\"\n"); + let stores = ProvisionStores { + config: &[], + kv: &[], + secrets: &[], + }; + let out = CloudflareCliAdapter + .provision(dir.path(), Some("wrangler.toml"), None, &stores, false) + .expect("no-store provision is fine"); + assert_eq!(out, vec!["cloudflare has no declared stores to provision"]); + } + + // ---------- find_namespace_id ---------- + + #[test] + fn find_namespace_id_reads_array_of_tables() { + let dir = tempdir().expect("tempdir"); + let path = write_wrangler( + dir.path(), + "name = \"demo\"\n[[kv_namespaces]]\nbinding = \"app_config\"\nid = \"00112233445566778899aabbccddeeff\"\n", + ); + let id = find_namespace_id(&path, TEST_CONFIG_ID).expect("found"); + assert_eq!(id, "00112233445566778899aabbccddeeff"); + } + + #[test] + fn find_namespace_id_reads_inline_array() { + let dir = tempdir().expect("tempdir"); + let path = write_wrangler( + dir.path(), + "name = \"demo\"\nkv_namespaces = [{ binding = \"app_config\", id = \"ffeeddccbbaa99887766554433221100\" }]\n", + ); + let id = find_namespace_id(&path, TEST_CONFIG_ID).expect("found"); + assert_eq!(id, "ffeeddccbbaa99887766554433221100"); + } + + #[test] + fn find_namespace_id_errors_with_provision_hint_when_binding_absent() { + let dir = tempdir().expect("tempdir"); + let path = write_wrangler( + dir.path(), + "name = \"demo\"\n[[kv_namespaces]]\nbinding = \"other\"\nid = \"00112233445566778899aabbccddeeff\"\n", + ); + let err = find_namespace_id(&path, TEST_CONFIG_ID).expect_err("missing must error"); + assert!( + err.contains(TEST_CONFIG_ID) && err.contains("provision"), + "error names the binding and points at provision: {err}" + ); + } + + #[test] + fn find_namespace_id_rejects_placeholder_id_with_provision_hint() { + // A binding with `id = "local-dev-placeholder"` (or any + // other non-32-char-hex value) is treated the same as + // a missing binding: the operator needs to run provision + // before the id is usable for `wrangler kv bulk put`. + // Without this guard, push would shell out with the + // placeholder as `--namespace-id=...` and fail at wrangler + // with a less actionable error. + let dir = tempdir().expect("tempdir"); + let path = write_wrangler( + dir.path(), + "name = \"demo\"\n[[kv_namespaces]]\nbinding = \"app_config\"\nid = \"local-dev-placeholder\"\n", + ); + let err = + find_namespace_id(&path, TEST_CONFIG_ID).expect_err("placeholder id must be rejected"); + assert!( + err.contains("local-dev-placeholder") && err.contains("provision"), + "error names the placeholder and points at provision: {err}" + ); + } + + #[test] + fn find_namespace_id_errors_with_provision_hint_when_file_missing() { + let dir = tempdir().expect("tempdir"); + let path = dir.path().join("does-not-exist.toml"); + let err = + find_namespace_id(&path, TEST_CONFIG_ID).expect_err("missing wrangler.toml must error"); + assert!( + err.contains("provision"), + "error points at provision: {err}" + ); + } + + // ---------- bulk_payload ---------- + + #[test] + fn bulk_payload_emits_wrangler_array_of_key_value_objects() { + let entries = vec![ + ("greeting".to_owned(), "hello".to_owned()), + ("service.timeout_ms".to_owned(), "1500".to_owned()), + ]; + let raw = bulk_payload(&entries).expect("payload"); + let parsed: serde_json::Value = serde_json::from_str(&raw).expect("valid JSON"); + let array = parsed.as_array().expect("array"); + assert_eq!(array.len(), 2); + assert_eq!(array[0]["key"], "greeting"); + assert_eq!(array[0]["value"], "hello"); + assert_eq!(array[1]["key"], "service.timeout_ms"); + assert_eq!(array[1]["value"], "1500"); + } + + #[test] + fn bulk_payload_with_no_entries_is_empty_array() { + let raw = bulk_payload(&[]).expect("empty payload"); + let parsed: serde_json::Value = serde_json::from_str(&raw).expect("valid JSON"); + assert_eq!(parsed, serde_json::json!([])); + } + + // ---------- push_config_entries (dry-run + error paths) ---------- + + #[test] + fn push_dry_run_resolves_namespace_id_and_does_not_invoke_wrangler() { + let dir = tempdir().expect("tempdir"); + let original = + "name = \"demo\"\n[[kv_namespaces]]\nbinding = \"app_config\"\nid = \"00112233445566778899aabbccddeeff\"\n"; + let path = write_wrangler(dir.path(), original); + let entries = vec![ + ("greeting".to_owned(), "hello".to_owned()), + ("feature.new_checkout".to_owned(), "false".to_owned()), + ]; + let out = CloudflareCliAdapter + .push_config_entries( + dir.path(), + Some("wrangler.toml"), + None, + &ResolvedStoreId::from_logical(TEST_CONFIG_ID), + &entries, + &AdapterPushContext::new(), + true, + ) + .expect("dry-run succeeds"); + // Header + per-entry preview, matching the fastly dry-run shape. + assert_eq!(out.len(), 1 + entries.len(), "header + per-entry preview"); + assert!( + out[0].contains("would run `wrangler kv bulk put") + && out[0].contains("--namespace-id=00112233445566778899aabbccddeeff"), + "dry-run header names namespace id: {out:?}" + ); + assert!( + out.iter().any(|line| line.contains("`greeting`")), + "dry-run lists `greeting`: {out:?}" + ); + assert!( + out.iter() + .any(|line| line.contains("`feature.new_checkout`")), + "dry-run lists `feature.new_checkout`: {out:?}" + ); + let after = fs::read_to_string(&path).expect("read"); + assert_eq!(after, original, "dry-run must not mutate wrangler.toml"); + } + + #[test] + fn push_dry_run_is_lenient_when_binding_not_yet_provisioned() { + let dir = tempdir().expect("tempdir"); + write_wrangler(dir.path(), "name = \"demo\"\n"); + let entries = vec![("greeting".to_owned(), "hello".to_owned())]; + let out = CloudflareCliAdapter + .push_config_entries( + dir.path(), + Some("wrangler.toml"), + None, + &ResolvedStoreId::from_logical(TEST_CONFIG_ID), + &entries, + &AdapterPushContext::new(), + true, + ) + .expect("dry-run is lenient: pre-provision preview is allowed"); + assert!( + out[0].contains("") && out[0].contains("provision"), + "dry-run header explains the namespace is unresolved and points at provision: {out:?}" + ); + assert!( + out.iter().any(|line| line.contains("`greeting`")), + "dry-run still lists the entries it would push: {out:?}" + ); + } + + #[test] + fn push_errors_when_adapter_manifest_path_missing() { + let dir = tempdir().expect("tempdir"); + let entries = vec![("k".to_owned(), "v".to_owned())]; + let err = CloudflareCliAdapter + .push_config_entries( + dir.path(), + None, + None, + &ResolvedStoreId::from_logical(TEST_CONFIG_ID), + &entries, + &AdapterPushContext::new(), + true, + ) + .expect_err("missing adapter manifest path must error"); + assert!( + err.contains("wrangler.toml") && err.contains("config push"), + "error explains the missing manifest pointer: {err}" + ); + } + + #[test] + fn push_real_run_errors_with_provision_hint_when_binding_absent() { + // dry-run is now lenient (see + // `push_dry_run_is_lenient_when_binding_not_yet_provisioned`), + // but a real run still must err so we don't silently push + // to a non-existent namespace. + let dir = tempdir().expect("tempdir"); + write_wrangler(dir.path(), "name = \"demo\"\n"); + let entries = vec![("greeting".to_owned(), "hello".to_owned())]; + let err = CloudflareCliAdapter + .push_config_entries( + dir.path(), + Some("wrangler.toml"), + None, + &ResolvedStoreId::from_logical(TEST_CONFIG_ID), + &entries, + &AdapterPushContext::new(), + false, + ) + .expect_err("missing binding must error on real run"); + assert!( + err.contains("provision") && err.contains(TEST_CONFIG_ID), + "error points at provision: {err}" + ); + } + + #[test] + fn push_with_no_entries_reports_no_op_after_resolving_namespace() { + let dir = tempdir().expect("tempdir"); + write_wrangler( + dir.path(), + "name = \"demo\"\n[[kv_namespaces]]\nbinding = \"app_config\"\nid = \"00112233445566778899aabbccddeeff\"\n", + ); + let out = CloudflareCliAdapter + .push_config_entries( + dir.path(), + Some("wrangler.toml"), + None, + &ResolvedStoreId::from_logical(TEST_CONFIG_ID), + &[], + &AdapterPushContext::new(), + false, + ) + .expect("zero-entry push is fine"); + assert_eq!(out.len(), 1); + assert!( + out[0].contains("no config entries") + && out[0].contains("00112233445566778899aabbccddeeff"), + "status line names empty + namespace id: {out:?}" + ); + } +} diff --git a/crates/edgezero-adapter-cloudflare/src/config_store.rs b/crates/edgezero-adapter-cloudflare/src/config_store.rs index 74e05e08..d2fea09c 100644 --- a/crates/edgezero-adapter-cloudflare/src/config_store.rs +++ b/crates/edgezero-adapter-cloudflare/src/config_store.rs @@ -1,182 +1,105 @@ -//! Cloudflare Workers adapter config store: reads a single JSON env var. +//! Cloudflare Workers adapter config store: reads from a KV namespace. //! -//! Config is stored as one Cloudflare string binding (set in `wrangler.toml [vars]`) -//! whose value is a JSON object, e.g.: +//! Each declared config id maps to its own Cloudflare KV namespace binding, +//! resolved at request time from `EDGEZERO__STORES__CONFIG____NAME`. +//! Reads are async (`worker::kv::KvStore::get(key).text().await`). //! //! ```toml -//! [vars] -//! app_config = '{"greeting":"hello","feature.new_checkout":"false"}' +//! # wrangler.toml +//! [[kv_namespaces]] +//! binding = "app_config" +//! id = "abc123…" //! ``` //! -//! This allows arbitrary string keys (including dots) on a platform whose binding -//! names are restricted to JavaScript identifier syntax. - -use std::collections::{HashMap, VecDeque}; -use std::sync::{Arc, Mutex, OnceLock}; +//! This replaces the pre-rewrite `[vars]`-backed JSON-string config store. +//! `[vars]` bindings are restricted to JavaScript identifier syntax, so +//! arbitrary dotted keys had to be JSON-packed inside one variable. The KV +//! backing has no such restriction. +use async_trait::async_trait; use edgezero_core::config_store::{ConfigStore, ConfigStoreError}; +#[cfg(test)] +use std::collections::HashMap; +#[cfg(not(any(all(feature = "cloudflare", target_arch = "wasm32"), test)))] +use std::convert::Infallible; +#[cfg(all(feature = "cloudflare", target_arch = "wasm32"))] +use worker::kv::KvStore as WorkerKvStore; +#[cfg(all(feature = "cloudflare", target_arch = "wasm32"))] use worker::Env; -type ConfigMap = HashMap; -/// Maximum number of distinct binding names to remember in the parse cache. -/// -/// A single Worker typically uses one or two config bindings; 64 is a generous -/// ceiling that bounds isolate memory without any practical limit for real apps. -/// When the cache is full, the oldest entry is evicted (LRU-style) to make room. -const CONFIG_CACHE_LIMIT: usize = 64; - -/// Config store backed by a single Cloudflare JSON string binding. +/// Config store backed by a Cloudflare KV namespace. /// -/// At construction time the binding value is parsed into a `HashMap`. -/// Reads are then O(1) map lookups with no further JS interop. +/// The namespace binding is opened at construction; individual reads are +/// async KV lookups against that namespace. pub struct CloudflareConfigStore { - data: Arc, + inner: CloudflareConfigBackend, } -impl CloudflareConfigStore { - /// Build a store by reading and parsing the JSON binding named `binding_name`. - /// - /// Returns an empty store (every key returns `None`) if the binding is absent or - /// its value is not valid JSON. Missing or invalid bindings are logged at `warn` - /// level (once per binding name per isolate lifetime) via the same path as - /// [`Self::try_new`], so misconfigured binding names will surface in logs. - /// Use [`Self::try_new`] when you need to distinguish a missing/invalid binding - /// from a valid but empty config at the call site. - pub fn new_or_empty(env: &Env, binding_name: &str) -> Self { - Self::try_new(env, binding_name).unwrap_or_else(Self::empty) - } - - /// Build a store only when the configured Cloudflare binding exists and parses successfully. - /// - /// Missing bindings or invalid JSON are treated as configuration problems, logged at warn - /// level (once per binding name per isolate lifetime), and return `None` so the adapter - /// can skip injecting the handle. - pub fn try_new(env: &Env, binding_name: &str) -> Option { - Some(Self { - data: lookup_cached(env, binding_name)?, - }) - } - - fn empty() -> Self { - Self { - data: Arc::new(HashMap::new()), - } - } +enum CloudflareConfigBackend { + #[cfg(test)] + InMemory(HashMap), + #[cfg(all(feature = "cloudflare", target_arch = "wasm32"))] + Kv(WorkerKvStore), + /// Never constructed; keeps the enum inhabited off production/test cfgs. + #[cfg(not(any(all(feature = "cloudflare", target_arch = "wasm32"), test)))] + _Uninhabited(Infallible), +} +impl CloudflareConfigStore { #[cfg(test)] fn from_entries(entries: impl IntoIterator) -> Self { Self { - data: Arc::new(entries.into_iter().collect()), + inner: CloudflareConfigBackend::InMemory(entries.into_iter().collect()), } } -} -impl ConfigStore for CloudflareConfigStore { - fn get(&self, key: &str) -> Result, ConfigStoreError> { - Ok(self.data.get(key).cloned()) - } -} - -/// Parse-and-cache the config map for `binding_name`. -/// -/// Keyed only by name: Cloudflare env vars are immutable within an isolate -/// lifetime, so the parsed result for a given binding name never changes. -/// Warnings are suppressed for recently seen binding names via a bounded cache. -/// -/// # WASM safety -/// `std::sync::Mutex` compiles for `wasm32-unknown-unknown` and is safe here because -/// WASM is single-threaded — the lock can never be contested and poisoning cannot -/// occur via a concurrent thread panic. -fn lookup_cached(env: &Env, binding_name: &str) -> Option> { - // Fast path: already cached. - if let Some(entry) = config_cache() - .lock() - .unwrap_or_else(|p| p.into_inner()) - .get(binding_name) - { - return entry; + /// Open the KV namespace bound as `binding_name`. + /// + /// # Errors + /// Returns [`ConfigStoreError::Unavailable`] when the binding is missing + /// or cannot be opened. + #[cfg(all(feature = "cloudflare", target_arch = "wasm32"))] + #[inline] + pub fn from_env(env: &Env, binding_name: &str) -> Result { + let store = env.kv(binding_name).map_err(|err| { + ConfigStoreError::unavailable(format!( + "failed to open config KV binding '{binding_name}': {err}" + )) + })?; + Ok(Self { + inner: CloudflareConfigBackend::Kv(store), + }) } - - // Cache miss: resolve from the JS env (synchronous interop, safe outside the lock). - let resolved = match env.var(binding_name).ok().map(|v| v.to_string()) { - None => { - log::warn!( - "configured config store binding '{}' is missing from the Worker environment; skipping config-store injection", - binding_name - ); - None - } - Some(raw) => match serde_json::from_str::(&raw) { - Ok(data) => Some(Arc::new(data)), - Err(err) => { - log::warn!( - "configured config store binding '{}' contains invalid JSON: {}; skipping config-store injection", - binding_name, - err - ); - None - } - }, - }; - - // Cache the resolved value — including None for missing/invalid bindings. - // This is safe because Cloudflare string bindings are immutable within an - // isolate lifetime: the parsed result for a given binding name never changes, - // so caching a failed parse prevents redundant warnings on every request. - config_cache() - .lock() - .unwrap_or_else(|p| p.into_inner()) - .get_or_insert(binding_name, resolved, CONFIG_CACHE_LIMIT) } -fn config_cache() -> &'static Mutex { - static CACHE: OnceLock> = OnceLock::new(); - CACHE.get_or_init(|| Mutex::new(ConfigCache::default())) -} - -#[derive(Default)] -struct ConfigCache { - entries: HashMap>>, - order: VecDeque, -} - -impl ConfigCache { - fn get(&self, key: &str) -> Option>> { - self.entries.get(key).cloned() - } - - fn get_or_insert( - &mut self, - key: &str, - value: Option>, - limit: usize, - ) -> Option> { - if let Some(existing) = self.entries.get(key) { - return existing.clone(); - } - - if limit > 0 && self.order.len() >= limit { - if let Some(oldest) = self.order.pop_front() { - self.entries.remove(&oldest); +#[async_trait(?Send)] +impl ConfigStore for CloudflareConfigStore { + #[inline] + async fn get(&self, key: &str) -> Result, ConfigStoreError> { + match &self.inner { + #[cfg(all(feature = "cloudflare", target_arch = "wasm32"))] + CloudflareConfigBackend::Kv(store) => store.get(key).text().await.map_err(|err| { + ConfigStoreError::internal(anyhow::anyhow!("kv config get failed: {err}")) + }), + #[cfg(test)] + CloudflareConfigBackend::InMemory(data) => Ok(data.get(key).cloned()), + #[cfg(not(any(all(feature = "cloudflare", target_arch = "wasm32"), test)))] + CloudflareConfigBackend::_Uninhabited(never) => { + let _: &str = key; + match *never {} } } - - let key = key.to_string(); - self.order.push_back(key.clone()); - self.entries.insert(key, value.clone()); - value } } #[cfg(test)] mod tests { use super::*; - use wasm_bindgen_test::wasm_bindgen_test; - edgezero_core::config_store_contract_tests!(cloudflare_config_store_contract, #[wasm_bindgen_test], { + edgezero_core::config_store_contract_tests!(cloudflare_config_store_contract, { CloudflareConfigStore::from_entries([ - ("contract.key.a".to_string(), "value_a".to_string()), - ("contract.key.b".to_string(), "value_b".to_string()), + ("contract.key.a".to_owned(), "value_a".to_owned()), + ("contract.key.b".to_owned(), "value_b".to_owned()), ]) }); } diff --git a/crates/edgezero-adapter-cloudflare/src/context.rs b/crates/edgezero-adapter-cloudflare/src/context.rs index d3bb8882..a5faf72f 100644 --- a/crates/edgezero-adapter-cloudflare/src/context.rs +++ b/crates/edgezero-adapter-cloudflare/src/context.rs @@ -6,27 +6,34 @@ use worker::{Context, Env}; /// Adapter-specific context stored alongside each request to expose Worker APIs. #[derive(Clone, Debug)] pub struct CloudflareRequestContext { - env: Arc, ctx: Arc, + env: Arc, } impl CloudflareRequestContext { - pub fn insert(request: &mut Request, env: Env, ctx: Context) { - request.extensions_mut().insert(Self { - env: Arc::new(env), - ctx: Arc::new(ctx), - }); + #[inline] + #[must_use] + pub fn ctx(&self) -> &Context { + &self.ctx } + #[inline] + #[must_use] pub fn env(&self) -> &Env { &self.env } - pub fn ctx(&self) -> &Context { - &self.ctx + #[inline] + #[must_use] + pub fn get(request: &Request) -> Option<&Self> { + request.extensions().get::() } - pub fn get(request: &Request) -> Option<&CloudflareRequestContext> { - request.extensions().get::() + #[inline] + pub fn insert(request: &mut Request, env: Env, ctx: Context) { + request.extensions_mut().insert(Self { + ctx: Arc::new(ctx), + env: Arc::new(env), + }); } } diff --git a/crates/edgezero-adapter-cloudflare/src/key_value_store.rs b/crates/edgezero-adapter-cloudflare/src/key_value_store.rs index 22566911..65178003 100644 --- a/crates/edgezero-adapter-cloudflare/src/key_value_store.rs +++ b/crates/edgezero-adapter-cloudflare/src/key_value_store.rs @@ -15,13 +15,15 @@ use bytes::Bytes; use edgezero_core::key_value_store::{KvError, KvPage, KvStore}; #[cfg(all(feature = "cloudflare", target_arch = "wasm32"))] use std::time::Duration; +#[cfg(all(feature = "cloudflare", target_arch = "wasm32"))] +use worker::kv::KvStore as WorkerKvStore; /// KV store backed by Cloudflare Workers KV. /// /// Wraps a `worker::kv::KvStore` handle obtained via the environment binding. #[cfg(all(feature = "cloudflare", target_arch = "wasm32"))] pub struct CloudflareKvStore { - store: worker::kv::KvStore, + store: WorkerKvStore, } #[cfg(all(feature = "cloudflare", target_arch = "wasm32"))] @@ -30,10 +32,15 @@ impl CloudflareKvStore { /// /// The `binding` must match a KV namespace binding in `wrangler.toml`. /// Uses `env.kv(binding)` which is the idiomatic `worker` 0.7+ API. + /// + /// # Errors + /// Returns [`KvError::Internal`] if the named binding is missing from the + /// Worker environment or otherwise cannot be opened. + #[inline] pub fn from_env(env: &worker::Env, binding: &str) -> Result { - let store = env - .kv(binding) - .map_err(|e| KvError::Internal(anyhow::anyhow!("failed to open kv binding: {e}")))?; + let store = env.kv(binding).map_err(|err| { + KvError::Internal(anyhow::anyhow!("failed to open kv binding: {err}")) + })?; Ok(Self { store }) } } @@ -41,25 +48,73 @@ impl CloudflareKvStore { #[cfg(all(feature = "cloudflare", target_arch = "wasm32"))] #[async_trait(?Send)] impl KvStore for CloudflareKvStore { + #[inline] + async fn delete(&self, key: &str) -> Result<(), KvError> { + self.store + .delete(key) + .await + .map_err(|err| KvError::Internal(anyhow::anyhow!("delete failed: {err}"))) + } + + #[inline] + async fn exists(&self, key: &str) -> Result { + Ok(self.get_bytes(key).await?.is_some()) + } + + #[inline] async fn get_bytes(&self, key: &str) -> Result, KvError> { let result = self .store .get(key) .bytes() .await - .map_err(|e| KvError::Internal(anyhow::anyhow!("get failed: {e}")))?; + .map_err(|err| KvError::Internal(anyhow::anyhow!("get failed: {err}")))?; Ok(result.map(Bytes::from)) } + #[inline] + async fn list_keys_page( + &self, + prefix: &str, + cursor: Option<&str>, + limit: usize, + ) -> Result { + let limit_u64 = u64::try_from(limit) + .map_err(|err| KvError::Validation(format!("list limit exceeds u64: {err}")))?; + let mut request = self.store.list().limit(limit_u64); + + if !prefix.is_empty() { + request = request.prefix(prefix.to_owned()); + } + if let Some(cursor_str) = cursor.filter(|value| !value.is_empty()) { + request = request.cursor(cursor_str.to_owned()); + } + + let response = request + .execute() + .await + .map_err(|err| KvError::Internal(anyhow::anyhow!("list execute failed: {err}")))?; + + Ok(KvPage { + keys: response.keys.into_iter().map(|key| key.name).collect(), + cursor: (!response.list_complete) + .then_some(response.cursor) + .flatten() + .filter(|value| !value.is_empty()), + }) + } + + #[inline] async fn put_bytes(&self, key: &str, value: Bytes) -> Result<(), KvError> { self.store .put_bytes(key, value.as_ref()) - .map_err(|e| KvError::Internal(anyhow::anyhow!("put failed: {e}")))? + .map_err(|err| KvError::Internal(anyhow::anyhow!("put failed: {err}")))? .execute() .await - .map_err(|e| KvError::Internal(anyhow::anyhow!("put execute failed: {e}"))) + .map_err(|err| KvError::Internal(anyhow::anyhow!("put execute failed: {err}"))) } + #[inline] async fn put_bytes_with_ttl( &self, key: &str, @@ -72,49 +127,11 @@ impl KvStore for CloudflareKvStore { self.store .put_bytes(key, value.as_ref()) - .map_err(|e| KvError::Internal(anyhow::anyhow!("put failed: {e}")))? + .map_err(|err| KvError::Internal(anyhow::anyhow!("put failed: {err}")))? .expiration_ttl(ttl_secs) .execute() .await - .map_err(|e| KvError::Internal(anyhow::anyhow!("put with ttl execute failed: {e}"))) - } - - async fn delete(&self, key: &str) -> Result<(), KvError> { - self.store - .delete(key) - .await - .map_err(|e| KvError::Internal(anyhow::anyhow!("delete failed: {e}"))) - } - - async fn list_keys_page( - &self, - prefix: &str, - cursor: Option<&str>, - limit: usize, - ) -> Result { - let limit = u64::try_from(limit) - .map_err(|_| KvError::Validation("list limit exceeds u64".to_string()))?; - let mut request = self.store.list().limit(limit); - - if !prefix.is_empty() { - request = request.prefix(prefix.to_string()); - } - if let Some(cursor) = cursor.filter(|cursor| !cursor.is_empty()) { - request = request.cursor(cursor.to_string()); - } - - let response = request - .execute() - .await - .map_err(|e| KvError::Internal(anyhow::anyhow!("list execute failed: {e}")))?; - - Ok(KvPage { - keys: response.keys.into_iter().map(|key| key.name).collect(), - cursor: (!response.list_complete) - .then_some(response.cursor) - .flatten() - .filter(|cursor| !cursor.is_empty()), - }) + .map_err(|err| KvError::Internal(anyhow::anyhow!("put with ttl execute failed: {err}"))) } } diff --git a/crates/edgezero-adapter-cloudflare/src/lib.rs b/crates/edgezero-adapter-cloudflare/src/lib.rs index d60b8efb..8288cbeb 100644 --- a/crates/edgezero-adapter-cloudflare/src/lib.rs +++ b/crates/edgezero-adapter-cloudflare/src/lib.rs @@ -3,130 +3,114 @@ #[cfg(feature = "cli")] pub mod cli; -#[cfg(all(feature = "cloudflare", target_arch = "wasm32"))] +// `config_store` compiles on host for its `InMemory` test backend; the +// production `Kv` backend is feature-gated internally. +#[cfg(any(test, all(feature = "cloudflare", target_arch = "wasm32")))] pub mod config_store; #[cfg(all(feature = "cloudflare", target_arch = "wasm32"))] -mod context; +pub mod context; #[cfg(all(feature = "cloudflare", target_arch = "wasm32"))] pub mod key_value_store; #[cfg(all(feature = "cloudflare", target_arch = "wasm32"))] -mod proxy; +pub mod proxy; #[cfg(all(feature = "cloudflare", target_arch = "wasm32"))] -mod request; +pub mod request; #[cfg(all(feature = "cloudflare", target_arch = "wasm32"))] -mod response; +pub mod response; #[cfg(all(feature = "cloudflare", target_arch = "wasm32"))] pub mod secret_store; #[cfg(all(feature = "cloudflare", target_arch = "wasm32"))] -pub use config_store::CloudflareConfigStore; -#[cfg(all(feature = "cloudflare", target_arch = "wasm32"))] -pub use context::CloudflareRequestContext; -#[cfg(all(feature = "cloudflare", target_arch = "wasm32"))] -pub use proxy::CloudflareProxyClient; +use edgezero_core::app::{Hooks, StoresMetadata}; #[cfg(all(feature = "cloudflare", target_arch = "wasm32"))] -#[allow(deprecated)] -pub use request::{ - dispatch, dispatch_with_config, dispatch_with_config_handle, dispatch_with_kv, - dispatch_with_kv_and_secrets, dispatch_with_secrets, into_core_request, DEFAULT_KV_BINDING, -}; +use edgezero_core::env_config::EnvConfig; #[cfg(all(feature = "cloudflare", target_arch = "wasm32"))] -pub use response::from_core_response; +use worker::{Context, Env, Error as WorkerError, Request, Response}; +/// # Errors +/// Never; this is currently a no-op on Cloudflare Workers (Workers manages +/// its own logging). The signature still returns [`log::SetLoggerError`] so +/// callers and the non-wasm stub stay drop-in compatible if a real logger +/// is wired in later. #[cfg(all(feature = "cloudflare", target_arch = "wasm32"))] +#[inline] pub fn init_logger() -> Result<(), log::SetLoggerError> { Ok(()) } +/// # Errors +/// Never; this is a no-op stub on non-wasm targets. #[cfg(not(all(feature = "cloudflare", target_arch = "wasm32")))] +#[inline] pub fn init_logger() -> Result<(), log::SetLoggerError> { Ok(()) } -#[cfg(all(feature = "cloudflare", target_arch = "wasm32"))] -pub trait AppExt { - #[deprecated( - note = "AppExt::dispatch() is the low-level manual path and does not inject config-store metadata; prefer run_app(), dispatch_with_config(), or dispatch_with_config_handle()" - )] - fn dispatch<'a>( - &'a self, - req: worker::Request, - env: worker::Env, - ctx: worker::Context, - ) -> ::core::pin::Pin< - Box> + 'a>, - >; -} - -#[cfg(all(feature = "cloudflare", target_arch = "wasm32"))] -impl AppExt for edgezero_core::app::App { - #[allow(deprecated)] - fn dispatch<'a>( - &'a self, - req: worker::Request, - env: worker::Env, - ctx: worker::Context, - ) -> ::core::pin::Pin< - Box> + 'a>, - > { - Box::pin(crate::request::dispatch_raw(self, req, env, ctx)) +/// Build an [`EnvConfig`] from a Cloudflare `Env`. Workers have no +/// `std::env`, and the `Env` binding object cannot be enumerated, so the exact +/// `EDGEZERO__STORES______NAME` keys are derived from the baked +/// store metadata and queried individually, alongside the fixed +/// `EDGEZERO__ADAPTER__*` / `EDGEZERO__LOGGING__*` keys. +#[cfg(all(feature = "cloudflare", target_arch = "wasm32"))] +fn env_config_from_worker(env: &Env, stores: StoresMetadata) -> EnvConfig { + let mut keys: Vec = vec![ + "EDGEZERO__ADAPTER__HOST".to_owned(), + "EDGEZERO__ADAPTER__PORT".to_owned(), + "EDGEZERO__LOGGING__LEVEL".to_owned(), + ]; + for (kind, store_meta) in [ + ("CONFIG", stores.config), + ("KV", stores.kv), + ("SECRETS", stores.secrets), + ] { + if let Some(meta) = store_meta { + for id in meta.ids { + keys.push(format!( + "EDGEZERO__STORES__{kind}__{}__NAME", + id.to_ascii_uppercase() + )); + } + } } + let vars = keys + .into_iter() + .filter_map(|key| env.var(&key).ok().map(|value| (key, value.to_string()))); + EnvConfig::from_vars(vars) } /// Entry point for a Cloudflare Workers application. /// -/// **Breaking change (pre-1.0):** `manifest_src` is now a required parameter. -/// Callers previously using `run_app_with_manifest` can rename to `run_app` — -/// the signatures are identical. -#[cfg(all(feature = "cloudflare", target_arch = "wasm32"))] -pub async fn run_app( - manifest_src: &str, - req: worker::Request, - env: worker::Env, - ctx: worker::Context, -) -> Result { - init_logger().expect("init cloudflare logger"); - let manifest_loader = edgezero_core::manifest::ManifestLoader::load_from_str(manifest_src); - let manifest = manifest_loader.manifest(); - let kv_binding = manifest.kv_store_name(edgezero_core::app::CLOUDFLARE_ADAPTER); - let kv_required = manifest.stores.kv.is_some(); - // Two-path resolution: `A::config_store()` is set at compile time by the - // `#[app]` macro and is the common case. The manifest fallback handles - // callers that implement `Hooks` manually without the macro — in that case - // `A::config_store()` returns `None` while `[stores.config]` in - // `edgezero.toml` may still be present. - let config_binding = A::config_store() - .map(|cfg| cfg.name_for_adapter(edgezero_core::app::CLOUDFLARE_ADAPTER)) - .or_else(|| { - manifest - .stores - .config - .as_ref() - .map(|cfg| cfg.config_store_name(edgezero_core::app::CLOUDFLARE_ADAPTER)) - }); - let secrets_required = manifest.secret_store_enabled("cloudflare"); +/// Portable store config is baked into `A` by the `app!` macro; adapter-specific +/// values (platform store names) are read at runtime from `EDGEZERO__*` +/// variables on the worker `Env`. No `edgezero.toml` is required. +/// +/// # Errors +/// Returns [`worker::Error`] if the inner dispatch fails or any required +/// store binding cannot be opened. +#[cfg(all(feature = "cloudflare", target_arch = "wasm32"))] +#[inline] +pub async fn run_app( + req: Request, + env: Env, + ctx: Context, +) -> Result { + // Best-effort: if a logger is already installed, ignore the error rather + // than panicking — every Worker request re-enters this function. + drop(init_logger()); + let stores = A::stores(); + let env_config = env_config_from_worker(&env, stores); let app = A::build_app(); - crate::request::dispatch_with_bindings( + request::dispatch_with_registries( &app, req, env, ctx, - config_binding, - kv_binding, - kv_required, - secrets_required, + request::RegistryInputs { + config_meta: stores.config, + kv_meta: stores.kv, + secret_meta: stores.secrets, + env_config: &env_config, + }, ) .await } - -/// Deprecated: use [`run_app`] which now takes `manifest_src` directly. -#[cfg(all(feature = "cloudflare", target_arch = "wasm32"))] -#[deprecated(note = "use run_app instead, which now takes manifest_src")] -pub async fn run_app_with_manifest( - manifest_src: &str, - req: worker::Request, - env: worker::Env, - ctx: worker::Context, -) -> Result { - run_app::(manifest_src, req, env, ctx).await -} diff --git a/crates/edgezero-adapter-cloudflare/src/proxy.rs b/crates/edgezero-adapter-cloudflare/src/proxy.rs index f217261a..72a8dafc 100644 --- a/crates/edgezero-adapter-cloudflare/src/proxy.rs +++ b/crates/edgezero-adapter-cloudflare/src/proxy.rs @@ -4,46 +4,48 @@ use edgezero_core::body::Body; use edgezero_core::compression::{decode_brotli_stream, decode_gzip_stream}; use edgezero_core::error::EdgeError; use edgezero_core::http::{header, HeaderMap, HeaderName, HeaderValue, Method, StatusCode, Uri}; -use edgezero_core::proxy::{ProxyClient, ProxyRequest, ProxyResponse}; -use futures_util::stream::{self, LocalBoxStream, StreamExt}; -use futures_util::TryStreamExt; +use edgezero_core::proxy::{ProxyClient, ProxyRequest, ProxyResponse, PROXY_HEADER}; +use futures_util::stream::{self, LocalBoxStream, StreamExt as _}; +use futures_util::TryStreamExt as _; use std::io; use worker::{ wasm_bindgen::JsValue, Body as WorkerBody, Fetch, Headers, Method as CfMethod, Request as CfRequest, RequestInit, Response as CfResponse, }; +type ChunkStream = LocalBoxStream<'static, Result, io::Error>>; + pub struct CloudflareProxyClient; #[async_trait(?Send)] impl ProxyClient for CloudflareProxyClient { + #[inline] async fn send(&self, request: ProxyRequest) -> Result { let (method, uri, headers, body, _ext) = request.into_parts(); - let cf_request = build_cf_request(method, &uri, headers, body).await?; + let cf_request = build_cf_request(&method, &uri, &headers, body)?; let mut cf_response = Fetch::Request(cf_request) .send() .await .map_err(EdgeError::internal)?; - let mut proxy_response = convert_response(&mut cf_response).await?; - proxy_response.headers_mut().insert( - edgezero_core::proxy::PROXY_HEADER, - HeaderValue::from_static("cloudflare"), - ); + let mut proxy_response = convert_response(&mut cf_response)?; + proxy_response + .headers_mut() + .insert(PROXY_HEADER, HeaderValue::from_static("cloudflare")); Ok(proxy_response) } } -async fn build_cf_request( - method: Method, +fn build_cf_request( + method: &Method, uri: &Uri, - headers: HeaderMap, + headers: &HeaderMap, body: Body, ) -> Result { let mut init = RequestInit::new(); - init.with_method(http_method_to_cf(method.clone())); + init.with_method(http_method_to_cf(method)); - let cf_headers = Headers::from(&headers); + let cf_headers = Headers::from(headers); init.with_headers(cf_headers); attach_body(&mut init, body)?; @@ -82,7 +84,7 @@ fn attach_body(init: &mut RequestInit, body: Body) -> Result<(), EdgeError> { Ok(()) } -async fn convert_response(cf_response: &mut CfResponse) -> Result { +fn convert_response(cf_response: &mut CfResponse) -> Result { let status = StatusCode::from_u16(cf_response.status_code()).map_err(EdgeError::internal)?; let mut proxy_response = ProxyResponse::new(status, Body::empty()); @@ -102,7 +104,9 @@ async fn convert_response(cf_response: &mut CfResponse) -> Result Result CfMethod { - match method { - Method::GET => CfMethod::Get, +fn http_method_to_cf(method: &Method) -> CfMethod { + match *method { Method::POST => CfMethod::Post, Method::PUT => CfMethod::Put, Method::PATCH => CfMethod::Patch, @@ -131,12 +134,6 @@ fn http_method_to_cf(method: Method) -> CfMethod { } } -type ChunkStream = LocalBoxStream<'static, Result, io::Error>>; - -fn worker_error_to_io(err: worker::Error) -> io::Error { - io::Error::new(io::ErrorKind::Other, err.to_string()) -} - fn transform_stream( stream: ChunkStream, encoding: Option<&str>, @@ -148,6 +145,10 @@ fn transform_stream( } } +fn worker_error_to_io(err: &worker::Error) -> io::Error { + io::Error::other(err.to_string()) +} + #[cfg(test)] mod tests { use super::*; @@ -155,15 +156,15 @@ mod tests { use flate2::{write::GzEncoder, Compression}; use futures::executor::block_on; use futures_util::stream; - use std::io::Write; + use std::io::Write as _; fn collect_body(body: Body) -> Vec { match body { Body::Once(bytes) => bytes.to_vec(), Body::Stream(mut stream) => block_on(async { let mut out = Vec::new(); - while let Some(chunk) = stream.next().await { - let chunk = chunk.expect("chunk"); + while let Some(item) = stream.next().await { + let chunk = item.expect("chunk"); out.extend_from_slice(&chunk); } out @@ -192,13 +193,12 @@ mod tests { assert_eq!(collect_body(body), b"gzip payload"); let mut brotli_data = Vec::new(); - { - let mut compressor = CompressorWriter::new(&mut brotli_data, 4096, 5, 21); - compressor.write_all(b"brotli payload").unwrap(); - } + let mut compressor = CompressorWriter::new(&mut brotli_data, 4096, 5, 21); + compressor.write_all(b"brotli payload").unwrap(); + drop(compressor); let brotli_stream: ChunkStream = Box::pin(stream::iter(vec![Ok::, io::Error>(brotli_data)])); - let body = Body::from_stream(transform_stream(brotli_stream, Some("br"))); - assert_eq!(collect_body(body), b"brotli payload"); + let brotli_body = Body::from_stream(transform_stream(brotli_stream, Some("br"))); + assert_eq!(collect_body(brotli_body), b"brotli payload"); } } diff --git a/crates/edgezero-adapter-cloudflare/src/request.rs b/crates/edgezero-adapter-cloudflare/src/request.rs index 3575a964..dfaab202 100644 --- a/crates/edgezero-adapter-cloudflare/src/request.rs +++ b/crates/edgezero-adapter-cloudflare/src/request.rs @@ -1,27 +1,29 @@ -use std::collections::BTreeSet; +use std::collections::{BTreeMap, BTreeSet}; +use std::fmt::Display; use std::sync::{Arc, Mutex, OnceLock}; -use crate::config_store::CloudflareConfigStore; -use crate::proxy::CloudflareProxyClient; -use crate::response::from_core_response; -use crate::CloudflareRequestContext; -use edgezero_core::app::App; +use edgezero_core::app::{App, StoreMetadata}; use edgezero_core::body::Body; use edgezero_core::config_store::ConfigStoreHandle; +use edgezero_core::env_config::EnvConfig; use edgezero_core::error::EdgeError; use edgezero_core::http::{request_builder, Method as CoreMethod, Request, Uri}; use edgezero_core::key_value_store::KvHandle; use edgezero_core::proxy::ProxyHandle; use edgezero_core::secret_store::SecretHandle; +use edgezero_core::store_registry::{ + BoundSecretStore, ConfigRegistry, KvRegistry, SecretRegistry, StoreRegistry, +}; use worker::{ Context, Env, Error as WorkerError, Method, Request as CfRequest, Response as CfResponse, }; -/// Default Cloudflare Workers KV binding name. -/// -/// If a KV namespace with this binding exists in your `wrangler.toml`, -/// it will be automatically available to handlers via the `Kv` extractor. -pub const DEFAULT_KV_BINDING: &str = edgezero_core::manifest::DEFAULT_KV_STORE_NAME; +use crate::config_store::CloudflareConfigStore; +use crate::context::CloudflareRequestContext; +use crate::key_value_store::CloudflareKvStore; +use crate::proxy::CloudflareProxyClient; +use crate::response::from_core_response; +use crate::secret_store::CloudflareSecretStore; /// Groups the optional per-request store handles injected at dispatch time. /// @@ -32,24 +34,221 @@ pub const DEFAULT_KV_BINDING: &str = edgezero_core::manifest::DEFAULT_KV_STORE_N /// ``` #[derive(Default)] pub(crate) struct Stores { - pub(crate) config_store: Option, - pub(crate) kv: Option, - pub(crate) secrets: Option, + config_registry: Option, + config_store: Option, + kv: Option, + kv_registry: Option, + secret_registry: Option, + secrets: Option, +} + +/// Cloudflare per-request dispatch service. +/// +/// Builds a Worker invocation with the stores the operator wants +/// injected into request extensions, then dispatches one request +/// against the wrapped `App`. The store wiring is a per-Service +/// decision; on Cloudflare Workers that means per-request (the +/// runtime invokes the entrypoint per HTTP request), but the +/// Service type itself is cheap to build. +/// +/// Replaces the prior `dispatch_with_*` variant fan-out. Each +/// builder method is independent: enable any combination of KV, +/// config, and secret stores by chaining the relevant `with_*` / +/// `require_*` calls. The manifest-driven `run_app` is still the +/// recommended entrypoint for normal flows -- the Service builder +/// is for manual / no-manifest deployments. +/// +/// ```rust,ignore +/// CloudflareService::new(&app) +/// .with_kv("sessions").require_kv() +/// .with_config("app_config") +/// .with_secrets() +/// .dispatch(req, env, ctx).await +/// ``` +pub struct CloudflareService<'app> { + app: &'app App, + config: ConfigSource, + kv: Option, + secrets: SecretSource, +} + +enum ConfigSource { + Binding(String), + Handle(ConfigStoreHandle), + None, } +struct KvSource { + binding: String, + required: bool, +} + +enum SecretSource { + Off, + On { required: bool }, +} + +impl<'app> CloudflareService<'app> { + /// Resolve every wired store at request time and dispatch + /// against the wrapped `App`. `env` and `ctx` come from the + /// Worker runtime per request, NOT the Service builder. + /// Consumes the service so a builder can't be reused with stale + /// wiring. + /// + /// # Errors + /// Returns [`worker::Error`] if a required store binding cannot be + /// opened, the core request cannot be built, or the inner router + /// dispatch fails. + #[inline] + pub async fn dispatch( + self, + req: CfRequest, + env: Env, + ctx: Context, + ) -> Result { + let config_store = match self.config { + ConfigSource::Binding(binding) => open_config_or_warn(&env, &binding), + ConfigSource::Handle(handle) => Some(handle), + ConfigSource::None => None, + }; + let kv = match self.kv { + Some(source) => resolve_kv_handle(&env, &source.binding, source.required)?, + None => None, + }; + let secrets = match self.secrets { + SecretSource::Off => None, + SecretSource::On { required } => resolve_secret_handle(&env, required), + }; + dispatch_with_handles( + self.app, + req, + env, + ctx, + Stores { + config_store, + kv, + secrets, + ..Default::default() + }, + ) + .await + } + + /// Build a new service that dispatches against `app` with NO + /// stores wired. Chain `.with_*` / `.require_*` to add stores. + #[must_use] + #[inline] + pub fn new(app: &'app App) -> Self { + Self { + app, + config: ConfigSource::None, + kv: None, + secrets: SecretSource::Off, + } + } + + /// Promote the previously-wired KV binding to required: an + /// unavailable namespace causes dispatch to return an error. + /// No-op when `with_kv` wasn't called. + #[must_use] + #[inline] + pub fn require_kv(mut self) -> Self { + if let Some(kv) = self.kv.as_mut() { + kv.required = true; + } + self + } + + /// Promote the previously-wired secret store to required. + /// No-op when `with_secrets` wasn't called. + #[must_use] + #[inline] + pub fn require_secrets(mut self) -> Self { + if let SecretSource::On { ref mut required } = self.secrets { + *required = true; + } + self + } + + /// Open the KV namespace bound as `binding` (per `wrangler.toml`) + /// as a Cloudflare config store and inject its handle. If the + /// binding is absent the dispatcher logs once and proceeds + /// without it. + #[must_use] + #[inline] + pub fn with_config>(mut self, binding: S) -> Self { + self.config = ConfigSource::Binding(binding.into()); + self + } + + /// Inject a pre-built `ConfigStoreHandle`. Use this when the + /// caller has already opened (or mocked) the backend. Mutually + /// exclusive with `with_config(binding)` -- the last call wins. + #[must_use] + #[inline] + pub fn with_config_handle(mut self, handle: ConfigStoreHandle) -> Self { + self.config = ConfigSource::Handle(handle); + self + } + + /// Open the KV namespace bound as `binding` and inject its + /// handle. Non-required by default: an absent binding logs + /// once and dispatch continues. Pair with `require_kv()` when + /// the manifest declares `[stores.kv]`. + #[must_use] + #[inline] + pub fn with_kv>(mut self, binding: S) -> Self { + self.kv = Some(KvSource { + binding: binding.into(), + required: false, + }); + self + } + + /// Enable Cloudflare Worker secrets and inject the secret-store + /// handle. Worker secrets have no namespace concept, so no + /// name is needed. Non-required by default; pair with + /// `require_secrets()` when the manifest declares + /// `[stores.secrets]`. Individual missing secrets surface as + /// `SecretError::NotFound` at access time. + #[must_use] + #[inline] + pub fn with_secrets(mut self) -> Self { + self.secrets = SecretSource::On { required: false }; + self + } +} + +/// Groups the multi-id store metadata + env config inputs threaded into +/// the registry-based dispatcher. Carved out so `dispatch_with_registries` +/// stays under the `too_many_arguments` ceiling. +pub(crate) struct RegistryInputs<'env> { + pub config_meta: Option, + pub env_config: &'env EnvConfig, + pub kv_meta: Option, + pub secret_meta: Option, +} + +/// Convert a Cloudflare Worker request into an `EdgeZero` core request. +/// +/// # Errors +/// Returns [`EdgeError::bad_request`] if the URL or URI cannot be parsed, +/// and [`EdgeError::internal`] if the body cannot be read or the core +/// request cannot be built. +#[inline] pub async fn into_core_request( mut req: CfRequest, env: Env, ctx: Context, ) -> Result { - let method = into_core_method(req.method()); + let method = into_core_method(&req.method()); let url = req .url() - .map_err(|err| EdgeError::bad_request(format!("invalid URL: {}", err)))?; + .map_err(|err| EdgeError::bad_request(format!("invalid URL: {err}")))?; let uri: Uri = url .as_str() .parse() - .map_err(|err| EdgeError::bad_request(format!("invalid URI: {}", err)))?; + .map_err(|err| EdgeError::bad_request(format!("invalid URI: {err}")))?; let mut builder = request_builder().method(method).uri(uri); let headers = req.headers(); @@ -70,226 +269,152 @@ pub async fn into_core_request( Ok(request) } -pub(crate) async fn dispatch_raw( - app: &App, - req: CfRequest, - env: Env, - ctx: Context, -) -> Result { - dispatch_with_kv(app, req, env, ctx, DEFAULT_KV_BINDING, false).await -} - -/// Low-level manual dispatch. -/// -/// This path does not resolve or inject config-store metadata from a manifest. -/// Prefer `run_app` or `dispatch_with_config` for normal config-store-aware -/// dispatch. Use `dispatch_with_config_handle` only when you already have a -/// prepared `ConfigStoreHandle`. -#[deprecated( - note = "dispatch() is the low-level manual path and does not inject config-store metadata; prefer run_app(), dispatch_with_config(), or dispatch_with_config_handle()" -)] -pub async fn dispatch( +pub(crate) async fn dispatch_with_handles( app: &App, req: CfRequest, env: Env, ctx: Context, + stores: Stores, ) -> Result { - dispatch_raw(app, req, env, ctx).await + let core_request = into_core_request(req, env, ctx) + .await + .map_err(|err| edge_error_to_worker(&err))?; + dispatch_core_request(app, core_request, stores).await } -/// Dispatch a Cloudflare Worker request with a custom KV binding name. +/// Dispatch with per-id store registries built from baked metadata. /// -/// `kv_required` should be `true` when `[stores.kv]` is explicitly present -/// in the manifest, causing the request to fail if the binding is unavailable -/// rather than silently degrading. -pub async fn dispatch_with_kv( +/// Cloudflare capability map: +/// - KV (Multi): each declared id opens its own KV namespace binding via +/// `EDGEZERO__STORES__KV____NAME` (default = id). +/// - Config (Multi): each declared id opens its own KV namespace via +/// `EDGEZERO__STORES__CONFIG____NAME`, read asynchronously. +/// - Secrets (Single): one shared [`CloudflareSecretStore`] is registered +/// under every declared id. +pub(crate) async fn dispatch_with_registries( app: &App, req: CfRequest, env: Env, ctx: Context, - kv_binding: &str, - kv_required: bool, + inputs: RegistryInputs<'_>, ) -> Result { - let kv = resolve_kv_handle(&env, kv_binding, kv_required)?; + let kv_registry = build_kv_registry(&env, inputs.kv_meta, inputs.env_config)?; + let config_registry = build_config_registry(&env, inputs.config_meta, inputs.env_config); + let secret_registry = build_secret_registry(&env, inputs.secret_meta, inputs.env_config); dispatch_with_handles( app, req, env, ctx, Stores { - kv, + config_registry, + kv_registry, + secret_registry, ..Default::default() }, ) .await } -/// Dispatch a request with a prepared config-store handle injected. -/// -/// This is the advanced/manual path. Prefer `dispatch_with_config` when you -/// want the adapter to resolve the configured backend for you. -/// -/// The KV namespace bound to [`DEFAULT_KV_BINDING`] is also resolved and injected -/// (non-required: missing bindings are silently skipped). -pub async fn dispatch_with_config_handle( - app: &App, - req: CfRequest, - env: Env, - ctx: Context, - config_store_handle: ConfigStoreHandle, -) -> Result { - let kv = resolve_kv_handle(&env, DEFAULT_KV_BINDING, false)?; - dispatch_with_handles( - app, - req, - env, - ctx, - Stores { - config_store: Some(config_store_handle), - kv, - ..Default::default() - }, - ) - .await +pub(crate) fn resolve_kv_handle( + env: &Env, + kv_binding: &str, + kv_required: bool, +) -> Result, WorkerError> { + match CloudflareKvStore::from_env(env, kv_binding) { + Ok(store) => Ok(Some(KvHandle::new(Arc::new(store)))), + Err(err) => { + if kv_required { + return Err(WorkerError::RustError(format!( + "KV binding '{kv_binding}' is explicitly configured but could not be opened: {err}" + ))); + } + warn_missing_kv_binding_once(kv_binding, &err); + Ok(None) + } + } } -/// Dispatch a request with a Cloudflare JSON config store injected. -/// -/// Reads `binding_name` from `env` (a `[vars]` string whose value is a JSON object), -/// parses it into a `CloudflareConfigStore`, and injects the handle before dispatch -/// when the binding is present and valid. -/// -/// The KV namespace bound to [`DEFAULT_KV_BINDING`] is also resolved and injected -/// (non-required: missing bindings are silently skipped). -pub async fn dispatch_with_config( - app: &App, - req: CfRequest, - env: Env, - ctx: Context, - binding_name: &str, -) -> Result { - let config_store_handle = CloudflareConfigStore::try_new(&env, binding_name) - .map(|store| ConfigStoreHandle::new(Arc::new(store))); - let kv = resolve_kv_handle(&env, DEFAULT_KV_BINDING, false)?; - dispatch_with_handles( - app, - req, - env, - ctx, - Stores { - config_store: config_store_handle, - kv, - ..Default::default() - }, - ) - .await -} +pub(crate) fn resolve_secret_handle(env: &Env, secrets_required: bool) -> Option { + if !secrets_required { + return None; + } -pub(crate) async fn dispatch_with_bindings( - app: &App, - req: CfRequest, - env: Env, - ctx: Context, - config_binding: Option<&str>, - kv_binding: &str, - kv_required: bool, - secrets_required: bool, -) -> Result { - let config_store_handle = config_binding.and_then(|binding_name| { - CloudflareConfigStore::try_new(&env, binding_name) - .map(|store| ConfigStoreHandle::new(Arc::new(store))) - }); - let kv = resolve_kv_handle(&env, kv_binding, kv_required)?; - let secrets = resolve_secret_handle(&env, secrets_required); - dispatch_with_handles( - app, - req, - env, - ctx, - Stores { - config_store: config_store_handle, - kv, - secrets, - }, - ) - .await + let secret_store = CloudflareSecretStore::from_env(env.clone()); + Some(SecretHandle::new(Arc::new(secret_store))) } -/// Dispatch a Cloudflare Worker request with a secret store attached (no KV store). -/// -/// Use this when your application accesses secrets but does not need a KV store. -/// For applications that need both, use [`dispatch_with_kv_and_secrets`] instead. -/// -/// For most applications, prefer [`crate::run_app`] which resolves all stores -/// from the manifest automatically. Use `dispatch_with_secrets` only when you -/// need direct control over the dispatch lifecycle without a manifest. -/// -/// The store is only attached when `secrets_required` is `true`. -/// Individual missing secrets surface as `SecretError::NotFound` at access time. -pub async fn dispatch_with_secrets( - app: &App, - req: CfRequest, - env: Env, - ctx: Context, - secrets_required: bool, -) -> Result { - let secrets = resolve_secret_handle(&env, secrets_required); - dispatch_with_handles( - app, - req, - env, - ctx, - Stores { - secrets, - ..Default::default() - }, - ) - .await +fn build_config_registry( + env: &Env, + config_meta: Option, + env_config: &EnvConfig, +) -> Option { + let meta = config_meta?; + let mut by_id: BTreeMap = BTreeMap::new(); + for id in meta.ids { + let binding = env_config.store_name("config", id); + if let Some(handle) = open_config_or_warn(env, &binding) { + by_id.insert((*id).to_owned(), handle); + } + } + let default_id = meta.default.to_owned(); + if !by_id.contains_key(&default_id) { + log::warn!( + "config registry default id `{default_id}` could not be opened; dropping the config registry" + ); + } + StoreRegistry::from_parts(by_id, default_id) } -/// Dispatch a Cloudflare Worker request with both KV and secret stores attached. -/// -/// Note: Cloudflare secrets have no namespace concept, so no secret binding name is needed. -/// -/// For most applications, prefer [`crate::run_app`] which resolves all stores -/// from the manifest automatically. Use `dispatch_with_kv_and_secrets` only -/// when you need direct control over the dispatch lifecycle without a manifest. -pub async fn dispatch_with_kv_and_secrets( - app: &App, - req: CfRequest, - env: Env, - ctx: Context, - kv_binding: &str, - kv_required: bool, - secrets_required: bool, -) -> Result { - let kv = resolve_kv_handle(&env, kv_binding, kv_required)?; - let secrets = resolve_secret_handle(&env, secrets_required); - dispatch_with_handles( - app, - req, - env, - ctx, - Stores { - kv, - secrets, - ..Default::default() - }, - ) - .await +fn build_kv_registry( + env: &Env, + kv_meta: Option, + env_config: &EnvConfig, +) -> Result, WorkerError> { + let Some(meta) = kv_meta else { + return Ok(None); + }; + let mut by_id: BTreeMap = BTreeMap::new(); + for id in meta.ids { + let binding = env_config.store_name("kv", id); + // Required per-id: `[stores.kv]` is declared, so failure to open is a + // runtime error rather than a silent skip. + let Some(handle) = resolve_kv_handle(env, &binding, true)? else { + continue; + }; + by_id.insert((*id).to_owned(), handle); + } + let default_id = meta.default.to_owned(); + if !by_id.contains_key(&default_id) { + log::warn!( + "KV registry default id `{default_id}` could not be opened; dropping the KV registry" + ); + } + Ok(StoreRegistry::from_parts(by_id, default_id)) } -pub(crate) async fn dispatch_with_handles( - app: &App, - req: CfRequest, - env: Env, - ctx: Context, - stores: Stores, -) -> Result { - let core_request = into_core_request(req, env, ctx) - .await - .map_err(edge_error_to_worker)?; - dispatch_core_request(app, core_request, stores).await +fn build_secret_registry( + env: &Env, + secret_meta: Option, + env_config: &EnvConfig, +) -> Option { + let meta = secret_meta?; + // Cloudflare is `Single` for secrets — one shared handle binds every id. + // `CloudflareSecretStore::get_bytes` ignores `store_name` (worker + // secrets are a flat namespace), so the per-id bound name is + // observable only via [`BoundSecretStore::store_name`]. + let handle = SecretHandle::new(Arc::new(CloudflareSecretStore::from_env(env.clone()))); + let mut by_id: BTreeMap = BTreeMap::new(); + for id in meta.ids { + let store_name = env_config.store_name("secrets", id); + by_id.insert( + (*id).to_owned(), + BoundSecretStore::new(handle.clone(), store_name), + ); + } + // Cloudflare secret handles are infallible to construct; `from_parts` + // keeps the API symmetric with the KV / config builders. + StoreRegistry::from_parts(by_id, meta.default.to_owned()) } async fn dispatch_core_request( @@ -297,79 +422,123 @@ async fn dispatch_core_request( mut core_request: Request, stores: Stores, ) -> Result { - if let Some(handle) = stores.config_store { - core_request.extensions_mut().insert(handle); + // Hard-cutoff: see fastly's `dispatch_core_request` + // for the rationale. Only registries go into extensions — + // legacy bare handles are synthesised into a one-id registry + // at the dispatch boundary. + let (config_registry, kv_registry, secret_registry) = synthesise_store_registries(stores); + if let Some(registry) = config_registry { + core_request.extensions_mut().insert(registry); } - if let Some(handle) = stores.kv { - core_request.extensions_mut().insert(handle); + if let Some(registry) = kv_registry { + core_request.extensions_mut().insert(registry); } - if let Some(handle) = stores.secrets { - core_request.extensions_mut().insert(handle); + if let Some(registry) = secret_registry { + core_request.extensions_mut().insert(registry); } let svc = app.router().clone(); - let response = svc.oneshot(core_request).await; - from_core_response(response).map_err(edge_error_to_worker) + let response = svc + .oneshot(core_request) + .await + .map_err(|err| edge_error_to_worker(&err))?; + from_core_response(response).map_err(|err| edge_error_to_worker(&err)) } -pub(crate) fn resolve_kv_handle( - env: &Env, - kv_binding: &str, - kv_required: bool, -) -> Result, WorkerError> { - match crate::key_value_store::CloudflareKvStore::from_env(env, kv_binding) { - Ok(store) => Ok(Some(KvHandle::new(std::sync::Arc::new(store)))), - Err(e) => { - if kv_required { - return Err(WorkerError::RustError(format!( - "KV binding '{}' is explicitly configured but could not be opened: {}", - kv_binding, e - ))); - } - warn_missing_kv_binding_once(kv_binding, &e); - Ok(None) - } - } +fn edge_error_to_worker(err: &EdgeError) -> WorkerError { + WorkerError::RustError(err.to_string()) } -pub(crate) fn resolve_secret_handle(env: &Env, secrets_required: bool) -> Option { - if !secrets_required { - return None; - } +fn into_core_method(method: &Method) -> CoreMethod { + let bytes = method.as_ref().as_bytes(); + CoreMethod::from_bytes(bytes).unwrap_or_else(|_| { + log::warn!( + "unknown HTTP method {:?}, defaulting to GET", + method.as_ref() + ); + CoreMethod::GET + }) +} - let secret_store = crate::secret_store::CloudflareSecretStore::from_env(env.clone()); - Some(SecretHandle::new(std::sync::Arc::new(secret_store))) +fn open_config_or_warn(env: &Env, binding_name: &str) -> Option { + match CloudflareConfigStore::from_env(env, binding_name) { + Ok(store) => Some(ConfigStoreHandle::new(Arc::new(store))), + Err(err) => { + warn_missing_config_binding_once(binding_name, &err.to_string()); + None + } + } } -fn edge_error_to_worker(err: EdgeError) -> WorkerError { - WorkerError::RustError(err.to_string()) +/// Pure synthesis: collapse a `Stores` (which may carry both a +/// wired multi-id registry AND a legacy bare handle) into the +/// three registries that go into request extensions. Precedence +/// is "registry wins": a wired registry is taken verbatim; only +/// in its absence is a bare handle wrapped into a one-id registry +/// keyed under `"default"`. The bare handle is never merged in, +/// never used as a fallback for ids the registry doesn't define. +/// Pulled out as a pure function so the precedence contract is +/// unit-testable without spinning up a real `Request` and async +/// dispatcher. +fn synthesise_store_registries( + stores: Stores, +) -> ( + Option, + Option, + Option, +) { + let config_registry = stores.config_registry.or_else(|| { + stores + .config_store + .map(|handle| ConfigRegistry::single_id("default".to_owned(), handle)) + }); + let kv_registry = stores.kv_registry.or_else(|| { + stores + .kv + .map(|handle| KvRegistry::single_id("default".to_owned(), handle)) + }); + let secret_registry = stores.secret_registry.or_else(|| { + stores.secrets.map(|handle| { + SecretRegistry::single_id( + "default".to_owned(), + BoundSecretStore::new(handle, "default".to_owned()), + ) + }) + }); + (config_registry, kv_registry, secret_registry) } -fn warn_missing_kv_binding_once(kv_binding: &str, error: &impl std::fmt::Display) { +fn warn_missing_config_binding_once(binding: &str, error: &impl Display) { static WARNED_BINDINGS: OnceLock>> = OnceLock::new(); let warned_bindings = WARNED_BINDINGS.get_or_init(|| Mutex::new(BTreeSet::new())); match warned_bindings.lock() { - Ok(mut warned_bindings) => { - if !warned_bindings.insert(kv_binding.to_string()) { + Ok(mut guard) => { + if !guard.insert(binding.to_owned()) { return; } - log::warn!("KV binding '{}' not available: {}", kv_binding, error); + log::warn!("config KV binding '{binding}' not available: {error}"); } Err(_) => { - log::warn!("KV binding '{}' not available: {}", kv_binding, error); + log::warn!("config KV binding '{binding}' not available: {error}"); } } } -fn into_core_method(method: Method) -> CoreMethod { - let bytes = method.as_ref().as_bytes(); - CoreMethod::from_bytes(bytes).unwrap_or_else(|_| { - log::warn!( - "unknown HTTP method {:?}, defaulting to GET", - method.as_ref() - ); - CoreMethod::GET - }) +fn warn_missing_kv_binding_once(kv_binding: &str, error: &impl Display) { + static WARNED_BINDINGS: OnceLock>> = OnceLock::new(); + let warned_bindings = WARNED_BINDINGS.get_or_init(|| Mutex::new(BTreeSet::new())); + + match warned_bindings.lock() { + Ok(mut guard) => { + if !guard.insert(kv_binding.to_owned()) { + return; + } + log::warn!("KV binding '{kv_binding}' not available: {error}"); + } + Err(_) => { + log::warn!("KV binding '{kv_binding}' not available: {error}"); + } + } } #[cfg(test)] @@ -378,16 +547,112 @@ mod tests { use wasm_bindgen_test::wasm_bindgen_test; #[wasm_bindgen_test] - fn into_http_method_maps_known_methods() { - assert_eq!(into_core_method(Method::Get), CoreMethod::GET); - assert_eq!(into_core_method(Method::Post), CoreMethod::POST); - assert_eq!(into_core_method(Method::Put), CoreMethod::PUT); - assert_eq!(into_core_method(Method::Delete), CoreMethod::DELETE); + fn into_http_method_defaults_unknown_to_get() { + let method = Method::from("FOO".to_owned()); + assert_eq!(into_core_method(&method), CoreMethod::GET); } #[wasm_bindgen_test] - fn into_http_method_defaults_unknown_to_get() { - let method = Method::from("FOO".to_string()); - assert_eq!(into_core_method(method), CoreMethod::GET); + fn into_http_method_maps_known_methods() { + assert_eq!(into_core_method(&Method::Get), CoreMethod::GET); + assert_eq!(into_core_method(&Method::Post), CoreMethod::POST); + assert_eq!(into_core_method(&Method::Put), CoreMethod::PUT); + assert_eq!(into_core_method(&Method::Delete), CoreMethod::DELETE); + } +} + +#[cfg(test)] +mod synthesis_tests { + use std::collections::BTreeMap; + use std::sync::Arc; + + use edgezero_core::config_store::{ConfigStore, ConfigStoreError, ConfigStoreHandle}; + use edgezero_core::key_value_store::{KvStore, NoopKvStore}; + use edgezero_core::secret_store::{NoopSecretStore, SecretHandle}; + + use super::*; + + struct StubConfig; + #[async_trait::async_trait(?Send)] + impl ConfigStore for StubConfig { + async fn get(&self, _key: &str) -> Result, ConfigStoreError> { + Ok(None) + } + } + + fn config_handle() -> ConfigStoreHandle { + ConfigStoreHandle::new(Arc::new(StubConfig)) + } + + fn kv_handle() -> KvHandle { + let store: Arc = Arc::new(NoopKvStore); + KvHandle::new(store) + } + + fn secret_handle() -> SecretHandle { + SecretHandle::new(Arc::new(NoopSecretStore)) + } + + #[test] + fn synthesis_handles_config_and_secret_bare_handles_symmetrically() { + let stores = Stores { + config_store: Some(config_handle()), + secrets: Some(secret_handle()), + ..Default::default() + }; + let (config, _, secret) = synthesise_store_registries(stores); + assert_eq!(config.expect("config").default_id(), "default"); + let secret_registry = secret.expect("secret"); + assert_eq!(secret_registry.default_id(), "default"); + // BoundSecretStore binds the synthesised secret to platform + // store name "default". A handler reading via + // `ctx.secret_store_default()?.require_str(key)` resolves + // the cloudflare Worker Secret literally named "default"; + // if the operator's wrangler.toml uses a different name, + // the runtime require_str() surfaces a clear store-name + // error rather than a silent miss. + assert_eq!( + secret_registry.default().expect("bound").store_name(), + "default" + ); + } + + #[test] + fn synthesis_registry_wins_over_bare_handle_when_both_wired() { + let mut by_id: BTreeMap = BTreeMap::new(); + by_id.insert("sessions".to_owned(), kv_handle()); + let registry = KvRegistry::new(by_id, "sessions".to_owned()); + let stores = Stores { + kv: Some(kv_handle()), + kv_registry: Some(registry), + ..Default::default() + }; + let (_, kv, _) = synthesise_store_registries(stores); + let kv_registry = kv.expect("registry survives"); + assert_eq!(kv_registry.default_id(), "sessions"); + assert!( + kv_registry.named("default").is_none(), + "bare handle's `default` synth NOT merged in" + ); + } + + #[test] + fn synthesis_returns_none_for_each_kind_with_no_wiring() { + let (config, kv, secret) = synthesise_store_registries(Stores::default()); + assert!(config.is_none() && kv.is_none() && secret.is_none()); + } + + #[test] + fn synthesis_wraps_bare_kv_handle_under_default_when_no_registry() { + let stores = Stores { + kv: Some(kv_handle()), + ..Default::default() + }; + let (config, kv, secret) = synthesise_store_registries(stores); + assert!(config.is_none()); + assert!(secret.is_none()); + let kv_registry = kv.expect("kv registry synthesised"); + assert_eq!(kv_registry.default_id(), "default"); + assert!(kv_registry.named("other").is_none()); } } diff --git a/crates/edgezero-adapter-cloudflare/src/response.rs b/crates/edgezero-adapter-cloudflare/src/response.rs index 43d82fa2..7843b899 100644 --- a/crates/edgezero-adapter-cloudflare/src/response.rs +++ b/crates/edgezero-adapter-cloudflare/src/response.rs @@ -1,13 +1,21 @@ use edgezero_core::body::Body; use edgezero_core::error::EdgeError; use edgezero_core::http::Response; -use futures_util::StreamExt; +use futures_util::StreamExt as _; use worker::{Error as WorkerError, Response as CfResponse}; +/// Convert an `EdgeZero` `Response` into a Cloudflare Worker `Response`. +/// +/// # Errors +/// Returns an [`EdgeError`] if the response body cannot be materialised +/// into a Workers response (empty body construction failure, byte body +/// conversion failure, stream adoption failure) or if any response +/// header is non-UTF-8 and the Workers header table rejects it. +#[inline] pub fn from_core_response(response: Response) -> Result { let (parts, body) = response.into_parts(); - let cf_response = match body { + let body_response = match body { Body::Once(bytes) if bytes.is_empty() => { CfResponse::empty().map_err(EdgeError::internal)? } @@ -23,9 +31,9 @@ pub fn from_core_response(response: Response) -> Result { } }; - let mut cf_response = cf_response.with_status(parts.status.as_u16()); + let mut cf_response = body_response.with_status(parts.status.as_u16()); let headers = cf_response.headers_mut(); - for (name, value) in parts.headers.iter() { + for (name, value) in &parts.headers { if let Ok(value_str) = value.to_str() { headers .set(name.as_str(), value_str) @@ -41,10 +49,11 @@ mod tests { use bytes::Bytes; use edgezero_core::body::Body; use edgezero_core::http::response_builder; - use futures_util::{stream, StreamExt}; + use futures::executor::block_on; + use futures_util::stream; #[test] - #[ignore] // Requires worker runtime — cannot construct worker::Response in unit tests + #[ignore = "requires worker runtime — worker::Response cannot be constructed in unit tests"] fn propagates_status_and_headers() { let response = response_builder() .status(201) @@ -67,10 +76,10 @@ mod tests { let mut cf = from_core_response(response).expect("cf response"); let mut byte_stream = cf.stream().expect("byte stream"); - let collected = futures::executor::block_on(async { + let collected = block_on(async { let mut out = Vec::new(); - while let Some(chunk) = byte_stream.next().await { - let chunk = chunk.expect("chunk"); + while let Some(item) = byte_stream.next().await { + let chunk = item.expect("chunk"); out.extend_from_slice(&chunk); } out diff --git a/crates/edgezero-adapter-cloudflare/src/secret_store.rs b/crates/edgezero-adapter-cloudflare/src/secret_store.rs index e8c3bbe0..bfe5c2b1 100644 --- a/crates/edgezero-adapter-cloudflare/src/secret_store.rs +++ b/crates/edgezero-adapter-cloudflare/src/secret_store.rs @@ -30,6 +30,8 @@ pub struct CloudflareSecretStore { #[cfg(all(feature = "cloudflare", target_arch = "wasm32"))] impl CloudflareSecretStore { /// Create a secret store from a cloned `Env`. + #[inline] + #[must_use] pub fn from_env(env: worker::Env) -> Self { Self { env } } @@ -38,6 +40,7 @@ impl CloudflareSecretStore { #[cfg(all(feature = "cloudflare", target_arch = "wasm32"))] #[async_trait(?Send)] impl SecretStore for CloudflareSecretStore { + #[inline] async fn get_bytes(&self, _store_name: &str, key: &str) -> Result, SecretError> { match self.env.secret(key) { Ok(secret) => { diff --git a/crates/edgezero-adapter-cloudflare/src/templates/Cargo.toml.hbs b/crates/edgezero-adapter-cloudflare/src/templates/Cargo.toml.hbs index 1b9bd7c1..f1b40760 100644 --- a/crates/edgezero-adapter-cloudflare/src/templates/Cargo.toml.hbs +++ b/crates/edgezero-adapter-cloudflare/src/templates/Cargo.toml.hbs @@ -4,6 +4,9 @@ version = "0.1.0" edition = "2021" publish = false +[lints] +workspace = true + [[bin]] name = "{{proj_cloudflare}}" path = "src/main.rs" diff --git a/crates/edgezero-adapter-cloudflare/src/templates/src/lib.rs.hbs b/crates/edgezero-adapter-cloudflare/src/templates/src/lib.rs.hbs index a910af1d..72d2f590 100644 --- a/crates/edgezero-adapter-cloudflare/src/templates/src/lib.rs.hbs +++ b/crates/edgezero-adapter-cloudflare/src/templates/src/lib.rs.hbs @@ -2,11 +2,9 @@ #[cfg(target_arch = "wasm32")] use worker::*; -#[cfg(target_arch = "wasm32")] -use {{proj_core_mod}}::App; #[cfg(target_arch = "wasm32")] #[event(fetch)] pub async fn main(req: Request, env: Env, ctx: Context) -> Result { - edgezero_adapter_cloudflare::run_app::(req, env, ctx).await + edgezero_adapter_cloudflare::run_app::<{{proj_core_mod}}::App>(req, env, ctx).await } diff --git a/crates/edgezero-adapter-cloudflare/src/templates/src/main.rs.hbs b/crates/edgezero-adapter-cloudflare/src/templates/src/main.rs.hbs index ccc937a8..6ba73f29 100644 --- a/crates/edgezero-adapter-cloudflare/src/templates/src/main.rs.hbs +++ b/crates/edgezero-adapter-cloudflare/src/templates/src/main.rs.hbs @@ -1,3 +1,7 @@ +#[expect( + clippy::print_stderr, + reason = "host stub; the real binary only runs on wasm32-unknown-unknown" +)] fn main() { eprintln!( "Run `wrangler dev` or target wasm32-unknown-unknown to execute {{proj_cloudflare}}." diff --git a/crates/edgezero-adapter-cloudflare/tests/contract.rs b/crates/edgezero-adapter-cloudflare/tests/contract.rs index e74b50d7..99f15d86 100644 --- a/crates/edgezero-adapter-cloudflare/tests/contract.rs +++ b/crates/edgezero-adapter-cloudflare/tests/contract.rs @@ -1,257 +1,288 @@ #![cfg(all(feature = "cloudflare", target_arch = "wasm32"))] -// Keep coverage for the deprecated low-level dispatch path while it remains public. -#![allow(deprecated)] - -use bytes::Bytes; -use edgezero_adapter_cloudflare::{ - dispatch, dispatch_with_config, dispatch_with_config_handle, from_core_response, - into_core_request, CloudflareRequestContext, -}; -use edgezero_core::{ - app::App, - body::Body, - config_store::{ConfigStore, ConfigStoreError, ConfigStoreHandle}, - context::RequestContext, - error::EdgeError, - http::{response_builder, Method, Response, StatusCode}, - router::RouterService, -}; -use futures::stream; -use std::sync::Arc; -use wasm_bindgen_test::*; -use worker::wasm_bindgen::{JsCast, JsValue}; -use worker::{Context, Env, Method as CfMethod, Request as CfRequest, RequestInit}; - -wasm_bindgen_test_configure!(run_in_browser); - -struct FixedConfigStore(&'static str); - -impl ConfigStore for FixedConfigStore { - fn get(&self, _key: &str) -> Result, ConfigStoreError> { - Ok(Some(self.0.to_string())) - } -} -fn build_test_app() -> App { - async fn capture_uri(ctx: RequestContext) -> Result { - let body = Body::text(ctx.request().uri().to_string()); - let response = response_builder() - .status(StatusCode::OK) - .body(body) - .expect("response"); - Ok(response) - } +// Compile-time check: CloudflareSecretStore implements SecretStore. +mod secret_store_compile_check { + use edgezero_adapter_cloudflare::secret_store::CloudflareSecretStore; + use edgezero_core::secret_store::SecretStore; - async fn mirror_body(ctx: RequestContext) -> Result { - let bytes = ctx.request().body().as_bytes().to_vec(); - let response = response_builder() - .status(StatusCode::OK) - .body(Body::from(bytes)) - .expect("response"); - Ok(response) - } + fn assert_provider_impl() {} - async fn config_presence(ctx: RequestContext) -> Result { - let present = if ctx.config_store().is_some() { - "yes" - } else { - "no" - }; - let response = response_builder() - .status(StatusCode::OK) - .body(Body::text(present)) - .expect("response"); - Ok(response) - } - - async fn stream_response(_ctx: RequestContext) -> Result { - let chunks = stream::iter(vec![ - Bytes::from_static(b"chunk-1"), - Bytes::from_static(b"chunk-2"), - ]); + // Anonymous const whose initializer is a never-called fn pointer; the + // type bound is checked at type-check time. + const _: fn() = assert_provider_impl::; +} - let response = response_builder() - .status(StatusCode::OK) - .body(Body::stream(chunks)) - .expect("response"); - Ok(response) +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use bytes::Bytes; + use edgezero_adapter_cloudflare::context::CloudflareRequestContext; + use edgezero_adapter_cloudflare::request::{into_core_request, CloudflareService}; + use edgezero_adapter_cloudflare::response::from_core_response; + use edgezero_core::app::App; + use edgezero_core::body::Body; + use edgezero_core::config_store::{ConfigStore, ConfigStoreError, ConfigStoreHandle}; + use edgezero_core::context::RequestContext; + use edgezero_core::error::EdgeError; + use edgezero_core::http::{response_builder, Method, Response, StatusCode}; + use edgezero_core::router::RouterService; + use futures::stream; + use wasm_bindgen_test::{wasm_bindgen_test, wasm_bindgen_test_configure}; + use worker::js_sys::Object; + use worker::wasm_bindgen::{JsCast as _, JsValue}; + use worker::worker_sys::Context as WorkerSysContext; + use worker::{Context, Env, Method as CfMethod, Request as CfRequest, RequestInit}; + + wasm_bindgen_test_configure!(run_in_browser); + + struct FixedConfigStore(&'static str); + + #[async_trait::async_trait(?Send)] + impl ConfigStore for FixedConfigStore { + async fn get(&self, _key: &str) -> Result, ConfigStoreError> { + Ok(Some(self.0.to_owned())) + } } - async fn config_value(ctx: RequestContext) -> Result { - let value = ctx - .config_store() - .and_then(|store| store.get("greeting").ok().flatten()) - .unwrap_or_else(|| "missing".to_string()); - let response = response_builder() - .status(StatusCode::OK) - .body(Body::text(value)) - .expect("response"); - Ok(response) + fn build_test_app() -> App { + async fn capture_uri(ctx: RequestContext) -> Result { + let body = Body::text(ctx.request().uri().to_string()); + let response = response_builder() + .status(StatusCode::OK) + .body(body) + .expect("response"); + Ok(response) + } + + async fn mirror_body(ctx: RequestContext) -> Result { + let bytes = ctx.request().body().as_bytes().expect("buffered").to_vec(); + let response = response_builder() + .status(StatusCode::OK) + .body(Body::from(bytes)) + .expect("response"); + Ok(response) + } + + async fn config_presence(ctx: RequestContext) -> Result { + // Hard-cutoff: legacy `ctx.config_handle()` is + // gone. The dispatch boundary now synthesises a one-id + // `ConfigRegistry` from the wired `ConfigStoreHandle`, so + // the registry-aware accessor resolves the same store. + let present = if ctx.config_store_default().is_some() { + "yes" + } else { + "no" + }; + let response = response_builder() + .status(StatusCode::OK) + .body(Body::text(present)) + .expect("response"); + Ok(response) + } + + async fn stream_response(_ctx: RequestContext) -> Result { + let chunks = stream::iter(vec![ + Bytes::from_static(b"chunk-1"), + Bytes::from_static(b"chunk-2"), + ]); + + let response = response_builder() + .status(StatusCode::OK) + .body(Body::stream(chunks)) + .expect("response"); + Ok(response) + } + + async fn config_value(ctx: RequestContext) -> Result { + // Hard-cutoff: legacy `ctx.config_handle()` is + // gone. See `config_presence` for the migration rationale. + let value = match ctx.config_store_default() { + Some(store) => store + .get("greeting") + .await + .ok() + .flatten() + .unwrap_or_else(|| "missing".to_owned()), + None => "missing".to_owned(), + }; + let response = response_builder() + .status(StatusCode::OK) + .body(Body::text(value)) + .expect("response"); + Ok(response) + } + + let router = RouterService::builder() + .get("/uri", capture_uri) + .post("/mirror", mirror_body) + .get("/stream", stream_response) + .get("/has-config", config_presence) + .get("/config-value", config_value) + .build(); + + App::new(router) } - let router = RouterService::builder() - .get("/uri", capture_uri) - .post("/mirror", mirror_body) - .get("/stream", stream_response) - .get("/has-config", config_presence) - .get("/config-value", config_value) - .build(); + fn cf_request(method: CfMethod, path: &str, body: Option<&[u8]>) -> CfRequest { + use worker::js_sys::Uint8Array; - App::new(router) -} + let mut init = RequestInit::new(); + init.with_method(method); -fn cf_request(method: CfMethod, path: &str, body: Option<&[u8]>) -> CfRequest { - use worker::js_sys::Uint8Array; + let headers = worker::Headers::new(); + headers.set("host", "example.com").expect("host header"); + headers.set("x-edgezero-test", "1").expect("custom header"); + init.with_headers(headers); - let mut init = RequestInit::new(); - init.with_method(method); + if let Some(bytes) = body { + let array = Uint8Array::from(bytes); + init.with_body(Some(JsValue::from(array))); // Uint8Array -> JsValue + } - let headers = worker::Headers::new(); - headers.set("host", "example.com").expect("host header"); - headers.set("x-edgezero-test", "1").expect("custom header"); - init.with_headers(headers); - - if let Some(bytes) = body { - let array = Uint8Array::from(bytes); - init.with_body(Some(JsValue::from(array))); // Uint8Array -> JsValue + let url = format!("https://example.com{path}"); + CfRequest::new_with_init(&url, &init).expect("cf request") } - let url = format!("https://example.com{}", path); - CfRequest::new_with_init(&url, &init).expect("cf request") -} - -fn test_env_ctx() -> (Env, Context) { - let env = worker::js_sys::Object::new().unchecked_into::(); - let js_context = worker::js_sys::Object::new().unchecked_into::(); - (env, Context::new(js_context)) -} - -#[wasm_bindgen_test] -async fn into_core_request_preserves_method_uri_headers_body_and_context() { - let req = cf_request(CfMethod::Post, "/mirror?foo=bar", Some(b"payload")); - let (env, ctx) = test_env_ctx(); + fn test_env_ctx() -> (Env, Context) { + let env = Object::new().unchecked_into::(); + let js_context = Object::new().unchecked_into::(); + (env, Context::new(js_context)) + } - let core_request = into_core_request(req, env, ctx) - .await - .expect("core request"); + #[wasm_bindgen_test] + async fn dispatch_passes_request_body_to_handlers() { + let app = build_test_app(); + let req = cf_request(CfMethod::Post, "/mirror", Some(b"echo")); + let (env, ctx) = test_env_ctx(); - assert_eq!(core_request.method(), &Method::POST); - assert_eq!(core_request.uri().path(), "/mirror"); - assert_eq!(core_request.uri().query(), Some("foo=bar")); + let mut response = CloudflareService::new(&app) + .dispatch(req, env, ctx) + .await + .expect("cf response"); - let header = core_request - .headers() - .get("x-edgezero-test") - .and_then(|value| value.to_str().ok()); - assert_eq!(header, Some("1")); + assert_eq!(response.status_code(), StatusCode::OK.as_u16()); + let bytes = response.bytes().await.expect("bytes"); + assert_eq!(bytes.as_slice(), b"echo"); + } - assert_eq!(core_request.body().as_bytes(), b"payload"); + #[wasm_bindgen_test] + async fn dispatch_runs_router_and_returns_response() { + let app = build_test_app(); + let req = cf_request(CfMethod::Get, "/uri", None); + let (env, ctx) = test_env_ctx(); - assert!(CloudflareRequestContext::get(&core_request).is_some()); -} + let mut response = CloudflareService::new(&app) + .dispatch(req, env, ctx) + .await + .expect("cf response"); -#[wasm_bindgen_test] -async fn from_core_response_translates_status_headers_and_streaming_body() { - let response = response_builder() - .status(StatusCode::CREATED) - .header("x-edgezero-res", "1") - .body(Body::stream(stream::iter(vec![ - Bytes::from_static(b"hello"), - Bytes::from_static(b" "), - Bytes::from_static(b"world"), - ]))) - .expect("response"); - - let mut cf_response = from_core_response(response).expect("cf response"); - - assert_eq!(cf_response.status_code(), StatusCode::CREATED.as_u16()); - let header = cf_response.headers().get("x-edgezero-res").unwrap(); - assert_eq!(header.as_deref(), Some("1")); - - let bytes = cf_response.bytes().await.expect("bytes"); - assert_eq!(bytes.as_slice(), b"hello world"); -} + assert_eq!(response.status_code(), StatusCode::OK.as_u16()); + let body = response.text().await.expect("text"); + assert_eq!(body, "https://example.com/uri"); + } -#[wasm_bindgen_test] -async fn dispatch_runs_router_and_returns_response() { - let app = build_test_app(); - let req = cf_request(CfMethod::Get, "/uri", None); - let (env, ctx) = test_env_ctx(); + #[wasm_bindgen_test] + async fn dispatch_streaming_route_preserves_chunks() { + let app = build_test_app(); + let req = cf_request(CfMethod::Get, "/stream", None); + let (env, ctx) = test_env_ctx(); - let mut response = dispatch(&app, req, env, ctx).await.expect("cf response"); + let mut response = CloudflareService::new(&app) + .dispatch(req, env, ctx) + .await + .expect("cf response"); - assert_eq!(response.status_code(), StatusCode::OK.as_u16()); - let body = response.text().await.expect("text"); - assert_eq!(body, "https://example.com/uri"); -} + assert_eq!(response.status_code(), StatusCode::OK.as_u16()); + let bytes = response.bytes().await.expect("bytes"); + assert_eq!(bytes.as_slice(), b"chunk-1chunk-2"); + } -#[wasm_bindgen_test] -async fn dispatch_streaming_route_preserves_chunks() { - let app = build_test_app(); - let req = cf_request(CfMethod::Get, "/stream", None); - let (env, ctx) = test_env_ctx(); + #[wasm_bindgen_test] + async fn from_core_response_translates_status_headers_and_streaming_body() { + let response = response_builder() + .status(StatusCode::CREATED) + .header("x-edgezero-res", "1") + .body(Body::stream(stream::iter(vec![ + Bytes::from_static(b"hello"), + Bytes::from_static(b" "), + Bytes::from_static(b"world"), + ]))) + .expect("response"); - let mut response = dispatch(&app, req, env, ctx).await.expect("cf response"); + let mut cf_response = from_core_response(response).expect("cf response"); - assert_eq!(response.status_code(), StatusCode::OK.as_u16()); - let bytes = response.bytes().await.expect("bytes"); - assert_eq!(bytes.as_slice(), b"chunk-1chunk-2"); -} + assert_eq!(cf_response.status_code(), StatusCode::CREATED.as_u16()); + let header = cf_response.headers().get("x-edgezero-res").unwrap(); + assert_eq!(header.as_deref(), Some("1")); -#[wasm_bindgen_test] -async fn dispatch_passes_request_body_to_handlers() { - let app = build_test_app(); - let req = cf_request(CfMethod::Post, "/mirror", Some(b"echo")); - let (env, ctx) = test_env_ctx(); + let bytes = cf_response.bytes().await.expect("bytes"); + assert_eq!(bytes.as_slice(), b"hello world"); + } - let mut response = dispatch(&app, req, env, ctx).await.expect("cf response"); + #[wasm_bindgen_test] + async fn into_core_request_preserves_method_uri_headers_body_and_context() { + let req = cf_request(CfMethod::Post, "/mirror?foo=bar", Some(b"payload")); + let (env, ctx) = test_env_ctx(); - assert_eq!(response.status_code(), StatusCode::OK.as_u16()); - let bytes = response.bytes().await.expect("bytes"); - assert_eq!(bytes.as_slice(), b"echo"); -} + let core_request = into_core_request(req, env, ctx) + .await + .expect("core request"); -#[wasm_bindgen_test] -async fn dispatch_with_config_missing_binding_skips_injection() { - // The test env is an empty JS object; any env.var() call returns None. - // dispatch_with_config should log a warning and dispatch without injecting - // a config-store handle, so the handler receives ctx.config_store() == None. - let app = build_test_app(); - let req = cf_request(CfMethod::Get, "/has-config", None); - let (env, ctx) = test_env_ctx(); - - let mut response = dispatch_with_config(&app, req, env, ctx, "nonexistent_binding") - .await - .expect("cf response"); - - assert_eq!(response.status_code(), StatusCode::OK.as_u16()); - let body = response.text().await.expect("text"); - assert_eq!(body, "no"); -} + assert_eq!(core_request.method(), &Method::POST); + assert_eq!(core_request.uri().path(), "/mirror"); + assert_eq!(core_request.uri().query(), Some("foo=bar")); -#[wasm_bindgen_test] -async fn dispatch_with_config_handle_injects_handle() { - let app = build_test_app(); - let req = cf_request(CfMethod::Get, "/config-value", None); - let (env, ctx) = test_env_ctx(); - let handle = ConfigStoreHandle::new(Arc::new(FixedConfigStore("hello from cf test"))); + let header = core_request + .headers() + .get("x-edgezero-test") + .and_then(|value| value.to_str().ok()); + assert_eq!(header, Some("1")); - let mut response = dispatch_with_config_handle(&app, req, env, ctx, handle) - .await - .expect("cf response"); + assert_eq!( + core_request.body().as_bytes().expect("buffered"), + b"payload" + ); - assert_eq!(response.status_code(), StatusCode::OK.as_u16()); - let body = response.text().await.expect("text"); - assert_eq!(body, "hello from cf test"); -} + assert!(CloudflareRequestContext::get(&core_request).is_some()); + } -#[cfg(all(feature = "cloudflare", target_arch = "wasm32"))] -mod secret_store_compile_check { - use edgezero_adapter_cloudflare::secret_store::CloudflareSecretStore; - use edgezero_core::secret_store::SecretStore; + #[wasm_bindgen_test] + async fn service_with_config_handle_injects_handle() { + let app = build_test_app(); + let req = cf_request(CfMethod::Get, "/config-value", None); + let (env, ctx) = test_env_ctx(); + let handle = ConfigStoreHandle::new(Arc::new(FixedConfigStore("hello from cf test"))); + + let mut response = CloudflareService::new(&app) + .with_config_handle(handle) + .dispatch(req, env, ctx) + .await + .expect("cf response"); + + assert_eq!(response.status_code(), StatusCode::OK.as_u16()); + let body = response.text().await.expect("text"); + assert_eq!(body, "hello from cf test"); + } - fn _assert_provider_impl() {} - fn _check() { - _assert_provider_impl::(); + #[wasm_bindgen_test] + async fn service_with_config_missing_binding_skips_injection() { + // The test env is an empty JS object; any env.var() call returns None. + // `CloudflareService::with_config(name)` should log a warning and + // dispatch without injecting a config-store handle, so the handler + // sees `ctx.config_store_default()` return `None`. + let app = build_test_app(); + let req = cf_request(CfMethod::Get, "/has-config", None); + let (env, ctx) = test_env_ctx(); + + let mut response = CloudflareService::new(&app) + .with_config("nonexistent_binding") + .dispatch(req, env, ctx) + .await + .expect("cf response"); + + assert_eq!(response.status_code(), StatusCode::OK.as_u16()); + let body = response.text().await.expect("text"); + assert_eq!(body, "no"); } } diff --git a/crates/edgezero-adapter-fastly/Cargo.toml b/crates/edgezero-adapter-fastly/Cargo.toml index f052e574..8e79f513 100644 --- a/crates/edgezero-adapter-fastly/Cargo.toml +++ b/crates/edgezero-adapter-fastly/Cargo.toml @@ -5,12 +5,17 @@ version = { workspace = true } authors = { workspace = true } license = { workspace = true } +[lints] +workspace = true + [features] default = [] cli = [ "dep:edgezero-adapter", "edgezero-adapter/cli", "dep:ctor", + "dep:serde_json", + "dep:toml_edit", "dep:walkdir", ] fastly = ["dep:fastly", "dep:log-fastly"] @@ -34,6 +39,9 @@ log = { workspace = true } log-fastly = { workspace = true, optional = true } fern = { workspace = true } chrono = { workspace = true } +serde_json = { workspace = true, optional = true } +thiserror = { workspace = true } +toml_edit = { workspace = true, optional = true } walkdir = { workspace = true, optional = true } [dev-dependencies] diff --git a/crates/edgezero-adapter-fastly/src/cli.rs b/crates/edgezero-adapter-fastly/src/cli.rs index 8678780f..25470990 100644 --- a/crates/edgezero-adapter-fastly/src/cli.rs +++ b/crates/edgezero-adapter-fastly/src/cli.rs @@ -1,168 +1,23 @@ +use std::env; use std::fs; +use std::io::ErrorKind; use std::path::{Path, PathBuf}; use std::process::Command; use ctor::ctor; use edgezero_adapter::cli_support::{ - find_manifest_upwards, find_workspace_root, path_distance, read_package_name, + find_manifest_upwards, find_workspace_root, path_distance, read_package_name, run_native_cli, +}; +use edgezero_adapter::registry::{ + register_adapter, Adapter, AdapterAction, AdapterPushContext, ProvisionStores, ResolvedStoreId, }; use edgezero_adapter::scaffold::{ register_adapter_blueprint, AdapterBlueprint, AdapterFileSpec, CommandTemplates, DependencySpec, LoggingDefaults, ManifestSpec, ReadmeInfo, TemplateRegistration, }; -use edgezero_adapter::{register_adapter, Adapter, AdapterAction}; use walkdir::WalkDir; -pub fn build(extra_args: &[String]) -> Result { - let manifest = find_fastly_manifest( - std::env::current_dir() - .map_err(|e| e.to_string())? - .as_path(), - )?; - let manifest_dir = manifest - .parent() - .ok_or_else(|| "fastly manifest has no parent directory".to_string())?; - let cargo_manifest = manifest_dir.join("Cargo.toml"); - let crate_name = read_package_name(&cargo_manifest)?; - - let status = Command::new("cargo") - .args([ - "build", - "--release", - "--target", - "wasm32-wasip1", - "--manifest-path", - cargo_manifest - .to_str() - .ok_or("invalid Cargo manifest path")?, - ]) - .args(extra_args) - .status() - .map_err(|e| format!("failed to run cargo build: {e}"))?; - if !status.success() { - return Err(format!("cargo build failed with status {status}")); - } - - let workspace_root = find_workspace_root(manifest_dir); - let artifact = locate_artifact(&workspace_root, manifest_dir, &crate_name)?; - let pkg_dir = workspace_root.join("pkg"); - fs::create_dir_all(&pkg_dir) - .map_err(|e| format!("failed to create {}: {e}", pkg_dir.display()))?; - let dest = pkg_dir.join(format!("{}.wasm", crate_name.replace('-', "_"))); - fs::copy(&artifact, &dest) - .map_err(|e| format!("failed to copy artifact to {}: {e}", dest.display()))?; - - Ok(dest) -} - -pub fn deploy(extra_args: &[String]) -> Result<(), String> { - let manifest = find_fastly_manifest( - std::env::current_dir() - .map_err(|e| e.to_string())? - .as_path(), - )?; - let manifest_dir = manifest - .parent() - .ok_or_else(|| "fastly manifest has no parent directory".to_string())?; - - let status = Command::new("fastly") - .args(["compute", "deploy"]) - .args(extra_args) - .current_dir(manifest_dir) - .status() - .map_err(|e| format!("failed to run fastly CLI: {e}"))?; - if !status.success() { - return Err(format!("fastly compute deploy failed with status {status}")); - } - - Ok(()) -} - -pub fn serve(extra_args: &[String]) -> Result<(), String> { - let manifest = find_fastly_manifest( - std::env::current_dir() - .map_err(|e| e.to_string())? - .as_path(), - )?; - let manifest_dir = manifest - .parent() - .ok_or_else(|| "fastly manifest has no parent directory".to_string())?; - - let status = Command::new("fastly") - .args(["compute", "serve"]) - .args(extra_args) - .current_dir(manifest_dir) - .status() - .map_err(|e| format!("failed to run fastly CLI: {e}"))?; - if !status.success() { - return Err(format!("fastly compute serve failed with status {status}")); - } - - Ok(()) -} - -struct FastlyCliAdapter; - -static FASTLY_TEMPLATE_REGISTRATIONS: &[TemplateRegistration] = &[ - TemplateRegistration { - name: "fastly_Cargo_toml", - contents: include_str!("templates/Cargo.toml.hbs"), - }, - TemplateRegistration { - name: "fastly_src_main_rs", - contents: include_str!("templates/src/main.rs.hbs"), - }, - TemplateRegistration { - name: "fastly_cargo_config_toml", - contents: include_str!("templates/.cargo/config.toml.hbs"), - }, - TemplateRegistration { - name: "fastly_fastly_toml", - contents: include_str!("templates/fastly.toml.hbs"), - }, -]; - -static FASTLY_FILE_SPECS: &[AdapterFileSpec] = &[ - AdapterFileSpec { - template: "fastly_Cargo_toml", - output: "Cargo.toml", - }, - AdapterFileSpec { - template: "fastly_src_main_rs", - output: "src/main.rs", - }, - AdapterFileSpec { - template: "fastly_cargo_config_toml", - output: ".cargo/config.toml", - }, - AdapterFileSpec { - template: "fastly_fastly_toml", - output: "fastly.toml", - }, -]; - -static FASTLY_DEPENDENCIES: &[DependencySpec] = &[ - DependencySpec { - key: "dep_edgezero_core_fastly", - repo_crate: "crates/edgezero-core", - fallback: "edgezero-core = { git = \"https://git@github.com/stackpop/edgezero.git\", package = \"edgezero-core\", default-features = false }", - features: &[], - }, - DependencySpec { - key: "dep_edgezero_adapter_fastly", - repo_crate: "crates/edgezero-adapter-fastly", - fallback: - "edgezero-adapter-fastly = { git = \"https://git@github.com/stackpop/edgezero.git\", package = \"edgezero-adapter-fastly\", default-features = false }", - features: &[], - }, - DependencySpec { - key: "dep_edgezero_adapter_fastly_wasm", - repo_crate: "crates/edgezero-adapter-fastly", - fallback: - "edgezero-adapter-fastly = { git = \"https://git@github.com/stackpop/edgezero.git\", package = \"edgezero-adapter-fastly\", default-features = false, features = [\"fastly\"] }", - features: &["fastly"], - }, -]; +static FASTLY_ADAPTER: FastlyCliAdapter = FastlyCliAdapter; static FASTLY_BLUEPRINT: AdapterBlueprint = AdapterBlueprint { id: "fastly", @@ -193,39 +48,788 @@ static FASTLY_BLUEPRINT: AdapterBlueprint = AdapterBlueprint { readme: ReadmeInfo { description: "{display} entrypoint.", dev_heading: "{display} (local)", - dev_steps: &["`cd {crate_dir}`", "`edgezero-cli serve --adapter fastly`"], + dev_steps: &["`cd {crate_dir}`", "`edgezero serve --adapter fastly`"], }, run_module: "edgezero_adapter_fastly", }; -static FASTLY_ADAPTER: FastlyCliAdapter = FastlyCliAdapter; +static FASTLY_DEPENDENCIES: &[DependencySpec] = &[ + DependencySpec { + key: "dep_edgezero_core_fastly", + repo_crate: "crates/edgezero-core", + fallback: "edgezero-core = { git = \"https://git@github.com/stackpop/edgezero.git\", package = \"edgezero-core\", default-features = false }", + features: &[], + }, + DependencySpec { + key: "dep_edgezero_adapter_fastly", + repo_crate: "crates/edgezero-adapter-fastly", + fallback: + "edgezero-adapter-fastly = { git = \"https://git@github.com/stackpop/edgezero.git\", package = \"edgezero-adapter-fastly\", default-features = false }", + features: &[], + }, + DependencySpec { + key: "dep_edgezero_adapter_fastly_wasm", + repo_crate: "crates/edgezero-adapter-fastly", + fallback: + "edgezero-adapter-fastly = { git = \"https://git@github.com/stackpop/edgezero.git\", package = \"edgezero-adapter-fastly\", default-features = false, features = [\"fastly\"] }", + features: &["fastly"], + }, +]; + +static FASTLY_FILE_SPECS: &[AdapterFileSpec] = &[ + AdapterFileSpec { + template: "fastly_Cargo_toml", + output: "Cargo.toml", + }, + AdapterFileSpec { + template: "fastly_src_main_rs", + output: "src/main.rs", + }, + AdapterFileSpec { + template: "fastly_cargo_config_toml", + output: ".cargo/config.toml", + }, + AdapterFileSpec { + template: "fastly_fastly_toml", + output: "fastly.toml", + }, +]; + +static FASTLY_TEMPLATE_REGISTRATIONS: &[TemplateRegistration] = &[ + TemplateRegistration { + name: "fastly_Cargo_toml", + contents: include_str!("templates/Cargo.toml.hbs"), + }, + TemplateRegistration { + name: "fastly_src_main_rs", + contents: include_str!("templates/src/main.rs.hbs"), + }, + TemplateRegistration { + name: "fastly_cargo_config_toml", + contents: include_str!("templates/.cargo/config.toml.hbs"), + }, + TemplateRegistration { + name: "fastly_fastly_toml", + contents: include_str!("templates/fastly.toml.hbs"), + }, +]; + +const FASTLY_INSTALL_HINT: &str = + "install the Fastly CLI (https://www.fastly.com/documentation/reference/tools/cli/) and try again"; + +struct FastlyCliAdapter; + +/// Outcome of scanning `fastly config-store list --json` for a +/// platform store id by `name`. Distinguishes three cases the +/// caller wants to act on differently: +/// +/// - `Found(id)` — happy path. +/// - `NotFound` — JSON parsed cleanly and the array contains +/// entries with well-formed `name` + `id` string fields, but no +/// entry matched `name`. Operator likely needs to run +/// `provision`. +/// - `SchemaDrift(detail)` — the JSON parsed but doesn't match +/// the expected shape (no `items` envelope nor bare array, OR +/// entries are missing `name` / `id` string fields, OR the +/// bytes didn't parse as JSON at all). Likely a fastly CLI +/// version bump that changed the output schema; surface the +/// detail so the operator can pin a known-compatible version. +#[derive(Debug)] +enum ConfigStoreLookup { + Found(String), + NotFound, + SchemaDrift(String), +} +// The three `validate_*` trait methods exist on `Adapter` because +// spin requires them (variable-name regex, `[component.*]` +// discovery, flat-namespace collision). The trait surface is typed +// generically so any future adapter with similar constraints can +// override — but fastly has no equivalent platform requirements, +// so the no-op defaults are correct: +// +// - `validate_app_config_keys`: Fastly Config Store keys accept +// alphanumeric + `-` / `_` / `.` up to 256 chars. Any reasonable +// Rust struct field name passes; no regex check needed. +// - `validate_adapter_manifest`: would require shelling out to +// `fastly compute validate` at validate-time. We keep +// `config validate` pure-Rust so it stays fast and +// tool-independent. +// - `validate_typed_secrets`: Fastly's KV / Config / Secret +// stores are independent namespaces — no spin-style flat- +// namespace collision risk to detect. +// +// `single_store_kinds` IS overridden below — explicitly returns +// `&[]` for documentation, matching the inherited default. +#[expect( + clippy::missing_trait_methods, + reason = "see the explanatory block comment immediately above; fastly's no-op defaults for the three validate_* hooks are intentional and documented. `single_store_kinds` IS overridden below (returns `&[]`)." +)] impl Adapter for FastlyCliAdapter { + fn execute(&self, action: AdapterAction, args: &[String]) -> Result<(), String> { + match action { + // `fastly profile {create|delete|list}` is the native + // sign-in surface for Fastly Compute. EdgeZero stores no + // credentials — this is a thin shell-out. + AdapterAction::AuthLogin => { + run_native_cli("fastly", &["profile", "create"], FASTLY_INSTALL_HINT) + } + AdapterAction::AuthLogout => { + run_native_cli("fastly", &["profile", "delete"], FASTLY_INSTALL_HINT) + } + AdapterAction::AuthStatus => { + run_native_cli("fastly", &["profile", "list"], FASTLY_INSTALL_HINT) + } + AdapterAction::Build => { + let artifact = build(args)?; + log::info!("[edgezero] Fastly build complete -> {}", artifact.display()); + Ok(()) + } + AdapterAction::Deploy => deploy(args), + AdapterAction::Serve => serve(args), + other => Err(format!("fastly adapter does not support {other:?}")), + } + } + fn name(&self) -> &'static str { "fastly" } - fn execute(&self, action: AdapterAction, args: &[String]) -> Result<(), String> { - match action { - AdapterAction::Build => { - let artifact = build(args)?; - println!("[edgezero] Fastly build complete -> {}", artifact.display()); - Ok(()) - } - AdapterAction::Deploy => deploy(args), - AdapterAction::Serve => serve(args), - } - } -} + fn provision( + &self, + manifest_root: &Path, + adapter_manifest_path: Option<&str>, + _component_selector: Option<&str>, + stores: &ProvisionStores<'_>, + dry_run: bool, + ) -> Result, String> { + // Fastly is Multi for every store kind. Each id maps 1:1 + // to a Fastly resource (kv-store / config-store / + // secret-store) created via the Fastly CLI; the manifest + // writeback declares the resource link for `fastly + // compute deploy` and the local viceroy server. + let Some(rel) = adapter_manifest_path else { + return Err( + "[adapters.fastly.adapter].manifest must point at fastly.toml for provision" + .to_owned(), + ); + }; + let fastly_path = manifest_root.join(rel); + + let mut out = Vec::new(); + for (kind, ids) in [ + ("kv", stores.kv), + ("config", stores.config), + ("secret", stores.secrets), + ] { + for store in ids { + // Fastly setup tables key on the resource name the + // CLI creates. The runtime resolves that same name + // via `EDGEZERO__STORES______NAME`, + // so provision must use the env-resolved PLATFORM + // name -- the logical id stays in status lines for + // human-facing wording. + let logical = store.logical.as_str(); + let name = store.platform.as_str(); + if dry_run { + out.push(format!( + "would run `fastly {kind}-store create --name={name}` and append [setup.{kind}_stores.{name}] / [local_server.{kind}_stores.{name}] to {} (logical id `{logical}`)", + fastly_path.display() + )); + continue; + } + if setup_block_present(&fastly_path, kind, name)? { + out.push(format!( + "fastly {kind}-store `{name}` (logical id `{logical}`) already declared in {}; skipping. To force a fresh remote: delete the [setup.{kind}_stores.{name}] / [local_server.{kind}_stores.{name}] blocks AND run `fastly {kind}-store delete --name={name}` (the old remote store lingers otherwise), then re-run provision.", + fastly_path.display() + )); + continue; + } + create_fastly_store(kind, name)?; + // If the platform store was created but the + // writeback fails, remote state and the local + // manifest are out of sync. Re-running `provision` + // would attempt to create the platform store again + // and fail with "already exists". Surface the + // recovery path explicitly so the operator isn't + // stuck. + append_fastly_setup(&fastly_path, kind, name).map_err(|err| { + format!( + "fastly {kind}-store `{name}` (logical id `{logical}`) was created remotely, but writeback to {path} failed: {err}\n To recover, either:\n 1. Manually append `[setup.{kind}_stores.{name}]` and `[local_server.{kind}_stores.{name}]` to {path} and re-run, or\n 2. Delete the orphan remote store via `fastly {kind}-store delete --name={name}` and re-run `edgezero provision --adapter fastly`.", + path = fastly_path.display() + ) + })?; + out.push(format!( + "created fastly {kind}-store `{name}` (logical id `{logical}`); appended setup tables to {}", + fastly_path.display() + )); + } + } + if out.is_empty() { + out.push("fastly has no declared stores to provision".to_owned()); + } + Ok(out) + } + + fn push_config_entries( + &self, + _manifest_root: &Path, + _adapter_manifest_path: Option<&str>, + _component_selector: Option<&str>, + store: &ResolvedStoreId, + entries: &[(String, String)], + _push_ctx: &AdapterPushContext<'_>, + dry_run: bool, + ) -> Result, String> { + // Resolve the platform config-store id on demand via + // `fastly config-store list --json` (matched by name = + // `store.platform`), then `fastly config-store-entry create + // --store-id= --key= --value=` per key. Keys + // arrive pre-flattened from the CLI (dotted form). + let logical = store.logical.as_str(); + let name = store.platform.as_str(); + if entries.is_empty() { + return Ok(vec![format!( + "no config entries to push to fastly config-store `{name}` (logical id `{logical}`)" + )]); + } + if dry_run { + // List each entry so the operator can verify intent + // before committing. Matches the spin dry-run preview + // shape. + let mut out = Vec::with_capacity(entries.len().saturating_add(1)); + out.push(format!( + "would resolve fastly config-store `{name}` (logical id `{logical}`) via `fastly config-store list --json` and run `fastly config-store-entry create` for {} entries:", + entries.len() + )); + for (key, _) in entries { + out.push(format!(" would create entry `{key}`")); + } + return Ok(out); + } + let resolved_id = resolve_remote_config_store_id(name)?; + push_entries_with_committer(entries, |key, value| { + create_config_store_entry(&resolved_id, key, value) + })?; + Ok(vec![format!( + "pushed {} entries to fastly config-store `{name}` (logical id `{logical}`, id={resolved_id})", + entries.len() + )]) + } + + fn push_config_entries_local( + &self, + manifest_root: &Path, + adapter_manifest_path: Option<&str>, + _component_selector: Option<&str>, + store: &ResolvedStoreId, + entries: &[(String, String)], + _push_ctx: &AdapterPushContext<'_>, + dry_run: bool, + ) -> Result, String> { + // Local-emulator path: edit + // `[local_server.config_stores..contents]` in + // `fastly.toml`. Viceroy reads it on startup, so a + // subsequent `fastly compute serve` exposes the new values + // to the wasm component. No shell-out to the production + // Fastly CLI -- the operator may not be authenticated and + // wouldn't want a local push to touch production anyway. + let Some(rel) = adapter_manifest_path else { + return Err( + "[adapters.fastly.adapter].manifest must point at fastly.toml for config push --local" + .to_owned(), + ); + }; + let fastly_path = manifest_root.join(rel); + let logical = store.logical.as_str(); + let name = store.platform.as_str(); + if entries.is_empty() { + return Ok(vec![format!( + "no config entries to push to `[local_server.config_stores.{name}]` in {} (logical id `{logical}`)", + fastly_path.display() + )]); + } + if dry_run { + let mut out = Vec::with_capacity(entries.len().saturating_add(1)); + out.push(format!( + "would edit `[local_server.config_stores.{name}.contents]` in {} (logical id `{logical}`) with {} entries:", + fastly_path.display(), + entries.len() + )); + for (key, _) in entries { + out.push(format!(" would set `{key}`")); + } + return Ok(out); + } + write_fastly_local_config_store(&fastly_path, name, entries)?; + Ok(vec![format!( + "wrote {} entries to `[local_server.config_stores.{name}.contents]` in {} (logical id `{logical}`); restart `fastly compute serve` to pick up changes", + entries.len(), + fastly_path.display() + )]) + } + + fn single_store_kinds(&self) -> &'static [&'static str] { + // Explicit `&[]` rather than inheriting the trait default, + // so the "Multi for every store kind" intent is documented + // at the call site. Fastly KV / Config / Secrets all + // support multiple distinct platform resources per kind, + // unlike spin's flat-namespace single-store model. + &[] + } +} + +/// Shell out to `fastly -store create --name=`. The +/// caller resolves `` from `EDGEZERO__STORES______NAME` +/// (falling back to the logical id), so this helper takes whatever the +/// caller hands it and does not re-translate. Returns `Ok(())` on success; +/// surfaces the CLI's stderr verbatim on failure (including the "already +/// exists" error, which is the caller's signal to fix the toml or use a +/// different name). +/// +/// # Errors +/// Returns an error if `fastly` isn't on `PATH`, the child fails to +/// spawn, or the exit status is non-zero. +fn create_fastly_store(kind: &str, name: &str) -> Result<(), String> { + let subcommand = format!("{kind}-store"); + let name_arg = format!("--name={name}"); + let output = Command::new("fastly") + .args([subcommand.as_str(), "create", name_arg.as_str()]) + .output() + .map_err(|err| { + if err.kind() == ErrorKind::NotFound { + format!("`fastly` not found on PATH; {FASTLY_INSTALL_HINT}") + } else { + format!("failed to spawn `fastly`: {err}") + } + })?; + if output.status.success() { + return Ok(()); + } + // Idempotency: the fastly CLI returns non-zero with an + // "already exists" message when a store of this name was + // created by a prior provision run. Treat that as success so + // the operator's recovery path -- "either manually append the + // setup block or delete the remote and re-run provision" -- + // doesn't get blocked. The append step is itself idempotent, + // so re-running provision after a writeback failure is the + // documented recovery and now actually works. + let stderr = String::from_utf8_lossy(&output.stderr); + if looks_like_already_exists(&stderr, kind) { + return Ok(()); + } + Err(format!( + "`fastly {subcommand} create --name={name}` exited with status {}\nstderr: {}", + output.status, + stderr.trim() + )) +} + +/// Heuristic: does the stderr blob look like a "store of this +/// kind, by this name, already exists" failure from the fastly +/// CLI? Different CLI versions phrase this slightly differently +/// ("a kv-store with that name already exists", +/// `"Conflict: duplicate kv_store name"`, etc.); we require BOTH +/// a conflict-signal keyword AND a store-kind reference so an +/// unrelated 409 ("Error: 409 Conflict on /service/...") cannot +/// be misread as idempotent success. The earlier wider heuristic +/// would have swallowed any stderr containing the word +/// "conflict" and let provision march on to writeback against a +/// nonexistent store, surfacing as a confusing deploy-time error. +fn looks_like_already_exists(stderr: &str, kind: &str) -> bool { + let lower = stderr.to_ascii_lowercase(); + let conflict_signal = lower.contains("already exists") + || (lower.contains("duplicate") && lower.contains("name")) + || lower.contains("conflict"); + if !conflict_signal { + return false; + } + // Accept the three common spellings of `-store` / + // `_store` / ` store` so a fastly CLI version + // bump that reshuffles punctuation still hits. + let dashed = format!("{kind}-store"); + let underscored = format!("{kind}_store"); + let spaced = format!("{kind} store"); + lower.contains(&dashed) || lower.contains(&underscored) || lower.contains(&spaced) +} + +/// Probe `fastly.toml` for the existence of BOTH +/// `[setup._stores.]` AND `[local_server._stores.]`. +/// Both are required for a complete provision; checking only `[setup]` +/// would let a half-edited manifest (e.g. `[setup.*]` present but +/// `[local_server.*]` missing) slip through as "already provisioned" +/// and never get repaired. Treats a missing file as "not present" so +/// the first provision call can create it. +/// +/// Limitation: this only verifies that the two tables EXIST with +/// the right names. It does not verify their inner shape (no +/// `format` field probe, no resource-link validation). A manifest +/// the operator hand-edited into a structurally-correct-but-empty +/// state will be treated as "already provisioned" and the skip +/// line will say so. The fastly CLI itself ends up being the +/// authoritative checker at deploy time; `provision` only owns +/// the "did `EdgeZero` write these blocks?" question. +fn setup_block_present(path: &Path, kind: &str, id: &str) -> Result { + let raw = match fs::read_to_string(path) { + Ok(text) => text, + Err(err) if err.kind() == ErrorKind::NotFound => return Ok(false), + Err(err) => return Err(format!("failed to read {}: {err}", path.display())), + }; + let doc: toml_edit::DocumentMut = raw + .parse() + .map_err(|err| format!("failed to parse {}: {err}", path.display()))?; + let plural = format!("{kind}_stores"); + let has = |parent: &str| { + doc.get(parent) + .and_then(|root| root.get(plural.as_str())) + .and_then(|kind_tbl| kind_tbl.get(id)) + .is_some() + }; + // append_fastly_setup is idempotent per parent: if only one of + // the two blocks is present, returning false here lets it run + // again and add just the missing block (the present-key check + // inside append_fastly_setup skips the one that already exists). + Ok(has("setup") && has("local_server")) +} + +/// Append `[setup._stores.]` and +/// `[local_server._stores.]` to `fastly.toml`. Creates +/// the file (and the parent `[setup]` / `[local_server]` tables) +/// if absent. Both new blocks are written as empty tables — the +/// resource-link declaration is enough for `fastly compute deploy` +/// to honour, and `config push` fills in entries later. +fn append_fastly_setup(path: &Path, kind: &str, id: &str) -> Result<(), String> { + use toml_edit::{table, DocumentMut, Item}; + + let raw = match fs::read_to_string(path) { + Ok(text) => text, + Err(err) if err.kind() == ErrorKind::NotFound => String::new(), + Err(err) => return Err(format!("failed to read {}: {err}", path.display())), + }; + let mut doc: DocumentMut = raw + .parse() + .map_err(|err| format!("failed to parse {}: {err}", path.display()))?; + + let plural = format!("{kind}_stores"); + for parent in ["setup", "local_server"] { + let parent_entry = doc.entry(parent).or_insert_with(table); + let parent_tbl = parent_entry.as_table_mut().ok_or_else(|| { + format!( + "{}: `{parent}` exists but is not a table; refusing to edit in place", + path.display() + ) + })?; + let kind_entry = parent_tbl + .entry(plural.as_str()) + .or_insert_with(|| Item::Table(toml_edit::Table::new())); + let kind_tbl = kind_entry.as_table_mut().ok_or_else(|| { + format!( + "{}: `{parent}.{plural}` exists but is not a table; refusing to edit in place", + path.display() + ) + })?; + if !kind_tbl.contains_key(id) { + kind_tbl.insert(id, Item::Table(toml_edit::Table::new())); + } + } + + fs::write(path, doc.to_string()) + .map_err(|err| format!("failed to write {}: {err}", path.display()))?; + Ok(()) +} + +/// Write the local-server config-store entries to `fastly.toml`: +/// `[local_server.config_stores.]` becomes +/// `format = "inline-toml"`, and `[local_server.config_stores..contents]` +/// gets the flat `key = "value"` pairs (overwriting any previous +/// values). Idempotent — re-running just rewrites `contents`. Other +/// blocks in `fastly.toml` (setup, scripts, the actual `[local_server]` +/// secret stores, etc.) are preserved via `toml_edit`. +fn write_fastly_local_config_store( + path: &Path, + platform_name: &str, + entries: &[(String, String)], +) -> Result<(), String> { + use toml_edit::{table, DocumentMut, Item, Table, Value}; + + let raw = match fs::read_to_string(path) { + Ok(text) => text, + Err(err) if err.kind() == ErrorKind::NotFound => String::new(), + Err(err) => return Err(format!("failed to read {}: {err}", path.display())), + }; + let mut doc: DocumentMut = raw + .parse() + .map_err(|err| format!("failed to parse {}: {err}", path.display()))?; + + let local_server_entry = doc.entry("local_server").or_insert_with(table); + let local_server_tbl = local_server_entry.as_table_mut().ok_or_else(|| { + format!( + "{}: `local_server` exists but is not a table; refusing to edit in place", + path.display() + ) + })?; + let config_stores_entry = local_server_tbl + .entry("config_stores") + .or_insert_with(|| Item::Table(Table::new())); + let config_stores_tbl = config_stores_entry.as_table_mut().ok_or_else(|| { + format!( + "{}: `local_server.config_stores` exists but is not a table; refusing to edit in place", + path.display() + ) + })?; + + // Replace the per-store block wholesale so stale entries don't + // linger across pushes (the inverse of provision's "preserve + // existing tables" rule -- here the push is the source of truth + // for the contents). + let mut store_tbl = Table::new(); + store_tbl.insert("format", toml_edit::value("inline-toml")); + let mut contents_tbl = Table::new(); + for (key, value) in entries { + contents_tbl.insert(key, Item::Value(Value::from(value.clone()))); + } + store_tbl.insert("contents", Item::Table(contents_tbl)); + config_stores_tbl.insert(platform_name, Item::Table(store_tbl)); + + fs::write(path, doc.to_string()) + .map_err(|err| format!("failed to write {}: {err}", path.display()))?; + Ok(()) +} + +// ------------------------------------------------------------------- +// `config push` helpers +// ------------------------------------------------------------------- + +/// Shell out to `fastly config-store-entry create --store-id= +/// --key= --value=` for a single entry. Surfaces fastly's +/// stderr verbatim on failure — including the "entry already +/// exists" error, which is the operator's signal to delete the +/// entry (or use `config-store-entry update` manually) before +/// re-running push. +/// Drive a sequential per-entry commit loop and produce the +/// partial-failure diagnostic when the committer fails mid-way. +/// Pure (no I/O) so the diagnostic shape is unit-testable without +/// the fastly CLI on PATH; production calls it with a closure that +/// shells out via `create_config_store_entry`. On success returns +/// the count of committed entries; on failure returns an error +/// string naming committed / failed / not-attempted keys so the +/// operator can resume from a known boundary. +fn push_entries_with_committer( + entries: &[(String, String)], + mut committer: F, +) -> Result +where + F: FnMut(&str, &str) -> Result<(), String>, +{ + let mut pushed: Vec = Vec::with_capacity(entries.len()); + for (key, value) in entries { + if let Err(err) = committer(key, value) { + let remaining: Vec<&str> = entries + .iter() + .skip(pushed.len().saturating_add(1)) + .map(|(remaining_key, _)| remaining_key.as_str()) + .collect(); + return Err(format!( + "fastly push failed at entry `{key}` after committing {committed} of {total} entries; the remaining {remaining_count} entries were NOT pushed.\n Committed (safe to skip on retry): {pushed:?}\n Failed: `{key}` — {err}\n Not attempted (re-push these): {remaining:?}", + committed = pushed.len(), + total = entries.len(), + remaining_count = remaining.len() + )); + } + pushed.push(key.clone()); + } + Ok(pushed.len()) +} + +fn create_config_store_entry(store_id: &str, key: &str, value: &str) -> Result<(), String> { + let store_arg = format!("--store-id={store_id}"); + let key_arg = format!("--key={key}"); + let value_arg = format!("--value={value}"); + let output = Command::new("fastly") + .args([ + "config-store-entry", + "create", + store_arg.as_str(), + key_arg.as_str(), + value_arg.as_str(), + ]) + .output() + .map_err(|err| { + if err.kind() == ErrorKind::NotFound { + format!("`fastly` not found on PATH; {FASTLY_INSTALL_HINT}") + } else { + format!("failed to spawn `fastly`: {err}") + } + })?; + if output.status.success() { + return Ok(()); + } + Err(format!( + "`fastly config-store-entry create --store-id={store_id} --key={key} ...` exited with status {}\nstderr: {}", + output.status, + String::from_utf8_lossy(&output.stderr).trim() + )) +} + +/// Parse `fastly config-store list --json` output and return the +/// platform `id` of the store whose `name` matches `name`. Accepts +/// both a bare array (`[ {"id": "...", "name": "..."}, ... ]`) +/// and an `{"items": [...]}` envelope so this stays compatible +/// across fastly CLI versions. +fn find_config_store_id(stdout: &str, name: &str) -> ConfigStoreLookup { + let parsed: serde_json::Value = match serde_json::from_str(stdout) { + Ok(value) => value, + Err(err) => { + return ConfigStoreLookup::SchemaDrift(format!("stdout did not parse as JSON: {err}")); + } + }; + let Some(array) = parsed + .as_array() + .or_else(|| parsed.get("items").and_then(serde_json::Value::as_array)) + else { + return ConfigStoreLookup::SchemaDrift(format!( + "expected a bare array `[...]` or an `{{\"items\": [...]}}` envelope; got JSON of shape `{}`", + shape_summary(&parsed) + )); + }; + let mut any_well_formed = false; + for entry in array { + let entry_name = entry.get("name").and_then(serde_json::Value::as_str); + let entry_id = entry.get("id").and_then(serde_json::Value::as_str); + if entry_name.is_some() && entry_id.is_some() { + any_well_formed = true; + } + if entry_name == Some(name) { + return entry_id.map_or_else( + || { + ConfigStoreLookup::SchemaDrift(format!( + "entry matched name `{name}` but is missing a string `id` field" + )) + }, + |id| ConfigStoreLookup::Found(id.to_owned()), + ); + } + } + if array.is_empty() || any_well_formed { + ConfigStoreLookup::NotFound + } else { + ConfigStoreLookup::SchemaDrift( + "no entry has both string `name` and `id` fields -- fastly CLI may have changed its output schema" + .to_owned(), + ) + } +} + +/// One-line type label for a `serde_json::Value` (for diagnostic +/// error messages — not a canonical JSON-schema description). +fn shape_summary(value: &serde_json::Value) -> &'static str { + match value { + serde_json::Value::Null => "null", + serde_json::Value::Bool(_) => "bool", + serde_json::Value::Number(_) => "number", + serde_json::Value::String(_) => "string", + serde_json::Value::Array(_) => "array", + serde_json::Value::Object(_) => "object", + } +} + +/// Resolve the platform config-store id on demand: shell out to +/// `fastly config-store list --json`, parse the JSON, match by +/// `name`. The provision flow doesn't persist this id, so push +/// has to re-fetch every time. +fn resolve_remote_config_store_id(name: &str) -> Result { + let output = Command::new("fastly") + .args(["config-store", "list", "--json"]) + .output() + .map_err(|err| { + if err.kind() == ErrorKind::NotFound { + format!("`fastly` not found on PATH; {FASTLY_INSTALL_HINT}") + } else { + format!("failed to spawn `fastly`: {err}") + } + })?; + if !output.status.success() { + return Err(format!( + "`fastly config-store list --json` exited with status {}\nstderr: {}", + output.status, + String::from_utf8_lossy(&output.stderr).trim() + )); + } + let stdout = String::from_utf8_lossy(&output.stdout); + match find_config_store_id(&stdout, name) { + ConfigStoreLookup::Found(id) => Ok(id), + ConfigStoreLookup::NotFound => Err(format!( + "no fastly config-store matches `{name}` (did you run `edgezero provision --adapter fastly`?)" + )), + ConfigStoreLookup::SchemaDrift(detail) => Err(format!( + "could not parse `fastly config-store list --json` output: {detail}.\n The fastly CLI may have changed its JSON schema in a recent version. Please file a bug report at https://github.com/stackpop/edgezero/issues with the fastly CLI version (`fastly version`) and the raw stdout. Workaround: pin to a known-compatible fastly CLI version." + )), + } +} + +/// # Errors +/// Returns an error if the Fastly CLI build command fails. +#[inline] +pub fn build(extra_args: &[String]) -> Result { + let manifest = + find_fastly_manifest(env::current_dir().map_err(|err| err.to_string())?.as_path())?; + let manifest_dir = manifest + .parent() + .ok_or_else(|| "fastly manifest has no parent directory".to_owned())?; + let cargo_manifest = manifest_dir.join("Cargo.toml"); + let crate_name = read_package_name(&cargo_manifest)?; + + let status = Command::new("cargo") + .args([ + "build", + "--release", + "--target", + "wasm32-wasip1", + "--manifest-path", + cargo_manifest + .to_str() + .ok_or("invalid Cargo manifest path")?, + ]) + .args(extra_args) + .status() + .map_err(|err| format!("failed to run cargo build: {err}"))?; + if !status.success() { + return Err(format!("cargo build failed with status {status}")); + } + + let workspace_root = find_workspace_root(manifest_dir); + let artifact = locate_artifact(&workspace_root, manifest_dir, &crate_name)?; + let pkg_dir = workspace_root.join("pkg"); + fs::create_dir_all(&pkg_dir) + .map_err(|err| format!("failed to create {}: {err}", pkg_dir.display()))?; + let dest = pkg_dir.join(format!("{}.wasm", crate_name.replace('-', "_"))); + fs::copy(&artifact, &dest) + .map_err(|err| format!("failed to copy artifact to {}: {err}", dest.display()))?; -pub fn register() { - register_adapter(&FASTLY_ADAPTER); - register_adapter_blueprint(&FASTLY_BLUEPRINT); + Ok(dest) } -#[ctor] -fn register_ctor() { - register(); +/// # Errors +/// Returns an error if the Fastly CLI deploy command fails. +#[inline] +pub fn deploy(extra_args: &[String]) -> Result<(), String> { + let manifest = + find_fastly_manifest(env::current_dir().map_err(|err| err.to_string())?.as_path())?; + let manifest_dir = manifest + .parent() + .ok_or_else(|| "fastly manifest has no parent directory".to_owned())?; + + let status = Command::new("fastly") + .args(["compute", "deploy"]) + .args(extra_args) + .current_dir(manifest_dir) + .status() + .map_err(|err| format!("failed to run fastly CLI: {err}"))?; + if !status.success() { + return Err(format!("fastly compute deploy failed with status {status}")); + } + + Ok(()) } fn find_fastly_manifest(start: &Path) -> Result { @@ -241,18 +845,15 @@ fn find_fastly_manifest(start: &Path) -> Result { .filter_map(Result::ok) .map(|entry| entry.path().to_path_buf()) .filter(|path| { - path.file_name() - .map(|n| n == "fastly.toml") - .unwrap_or(false) + path.file_name().is_some_and(|n| n == "fastly.toml") && path .parent() - .map(|dir| dir.join("Cargo.toml").exists()) - .unwrap_or(false) + .is_some_and(|dir| dir.join("Cargo.toml").exists()) }) .collect(); if candidates.is_empty() { - return Err("could not locate fastly.toml".to_string()); + return Err("could not locate fastly.toml".to_owned()); } candidates.sort_by_key(|path| { @@ -271,7 +872,7 @@ fn locate_artifact( let target_triple = "wasm32-wasip1"; let release_name = format!("{}.wasm", crate_name.replace('-', "_")); - if let Some(custom) = std::env::var_os("CARGO_TARGET_DIR") { + if let Some(custom) = env::var_os("CARGO_TARGET_DIR") { let candidate = PathBuf::from(custom) .join(target_triple) .join("release") @@ -305,12 +906,76 @@ fn locate_artifact( )) } +#[inline] +pub fn register() { + register_adapter(&FASTLY_ADAPTER); + register_adapter_blueprint(&FASTLY_BLUEPRINT); +} + +#[ctor(unsafe)] +fn register_ctor() { + register(); +} + +/// # Errors +/// Returns an error if the Fastly CLI serve command (Viceroy) fails. +#[inline] +pub fn serve(extra_args: &[String]) -> Result<(), String> { + let manifest = + find_fastly_manifest(env::current_dir().map_err(|err| err.to_string())?.as_path())?; + let manifest_dir = manifest + .parent() + .ok_or_else(|| "fastly manifest has no parent directory".to_owned())?; + + let status = Command::new("fastly") + .args(["compute", "serve"]) + .args(extra_args) + .current_dir(manifest_dir) + .status() + .map_err(|err| format!("failed to run fastly CLI: {err}"))?; + if !status.success() { + return Err(format!("fastly compute serve failed with status {status}")); + } + + Ok(()) +} + #[cfg(test)] mod tests { use super::*; use edgezero_adapter::cli_support::read_package_name; use tempfile::tempdir; + // Shared fixture names. Pinning these as consts (instead of + // inline `"sessions"` / `"app_config"` per call site) keeps the + // setup-vs-assertion pair in sync -- a typo in one place no + // longer silently divorces from the other, because both reference + // the same const. Also names the intent: these are the LOGICAL + // store ids the fastly adapter operates on, not arbitrary strings. + const TEST_KV_ID: &str = "sessions"; + const TEST_CONFIG_ID: &str = "app_config"; + const TEST_SECRET_ID: &str = "default"; + + #[test] + fn finds_closest_manifest_when_multiple_exist() { + let dir = tempdir().unwrap(); + let root = dir.path(); + fs::write(root.join("Cargo.toml"), "[workspace]").unwrap(); + + let first = root.join("crates/first"); + fs::create_dir_all(&first).unwrap(); + fs::write(first.join("Cargo.toml"), "[package]\nname=\"first\"").unwrap(); + fs::write(first.join("fastly.toml"), "name=\"first\"").unwrap(); + + let second = root.join("examples/second"); + fs::create_dir_all(&second).unwrap(); + fs::write(second.join("Cargo.toml"), "[package]\nname=\"second\"").unwrap(); + fs::write(second.join("fastly.toml"), "name=\"second\"").unwrap(); + + let found = find_fastly_manifest(&second).unwrap(); + assert_eq!(found, second.join("fastly.toml")); + } + #[test] fn finds_manifest_in_current_directory() { let dir = tempdir().unwrap(); @@ -323,12 +988,17 @@ mod tests { } #[test] - fn read_package_prefers_package_table() { + fn locate_artifact_considers_workspace_target() { let dir = tempdir().unwrap(); - let manifest = dir.path().join("Cargo.toml"); - fs::write(&manifest, "[package]\nname = \"demo\"\n").unwrap(); - let name = read_package_name(&manifest).unwrap(); - assert_eq!(name, "demo"); + let workspace = dir.path(); + let manifest_dir = workspace.join("service"); + fs::create_dir_all(manifest_dir.join("target/wasm32-wasip1/release")).unwrap(); + let artifact = workspace.join("target/wasm32-wasip1/release/demo.wasm"); + fs::create_dir_all(artifact.parent().unwrap()).unwrap(); + fs::write(&artifact, "wasm").unwrap(); + + let located = locate_artifact(workspace, &manifest_dir, "demo").unwrap(); + assert_eq!(located, artifact); } #[test] @@ -341,36 +1011,703 @@ mod tests { } #[test] - fn locate_artifact_considers_workspace_target() { + fn read_package_prefers_package_table() { let dir = tempdir().unwrap(); - let workspace = dir.path(); - let manifest_dir = workspace.join("service"); - fs::create_dir_all(manifest_dir.join("target/wasm32-wasip1/release")).unwrap(); - let artifact = workspace.join("target/wasm32-wasip1/release/demo.wasm"); - fs::create_dir_all(artifact.parent().unwrap()).unwrap(); - fs::write(&artifact, "wasm").unwrap(); + let manifest = dir.path().join("Cargo.toml"); + fs::write(&manifest, "[package]\nname = \"demo\"\n").unwrap(); + let name = read_package_name(&manifest).unwrap(); + assert_eq!(name, "demo"); + } - let located = locate_artifact(workspace, &manifest_dir, "demo").unwrap(); - assert_eq!(located, artifact); + // ---------- push_entries_with_committer ---------- + + #[test] + fn push_entries_with_committer_returns_count_when_all_succeed() { + let entries = vec![ + ("a".to_owned(), "1".to_owned()), + ("b".to_owned(), "2".to_owned()), + ("c".to_owned(), "3".to_owned()), + ]; + let pushed = push_entries_with_committer(&entries, |_, _| Ok(())).expect("all succeed"); + assert_eq!(pushed, 3); } #[test] - fn finds_closest_manifest_when_multiple_exist() { - let dir = tempdir().unwrap(); - let root = dir.path(); - fs::write(root.join("Cargo.toml"), "[workspace]").unwrap(); + fn push_entries_with_committer_zero_entries_is_ok() { + let pushed = push_entries_with_committer(&[], |_, _| Ok(())).expect("empty is fine"); + assert_eq!(pushed, 0); + } - let first = root.join("crates/first"); - fs::create_dir_all(&first).unwrap(); - fs::write(first.join("Cargo.toml"), "[package]\nname=\"first\"").unwrap(); - fs::write(first.join("fastly.toml"), "name=\"first\"").unwrap(); + #[test] + fn push_entries_with_committer_failure_surfaces_committed_failed_not_attempted() { + // Mock committer: succeed for first 2 keys, fail at third. + let entries = vec![ + ("k1".to_owned(), "v1".to_owned()), + ("k2".to_owned(), "v2".to_owned()), + ("k3".to_owned(), "v3".to_owned()), + ("k4".to_owned(), "v4".to_owned()), + ("k5".to_owned(), "v5".to_owned()), + ]; + let mut calls: usize = 0; + let err = push_entries_with_committer(&entries, |key, _| { + calls = calls.saturating_add(1); + if key == "k3" { + Err("simulated fastly stderr".to_owned()) + } else { + Ok(()) + } + }) + .expect_err("middle failure must error"); + // Committer was invoked for k1, k2, k3 and stopped. + assert_eq!(calls, 3_usize, "no retries beyond failure point"); + // Error names all three categories. + assert!(err.contains("k1") && err.contains("k2"), "committed: {err}"); + assert!( + err.contains("Failed: `k3`"), + "failed entry named exactly: {err}" + ); + assert!( + err.contains("k4") && err.contains("k5"), + "not-attempted: {err}" + ); + assert!(err.contains("simulated fastly stderr"), "inner err: {err}"); + // Counts are sane. + assert!( + err.contains("committing 2 of 5 entries"), + "committed/total count: {err}" + ); + } - let second = root.join("examples/second"); - fs::create_dir_all(&second).unwrap(); - fs::write(second.join("Cargo.toml"), "[package]\nname=\"second\"").unwrap(); - fs::write(second.join("fastly.toml"), "name=\"second\"").unwrap(); + #[test] + fn push_entries_with_committer_first_entry_failure_reports_zero_committed() { + let entries = vec![ + ("only".to_owned(), "val".to_owned()), + ("never".to_owned(), "tried".to_owned()), + ]; + let err = push_entries_with_committer(&entries, |_, _| Err("nope".to_owned())) + .expect_err("first-entry failure"); + assert!(err.contains("committing 0 of 2"), "zero committed: {err}"); + assert!( + err.contains("Failed: `only`"), + "first-entry failure named: {err}" + ); + assert!( + err.contains("never"), + "second entry as not-attempted: {err}" + ); + } - let found = find_fastly_manifest(&second).unwrap(); - assert_eq!(found, second.join("fastly.toml")); + #[test] + fn push_entries_with_committer_last_entry_failure_reports_n_minus_one_committed() { + let entries = vec![ + ("a".to_owned(), "1".to_owned()), + ("b".to_owned(), "2".to_owned()), + ("c".to_owned(), "3".to_owned()), + ]; + let err = push_entries_with_committer(&entries, |key, _| { + if key == "c" { + Err("late failure".to_owned()) + } else { + Ok(()) + } + }) + .expect_err("last-entry failure"); + assert!(err.contains("committing 2 of 3"), "n-1 committed: {err}"); + assert!( + err.contains("the remaining 0 entries"), + "zero not-attempted when last fails: {err}" + ); + } + + // ---------- looks_like_already_exists ---------- + + #[test] + fn looks_like_already_exists_recognises_common_phrasings() { + // Real-shaped fastly CLI error strings (paraphrased; the + // CLI varies across versions). Each must be detected so + // create_fastly_store can treat it as idempotent success. + assert!(looks_like_already_exists( + "Error: a kv-store with that name already exists", + "kv", + )); + assert!(looks_like_already_exists( + "ERROR: Conflict (409): duplicate kv_store name", + "kv", + )); + assert!(looks_like_already_exists( + "A config-store with this name already exists", + "config", + )); + // Spaced form: some fastly CLI versions emit prose + // ("kv store"); accept it alongside the punctuated forms. + assert!(looks_like_already_exists( + "Error: kv store conflict: name already in use", + "kv", + )); + } + + #[test] + fn looks_like_already_exists_rejects_unrelated_errors() { + assert!(!looks_like_already_exists( + "Error: unauthenticated; run `fastly profile create`", + "kv", + )); + assert!(!looks_like_already_exists( + "Error: network unreachable", + "kv", + )); + assert!(!looks_like_already_exists("", "kv")); + } + + #[test] + fn looks_like_already_exists_rejects_unrelated_conflict_errors() { + // The earlier wider heuristic swallowed ANY stderr + // containing "conflict" or "already exists", which would + // misread an unrelated 409 from a different fastly + // subcommand (e.g. a service-version conflict during a + // parallel deploy) as idempotent store-create success. + // Now we require the kind context too, so unrelated + // conflicts surface as failures. + assert!( + !looks_like_already_exists( + "Error: 409 Conflict on /service/abc/version/42 -- already exists", + "kv", + ), + "service-version conflict must NOT be misread as kv-store idempotency" + ); + assert!( + !looks_like_already_exists( + "Error: invalid duplicate request; check name resolution", + "kv", + ), + "unrelated `duplicate ... name` AND-match must NOT trigger" + ); + // And the kind must match: a config-store conflict must + // not look-like-already-exists for a kv-store create call. + assert!( + !looks_like_already_exists("Error: a config-store with that name already exists", "kv",), + "wrong-kind conflict must NOT trigger" + ); + } + + // ---------- setup_block_present ---------- + + #[test] + fn setup_block_present_true_when_table_exists() { + let dir = tempdir().expect("tempdir"); + let path = dir.path().join("fastly.toml"); + fs::write( + &path, + "name = \"demo\"\n[setup.kv_stores.sessions]\n[local_server.kv_stores.sessions]\n", + ) + .expect("write"); + assert!(setup_block_present(&path, "kv", TEST_KV_ID).expect("probe")); + } + + #[test] + fn setup_block_present_false_when_id_missing() { + let dir = tempdir().expect("tempdir"); + let path = dir.path().join("fastly.toml"); + fs::write(&path, "name = \"demo\"\n[setup.kv_stores.other]\n").expect("write"); + assert!(!setup_block_present(&path, "kv", TEST_KV_ID).expect("probe")); + } + + #[test] + fn setup_block_present_false_for_missing_file() { + let dir = tempdir().expect("tempdir"); + let path = dir.path().join("does-not-exist.toml"); + assert!(!setup_block_present(&path, "kv", TEST_KV_ID).expect("probe")); + } + + #[test] + fn setup_block_present_false_when_only_setup_or_only_local_server_exists() { + // Spec requires BOTH [setup._stores.] AND + // [local_server._stores.] for a fully provisioned + // store. A half-edited manifest (e.g. operator hand-added + // the [setup.*] block but skipped [local_server.*]) must + // return false so the next provision repairs the missing + // block; append_fastly_setup is idempotent per parent, so + // it skips the present one and writes the missing one. + let dir = tempdir().expect("tempdir"); + let only_setup = dir.path().join("only_setup.toml"); + fs::write(&only_setup, "name = \"demo\"\n[setup.kv_stores.sessions]\n").expect("write"); + assert!( + !setup_block_present(&only_setup, "kv", TEST_KV_ID).expect("probe"), + "[setup.*] alone is not enough -- [local_server.*] also required" + ); + + let only_local = dir.path().join("only_local.toml"); + fs::write( + &only_local, + "name = \"demo\"\n[local_server.kv_stores.sessions]\n", + ) + .expect("write"); + assert!( + !setup_block_present(&only_local, "kv", TEST_KV_ID).expect("probe"), + "[local_server.*] alone is not enough -- [setup.*] also required" + ); + } + + // ---------- append_fastly_setup ---------- + + #[test] + fn append_fastly_setup_creates_both_tables_in_minimal_file() { + let dir = tempdir().expect("tempdir"); + let path = dir.path().join("fastly.toml"); + fs::write(&path, "name = \"demo\"\n").expect("write"); + append_fastly_setup(&path, "kv", TEST_KV_ID).expect("append"); + let after = fs::read_to_string(&path).expect("read back"); + assert!( + after.contains("[setup.kv_stores.sessions]"), + "setup table added: {after}" + ); + assert!( + after.contains("[local_server.kv_stores.sessions]"), + "local_server table added: {after}" + ); + assert!( + after.contains("name = \"demo\""), + "preserved original keys: {after}" + ); + } + + #[test] + fn append_fastly_setup_appends_alongside_existing_kind_tables() { + let dir = tempdir().expect("tempdir"); + let path = dir.path().join("fastly.toml"); + fs::write( + &path, + "[setup.kv_stores.cache]\n[local_server.kv_stores.cache]\n", + ) + .expect("write"); + append_fastly_setup(&path, "kv", TEST_KV_ID).expect("append"); + let after = fs::read_to_string(&path).expect("read back"); + assert!( + after.contains("[setup.kv_stores.cache]"), + "existing entry kept: {after}" + ); + assert!( + after.contains("[setup.kv_stores.sessions]"), + "new entry added: {after}" + ); + } + + #[test] + fn append_fastly_setup_is_idempotent_on_duplicate_id() { + let dir = tempdir().expect("tempdir"); + let path = dir.path().join("fastly.toml"); + fs::write( + &path, + "[setup.kv_stores.sessions]\nfoo = \"keep\"\n[local_server.kv_stores.sessions]\n", + ) + .expect("write"); + append_fastly_setup(&path, "kv", TEST_KV_ID).expect("idempotent append"); + let after = fs::read_to_string(&path).expect("read back"); + assert!( + after.contains("foo = \"keep\""), + "did not stomp existing key: {after}" + ); + } + + #[test] + fn append_fastly_setup_creates_file_when_missing() { + let dir = tempdir().expect("tempdir"); + let path = dir.path().join("fastly.toml"); + // Note: no fs::write — file starts absent. + append_fastly_setup(&path, "config", TEST_CONFIG_ID).expect("create"); + let after = fs::read_to_string(&path).expect("read back"); + assert!(after.contains("[setup.config_stores.app_config]")); + assert!(after.contains("[local_server.config_stores.app_config]")); + } + + #[test] + fn append_fastly_setup_preserves_top_comments() { + let dir = tempdir().expect("tempdir"); + let path = dir.path().join("fastly.toml"); + fs::write( + &path, + "# managed by hand -- please keep this line\nname = \"demo\"\n", + ) + .expect("write"); + append_fastly_setup(&path, "secret", TEST_SECRET_ID).expect("append"); + let after = fs::read_to_string(&path).expect("read back"); + assert!( + after.contains("# managed by hand"), + "preserved comment: {after}" + ); + } + + // ---------- write_fastly_local_config_store (config push --local) ---------- + + #[test] + fn write_fastly_local_config_store_creates_inline_block_in_minimal_file() { + let dir = tempdir().expect("tempdir"); + let path = dir.path().join("fastly.toml"); + fs::write(&path, "name = \"demo\"\n").expect("write"); + let entries = vec![ + ("greeting".to_owned(), "hello".to_owned()), + ("service.timeout_ms".to_owned(), "1500".to_owned()), + ]; + write_fastly_local_config_store(&path, TEST_CONFIG_ID, &entries).expect("write"); + let after = fs::read_to_string(&path).expect("read back"); + assert!( + after.contains(&format!("[local_server.config_stores.{TEST_CONFIG_ID}]")), + "store table: {after}" + ); + assert!( + after.contains("format = \"inline-toml\""), + "format field: {after}" + ); + assert!( + after.contains(&format!( + "[local_server.config_stores.{TEST_CONFIG_ID}.contents]" + )), + "contents table: {after}" + ); + assert!(after.contains("greeting = \"hello\""), "key 1: {after}"); + assert!( + after.contains("\"service.timeout_ms\" = \"1500\""), + "dotted key quoted: {after}" + ); + assert!(after.contains("name = \"demo\""), "preserved: {after}"); + } + + #[test] + fn write_fastly_local_config_store_replaces_existing_block_on_re_push() { + let dir = tempdir().expect("tempdir"); + let path = dir.path().join("fastly.toml"); + fs::write(&path, "name = \"demo\"\n").expect("write"); + write_fastly_local_config_store( + &path, + TEST_CONFIG_ID, + &[("greeting".to_owned(), "stale".to_owned())], + ) + .expect("first write"); + write_fastly_local_config_store( + &path, + TEST_CONFIG_ID, + &[("greeting".to_owned(), "fresh".to_owned())], + ) + .expect("second write"); + let after = fs::read_to_string(&path).expect("read back"); + assert!(after.contains("greeting = \"fresh\""), "new value: {after}"); + assert!( + !after.contains("greeting = \"stale\""), + "stale value dropped: {after}" + ); + } + + #[test] + fn write_fastly_local_config_store_preserves_unrelated_blocks() { + let dir = tempdir().expect("tempdir"); + let path = dir.path().join("fastly.toml"); + let original = "\ +[setup.kv_stores.sessions] + +[[local_server.kv_stores.sessions]] +key = \"__init__\" +data = \"\" + +[scripts] +build = \"cargo build --release\" +"; + fs::write(&path, original).expect("write"); + write_fastly_local_config_store( + &path, + TEST_CONFIG_ID, + &[("greeting".to_owned(), "hi".to_owned())], + ) + .expect("write"); + let after = fs::read_to_string(&path).expect("read back"); + assert!( + after.contains("[setup.kv_stores.sessions]"), + "setup KV kept: {after}" + ); + assert!(after.contains("[scripts]"), "scripts table kept: {after}"); + assert!( + after.contains("build = \"cargo build --release\""), + "scripts value kept: {after}" + ); + assert!( + after.contains(&format!( + "[local_server.config_stores.{TEST_CONFIG_ID}.contents]" + )), + "new config_stores block added: {after}" + ); + } + + #[test] + fn write_fastly_local_config_store_creates_file_when_missing() { + let dir = tempdir().expect("tempdir"); + let path = dir.path().join("fastly.toml"); + // No fs::write — file absent. + write_fastly_local_config_store( + &path, + TEST_CONFIG_ID, + &[("greeting".to_owned(), "hi".to_owned())], + ) + .expect("write"); + let after = fs::read_to_string(&path).expect("read back"); + assert!(after.contains(&format!( + "[local_server.config_stores.{TEST_CONFIG_ID}.contents]" + ))); + assert!(after.contains("greeting = \"hi\"")); + } + + // ---------- provision (dry-run + error path) ---------- + + #[test] + fn provision_dry_run_does_not_invoke_fastly() { + let dir = tempdir().expect("tempdir"); + let path = dir.path().join("fastly.toml"); + fs::write(&path, "name = \"demo\"\n").expect("write"); + let kv_ids: Vec = ResolvedStoreId::from_logicals(&[TEST_KV_ID]); + let config_ids: Vec = ResolvedStoreId::from_logicals(&[TEST_CONFIG_ID]); + let secret_ids: Vec = ResolvedStoreId::from_logicals(&[TEST_SECRET_ID]); + let stores = ProvisionStores { + config: &config_ids, + kv: &kv_ids, + secrets: &secret_ids, + }; + let out = FastlyCliAdapter + .provision(dir.path(), Some("fastly.toml"), None, &stores, true) + .expect("dry-run succeeds"); + // 1 KV + 1 config + 1 secret = 3 status lines. + assert_eq!(out.len(), 3); + assert!(out[0].contains("would run `fastly kv-store create --name=sessions`")); + assert!(out[1].contains("would run `fastly config-store create --name=app_config`")); + assert!(out[2].contains("would run `fastly secret-store create --name=default`")); + // Manifest untouched. + let after = fs::read_to_string(&path).expect("read"); + assert_eq!(after, "name = \"demo\"\n", "dry-run mutated fastly.toml"); + } + + #[test] + fn provision_errors_when_adapter_manifest_path_missing() { + let dir = tempdir().expect("tempdir"); + let kv_ids: Vec = ResolvedStoreId::from_logicals(&[TEST_KV_ID]); + let stores = ProvisionStores { + config: &[], + kv: &kv_ids, + secrets: &[], + }; + let err = FastlyCliAdapter + .provision(dir.path(), None, None, &stores, true) + .expect_err("missing adapter manifest path must error"); + assert!( + err.contains("fastly.toml"), + "error names what's missing: {err}" + ); + } + + #[test] + fn provision_with_no_declared_stores_says_so() { + let dir = tempdir().expect("tempdir"); + let path = dir.path().join("fastly.toml"); + fs::write(&path, "name = \"demo\"\n").expect("write"); + let stores = ProvisionStores { + config: &[], + kv: &[], + secrets: &[], + }; + let out = FastlyCliAdapter + .provision(dir.path(), Some("fastly.toml"), None, &stores, false) + .expect("no-store provision is fine"); + assert_eq!(out, vec!["fastly has no declared stores to provision"]); + } + + #[test] + fn provision_skips_id_when_setup_block_already_present() { + // setup_block_present's role in the flow: re-running + // provision after the user already declared a store in + // fastly.toml must be a no-op (no shell-out to fastly). + // We can verify this in a real (non-dry-run) call because + // the skip path bypasses create_fastly_store entirely. + let dir = tempdir().expect("tempdir"); + let path = dir.path().join("fastly.toml"); + fs::write( + &path, + "[setup.kv_stores.sessions]\n[local_server.kv_stores.sessions]\n", + ) + .expect("write"); + let kv_ids: Vec = ResolvedStoreId::from_logicals(&[TEST_KV_ID]); + let stores = ProvisionStores { + config: &[], + kv: &kv_ids, + secrets: &[], + }; + let out = FastlyCliAdapter + .provision(dir.path(), Some("fastly.toml"), None, &stores, false) + .expect("skip path succeeds without invoking fastly"); + assert_eq!(out.len(), 1); + assert!(out[0].contains("already declared"), "got: {out:?}"); + } + + // ---------- find_config_store_id ---------- + + #[test] + fn find_config_store_id_matches_bare_array_by_name() { + let stdout = format!( + r#"[ + {{"id": "abc123", "name": "{TEST_CONFIG_ID}"}}, + {{"id": "def456", "name": "other_store"}} + ]"# + ); + match find_config_store_id(&stdout, TEST_CONFIG_ID) { + ConfigStoreLookup::Found(id) => assert_eq!(id, "abc123"), + ConfigStoreLookup::NotFound => panic!("expected Found, got NotFound"), + ConfigStoreLookup::SchemaDrift(detail) => { + panic!("expected Found, got SchemaDrift({detail})") + } + } + } + + #[test] + fn find_config_store_id_tolerates_items_envelope() { + let stdout = format!( + r#"{{"items": [ + {{"id": "xyz789", "name": "{TEST_CONFIG_ID}"}} + ]}}"# + ); + match find_config_store_id(&stdout, TEST_CONFIG_ID) { + ConfigStoreLookup::Found(id) => assert_eq!(id, "xyz789"), + ConfigStoreLookup::NotFound => panic!("expected Found, got NotFound"), + ConfigStoreLookup::SchemaDrift(detail) => { + panic!("expected Found, got SchemaDrift({detail})") + } + } + } + + #[test] + fn find_config_store_id_distinguishes_not_found_from_match_failure() { + // JSON parses cleanly, entries are well-formed + // (`name` + `id` strings present), but no entry matches + // → NotFound. Operator likely needs to run `provision`. + let stdout = r#"[{"id": "abc", "name": "other"}]"#; + assert!(matches!( + find_config_store_id(stdout, "missing"), + ConfigStoreLookup::NotFound + )); + } + + #[test] + fn find_config_store_id_flags_schema_drift_on_malformed_json() { + // Unparseable bytes are NOT a "store not found" — they're + // a "fastly CLI output format changed" signal. Operator + // needs different recovery (file a bug, pin CLI version) + // than for the "store doesn't exist yet" case. + let drift = find_config_store_id("not json", "anything"); + assert!( + matches!(drift, ConfigStoreLookup::SchemaDrift(_)), + "non-JSON stdout must be schema drift, got {drift:?}" + ); + let empty = find_config_store_id("", "anything"); + assert!( + matches!(empty, ConfigStoreLookup::SchemaDrift(_)), + "empty stdout must be schema drift, got {empty:?}" + ); + } + + #[test] + fn find_config_store_id_flags_schema_drift_when_shape_unexpected() { + // JSON parses but the top-level is neither a bare array + // nor an `{items: [...]}` envelope. + let stdout = r#"{"namespace": "fastly", "list": []}"#; + match find_config_store_id(stdout, "any") { + ConfigStoreLookup::SchemaDrift(detail) => { + assert!( + detail.contains("bare array") || detail.contains("items"), + "schema-drift detail names the expected shapes: {detail}" + ); + } + ConfigStoreLookup::Found(id) => panic!("expected SchemaDrift, got Found({id})"), + ConfigStoreLookup::NotFound => panic!("expected SchemaDrift, got NotFound"), + } + } + + #[test] + fn find_config_store_id_flags_schema_drift_when_entries_lack_name_id() { + // Array of objects but none have BOTH string `name` and + // string `id` fields — suggests schema rename (e.g. + // fastly renamed `name` → `title`). + let stdout = format!(r#"[{{"title": "{TEST_CONFIG_ID}", "uid": "abc"}}]"#); + let drift = find_config_store_id(&stdout, TEST_CONFIG_ID); + assert!( + matches!(drift, ConfigStoreLookup::SchemaDrift(_)), + "entries lacking name/id must be schema drift, got {drift:?}" + ); + } + + #[test] + fn find_config_store_id_returns_not_found_for_empty_array() { + // Empty array IS a valid "store doesn't exist yet" signal, + // not schema drift — fastly CLI legitimately returns `[]` + // when no config-stores exist. + let drift = find_config_store_id("[]", "any"); + assert!( + matches!(drift, ConfigStoreLookup::NotFound), + "empty array must be NotFound, got {drift:?}" + ); + } + + // ---------- push_config_entries (dry-run + error paths) ---------- + + #[test] + fn push_dry_run_does_not_invoke_fastly() { + let dir = tempdir().expect("tempdir"); + let entries = vec![ + ("greeting".to_owned(), "hello".to_owned()), + ("feature.new_checkout".to_owned(), "false".to_owned()), + ]; + let out = FastlyCliAdapter + .push_config_entries( + dir.path(), + Some("fastly.toml"), + None, + &ResolvedStoreId::from_logical(TEST_CONFIG_ID), + &entries, + &AdapterPushContext::new(), + true, + ) + .expect("dry-run succeeds"); + // First line names the resolve+publish flow; subsequent lines preview + // each key the push would create (so callers can eyeball the keyset + // before running for real). + assert_eq!(out.len(), 1 + entries.len(), "header + per-entry preview"); + assert!( + out[0].contains("would resolve fastly config-store `app_config`") + && out[0].contains("config-store-entry create"), + "dry-run header describes the would-be flow: {out:?}" + ); + assert!( + out.iter().any(|line| line.contains("`greeting`")), + "dry-run lists `greeting`: {out:?}" + ); + assert!( + out.iter() + .any(|line| line.contains("`feature.new_checkout`")), + "dry-run lists `feature.new_checkout`: {out:?}" + ); + } + + #[test] + fn push_with_no_entries_reports_no_op_without_invoking_fastly() { + let dir = tempdir().expect("tempdir"); + let out = FastlyCliAdapter + .push_config_entries( + dir.path(), + Some("fastly.toml"), + None, + &ResolvedStoreId::from_logical(TEST_CONFIG_ID), + &[], + &AdapterPushContext::new(), + false, + ) + .expect("zero-entry push is fine"); + assert_eq!(out.len(), 1); + assert!( + out[0].contains("no config entries"), + "status line names the no-op: {out:?}" + ); } } diff --git a/crates/edgezero-adapter-fastly/src/config_store.rs b/crates/edgezero-adapter-fastly/src/config_store.rs index b7affd0b..4723cb77 100644 --- a/crates/edgezero-adapter-fastly/src/config_store.rs +++ b/crates/edgezero-adapter-fastly/src/config_store.rs @@ -3,7 +3,10 @@ #[cfg(test)] use std::collections::HashMap; +use async_trait::async_trait; use edgezero_core::config_store::{ConfigStore, ConfigStoreError}; +use fastly::config_store::{LookupError, OpenError}; +use fastly::ConfigStore as FastlyConfigStoreInner; /// Config store backed by a Fastly Config Store resource link. pub struct FastlyConfigStore { @@ -11,43 +14,57 @@ pub struct FastlyConfigStore { } enum FastlyConfigStoreBackend { - Fastly(fastly::ConfigStore), + Fastly(FastlyConfigStoreInner), #[cfg(test)] InMemory(HashMap), } impl FastlyConfigStore { - /// Open a Fastly Config Store by resource link name. - /// - /// Returns an error if the configured store cannot be opened. - pub fn try_open(name: &str) -> Result { - fastly::ConfigStore::try_open(name).map(|inner| Self { - inner: FastlyConfigStoreBackend::Fastly(inner), - }) - } - #[cfg(test)] fn from_entries(entries: impl IntoIterator) -> Self { Self { inner: FastlyConfigStoreBackend::InMemory(entries.into_iter().collect()), } } + + /// Open a Fastly Config Store by resource link name. + /// + /// Returns an error if the configured store cannot be opened. + /// + /// # Errors + /// Returns the underlying [`fastly::config_store::OpenError`] when the named store does not exist or cannot be opened. + #[inline] + pub fn try_open(name: &str) -> Result { + FastlyConfigStoreInner::try_open(name).map(|inner| Self { + inner: FastlyConfigStoreBackend::Fastly(inner), + }) + } } +#[async_trait(?Send)] impl ConfigStore for FastlyConfigStore { - fn get(&self, key: &str) -> Result, ConfigStoreError> { + #[inline] + async fn get(&self, key: &str) -> Result, ConfigStoreError> { match &self.inner { - FastlyConfigStoreBackend::Fastly(inner) => inner.try_get(key).map_err(map_lookup_error), + FastlyConfigStoreBackend::Fastly(inner) => { + inner.try_get(key).map_err(|err| map_lookup_error(&err)) + } #[cfg(test)] FastlyConfigStoreBackend::InMemory(data) => Ok(data.get(key).cloned()), } } } -fn map_lookup_error(err: fastly::config_store::LookupError) -> ConfigStoreError { +fn map_lookup_error(err: &LookupError) -> ConfigStoreError { + // `LookupError` is from the `fastly` crate; using a wildcard arm guards + // against new variants being added in upstream point releases without + // forcing us into a breaking match every bump. + #[expect( + clippy::wildcard_enum_match_arm, + reason = "external enum; new variants must remain unavailable→unavailable" + )] match err { - fastly::config_store::LookupError::KeyInvalid - | fastly::config_store::LookupError::KeyTooLong => { + LookupError::KeyInvalid | LookupError::KeyTooLong => { ConfigStoreError::invalid_key("invalid config key") } _ => { @@ -63,20 +80,20 @@ mod tests { edgezero_core::config_store_contract_tests!(fastly_config_store_contract, { FastlyConfigStore::from_entries([ - ("contract.key.a".to_string(), "value_a".to_string()), - ("contract.key.b".to_string(), "value_b".to_string()), + ("contract.key.a".to_owned(), "value_a".to_owned()), + ("contract.key.b".to_owned(), "value_b".to_owned()), ]) }); #[test] fn key_invalid_maps_to_invalid_key_error() { - let err = map_lookup_error(fastly::config_store::LookupError::KeyInvalid); + let err = map_lookup_error(&LookupError::KeyInvalid); assert!(matches!(err, ConfigStoreError::InvalidKey { .. })); } #[test] fn key_too_long_maps_to_invalid_key_error() { - let err = map_lookup_error(fastly::config_store::LookupError::KeyTooLong); + let err = map_lookup_error(&LookupError::KeyTooLong); assert!(matches!(err, ConfigStoreError::InvalidKey { .. })); } } diff --git a/crates/edgezero-adapter-fastly/src/context.rs b/crates/edgezero-adapter-fastly/src/context.rs index 54f07082..07b46208 100644 --- a/crates/edgezero-adapter-fastly/src/context.rs +++ b/crates/edgezero-adapter-fastly/src/context.rs @@ -9,13 +9,15 @@ pub struct FastlyRequestContext { } impl FastlyRequestContext { - pub fn insert(request: &mut Request, context: FastlyRequestContext) { - request.extensions_mut().insert(context); - } - + #[inline] pub fn get(request: &Request) -> Option<&FastlyRequestContext> { request.extensions().get::() } + + #[inline] + pub fn insert(request: &mut Request, context: FastlyRequestContext) { + request.extensions_mut().insert(context); + } } #[cfg(test)] @@ -24,7 +26,7 @@ mod tests { use edgezero_core::body::Body; use edgezero_core::http::request_builder; use std::net::IpAddr; - use std::str::FromStr; + use std::str::FromStr as _; #[test] fn inserts_and_retrieves_client_ip() { diff --git a/crates/edgezero-adapter-fastly/src/key_value_store.rs b/crates/edgezero-adapter-fastly/src/key_value_store.rs index 98d7d470..111d18fd 100644 --- a/crates/edgezero-adapter-fastly/src/key_value_store.rs +++ b/crates/edgezero-adapter-fastly/src/key_value_store.rs @@ -13,6 +13,8 @@ use bytes::Bytes; #[cfg(feature = "fastly")] use edgezero_core::key_value_store::{KvError, KvPage, KvStore}; #[cfg(feature = "fastly")] +use fastly::kv_store::{KVStore, KVStoreError}; +#[cfg(feature = "fastly")] use std::time::Duration; /// KV store backed by Fastly's KV Store API. @@ -20,7 +22,7 @@ use std::time::Duration; /// Wraps a `fastly::kv_store::KVStore` handle obtained via `KVStore::open(name)`. #[cfg(feature = "fastly")] pub struct FastlyKvStore { - store: fastly::kv_store::KVStore, + store: KVStore, } #[cfg(feature = "fastly")] @@ -28,9 +30,13 @@ impl FastlyKvStore { /// Open a Fastly KV Store by name. /// /// Returns `KvError::Unavailable` if the store does not exist. + /// + /// # Errors + /// Returns [`KvError::Internal`] if the named KV store cannot be opened. + #[inline] pub fn open(name: &str) -> Result { - let store = fastly::kv_store::KVStore::open(name) - .map_err(|e| KvError::Internal(anyhow::anyhow!("failed to open kv store: {e}")))? + let store = KVStore::open(name) + .map_err(|err| KvError::Internal(anyhow::anyhow!("failed to open kv store: {err}")))? .ok_or(KvError::Unavailable)?; Ok(Self { store }) } @@ -39,70 +45,80 @@ impl FastlyKvStore { #[cfg(feature = "fastly")] #[async_trait(?Send)] impl KvStore for FastlyKvStore { + #[inline] + async fn delete(&self, key: &str) -> Result<(), KvError> { + self.store + .delete(key) + .map_err(|err| KvError::Internal(anyhow::anyhow!("delete failed: {err}"))) + } + + #[inline] + async fn exists(&self, key: &str) -> Result { + Ok(self.get_bytes(key).await?.is_some()) + } + + #[inline] async fn get_bytes(&self, key: &str) -> Result, KvError> { match self.store.lookup(key) { Ok(mut response) => { let bytes = response.take_body_bytes(); Ok(Some(Bytes::from(bytes))) } - Err(fastly::kv_store::KVStoreError::ItemNotFound) => Ok(None), - Err(e) => Err(KvError::Internal(anyhow::anyhow!("lookup failed: {e}"))), + Err(KVStoreError::ItemNotFound) => Ok(None), + Err(err) => Err(KvError::Internal(anyhow::anyhow!("lookup failed: {err}"))), } } - async fn put_bytes(&self, key: &str, value: Bytes) -> Result<(), KvError> { - self.store - .insert(key, value.as_ref()) - .map_err(|e| KvError::Internal(anyhow::anyhow!("insert failed: {e}"))) - } - - async fn put_bytes_with_ttl( - &self, - key: &str, - value: Bytes, - ttl: Duration, - ) -> Result<(), KvError> { - self.store - .build_insert() - .time_to_live(ttl) - .execute(key, value.as_ref()) - .map_err(|e| KvError::Internal(anyhow::anyhow!("insert with ttl failed: {e}"))) - } - - async fn delete(&self, key: &str) -> Result<(), KvError> { - self.store - .delete(key) - .map_err(|e| KvError::Internal(anyhow::anyhow!("delete failed: {e}"))) - } - + #[inline] async fn list_keys_page( &self, prefix: &str, cursor: Option<&str>, limit: usize, ) -> Result { - let limit = u32::try_from(limit) - .map_err(|_| KvError::Validation("list limit exceeds u32".to_string()))?; + let limit_u32 = u32::try_from(limit) + .map_err(|_e| KvError::Validation("list limit exceeds u32".to_owned()))?; - let mut request = self.store.build_list().limit(limit); + let mut request = self.store.build_list().limit(limit_u32); if !prefix.is_empty() { request = request.prefix(prefix); } - if let Some(cursor) = cursor.filter(|cursor| !cursor.is_empty()) { - request = request.cursor(cursor); + if let Some(token) = cursor.filter(|token| !token.is_empty()) { + request = request.cursor(token); } let page = request .execute() - .map_err(|e| KvError::Internal(anyhow::anyhow!("list failed: {e}")))?; - let cursor = page.next_cursor().filter(|cursor| !cursor.is_empty()); + .map_err(|err| KvError::Internal(anyhow::anyhow!("list failed: {err}")))?; + let next_cursor = page.next_cursor().filter(|token| !token.is_empty()); Ok(KvPage { + cursor: next_cursor, keys: page.into_keys(), - cursor, }) } + + #[inline] + async fn put_bytes(&self, key: &str, value: Bytes) -> Result<(), KvError> { + self.store + .insert(key, value.as_ref()) + .map_err(|err| KvError::Internal(anyhow::anyhow!("insert failed: {err}"))) + } + + #[inline] + async fn put_bytes_with_ttl( + &self, + key: &str, + value: Bytes, + ttl: Duration, + ) -> Result<(), KvError> { + self.store + .build_insert() + .time_to_live(ttl) + .execute(key, value.as_ref()) + .map_err(|err| KvError::Internal(anyhow::anyhow!("insert with ttl failed: {err}"))) + } } // TODO: integration tests require the Fastly compute environment. diff --git a/crates/edgezero-adapter-fastly/src/lib.rs b/crates/edgezero-adapter-fastly/src/lib.rs index e64a6fed..9c28730a 100644 --- a/crates/edgezero-adapter-fastly/src/lib.rs +++ b/crates/edgezero-adapter-fastly/src/lib.rs @@ -5,67 +5,67 @@ pub mod cli; #[cfg(feature = "fastly")] pub mod config_store; -mod context; +pub mod context; #[cfg(feature = "fastly")] pub mod key_value_store; #[cfg(feature = "fastly")] -mod logger; +pub mod logger; #[cfg(feature = "fastly")] -mod proxy; +pub mod proxy; #[cfg(feature = "fastly")] -mod request; +pub mod request; #[cfg(feature = "fastly")] -mod response; +pub mod response; #[cfg(feature = "fastly")] pub mod secret_store; #[cfg(feature = "fastly")] -pub use config_store::FastlyConfigStore; -pub use context::FastlyRequestContext; +use edgezero_core::app::Hooks; #[cfg(feature = "fastly")] -pub use proxy::FastlyProxyClient; +use edgezero_core::env_config::EnvConfig; #[cfg(feature = "fastly")] -#[allow(deprecated)] -pub use request::{ - dispatch, dispatch_with_config, dispatch_with_config_handle, dispatch_with_kv, - dispatch_with_kv_and_secrets, dispatch_with_secrets, into_core_request, DEFAULT_KV_STORE_NAME, -}; -#[cfg(feature = "fastly")] -pub use response::from_core_response; -#[cfg(feature = "fastly")] -pub use secret_store::FastlySecretStore; - +use edgezero_core::manifest::ResolvedLoggingConfig; #[cfg(feature = "fastly")] #[derive(Debug, Clone)] pub struct FastlyLogging { + pub echo_stdout: bool, pub endpoint: Option, pub level: log::LevelFilter, - pub echo_stdout: bool, pub use_fastly_logger: bool, } #[cfg(feature = "fastly")] -impl From for FastlyLogging { - fn from(config: edgezero_core::manifest::ResolvedLoggingConfig) -> Self { +impl From for FastlyLogging { + #[inline] + fn from(config: ResolvedLoggingConfig) -> Self { Self { + echo_stdout: config.echo_stdout.unwrap_or(true), endpoint: config.endpoint, level: config.level.into(), - echo_stdout: config.echo_stdout.unwrap_or(true), use_fastly_logger: true, } } } +/// # Errors +/// Returns [`logger::InitLoggerError::Build`] if the underlying logger +/// builder rejects its inputs (e.g. an empty endpoint), or +/// [`logger::InitLoggerError::SetLogger`] if a global logger is already +/// installed. #[cfg(feature = "fastly")] +#[inline] pub fn init_logger( endpoint: &str, level: log::LevelFilter, echo_stdout: bool, -) -> Result<(), log::SetLoggerError> { +) -> Result<(), logger::InitLoggerError> { logger::init_logger(endpoint, level, echo_stdout) } +/// # Errors +/// Never; this is a no-op stub on builds without the `fastly` feature. #[cfg(not(feature = "fastly"))] +#[inline] pub fn init_logger( _endpoint: &str, _level: log::LevelFilter, @@ -74,141 +74,91 @@ pub fn init_logger( Ok(()) } -#[cfg(feature = "fastly")] -pub trait AppExt { - #[deprecated( - note = "AppExt::dispatch() is the low-level manual path and does not inject config-store metadata; prefer run_app(), dispatch_with_config(), or dispatch_with_config_handle()" - )] - fn dispatch(&self, req: fastly::Request) -> Result; -} - -#[cfg(feature = "fastly")] -impl AppExt for edgezero_core::app::App { - #[allow(deprecated)] - fn dispatch(&self, req: fastly::Request) -> Result { - crate::request::dispatch_raw(self, req) +/// Resolve [`FastlyLogging`] from `EDGEZERO__LOGGING__LEVEL`, falling back to +/// the adapter default when the variable is unset or unparseable. +#[cfg(feature = "fastly")] +fn logging_from_env(env: &EnvConfig) -> FastlyLogging { + use std::str::FromStr as _; + + let level = env + .logging_level() + .and_then(|raw| log::LevelFilter::from_str(raw).ok()) + .unwrap_or(log::LevelFilter::Info); + // Only attach Fastly's named-endpoint logger when `EDGEZERO__LOGGING__ENDPOINT` + // is set. Production deployments set it to a real `[log_endpoints]` entry from + // `fastly.toml`; local Viceroy runs leave it unset and avoid the + // "endpoint not found, or is reserved" error that fires when the adapter + // would otherwise fall back to a reserved name like `stdout`. + let endpoint = env.logging_endpoint().map(str::to_owned); + let use_fastly_logger = endpoint.is_some(); + FastlyLogging { + echo_stdout: true, + endpoint, + level, + use_fastly_logger, } } /// Entry point for a Fastly Compute application. /// -/// **Breaking change (pre-1.0):** `manifest_src` is now a required parameter. -#[cfg(feature = "fastly")] -pub fn run_app( - manifest_src: &str, - req: fastly::Request, -) -> Result { - let manifest_loader = edgezero_core::manifest::ManifestLoader::load_from_str(manifest_src); - let manifest = manifest_loader.manifest(); - let logging = manifest.logging_or_default(edgezero_core::app::FASTLY_ADAPTER); - // Two-path resolution: `A::config_store()` is set at compile time by the - // `#[app]` macro and is the common case. The manifest fallback handles - // callers that implement `Hooks` manually without the macro — in that case - // `A::config_store()` returns `None` while `[stores.config]` in - // `edgezero.toml` may still be present. - let config_name = A::config_store() - .map(|cfg| { - cfg.name_for_adapter(edgezero_core::app::FASTLY_ADAPTER) - .to_string() - }) - .or_else(|| { - manifest.stores.config.as_ref().map(|cfg| { - cfg.config_store_name(edgezero_core::app::FASTLY_ADAPTER) - .to_string() - }) - }); - let kv_name = manifest - .kv_store_name(edgezero_core::app::FASTLY_ADAPTER) - .to_string(); - let requirements = StoreRequirements { - kv_required: manifest.stores.kv.is_some(), - secrets_required: manifest.secret_store_enabled("fastly"), - }; - run_app_with_stores::( - logging.into(), - req, - config_name.as_deref(), - &kv_name, - requirements, - ) -} - -/// Dispatch with a config store. Prefer this over `run_app_with_logging` for new code. -#[cfg(feature = "fastly")] -pub fn run_app_with_config( - logging: FastlyLogging, - req: fastly::Request, - config_store_name: Option<&str>, -) -> Result { - run_app_with_stores::( - logging, - req, - config_store_name, - DEFAULT_KV_STORE_NAME, - StoreRequirements::default(), - ) -} - -/// Compatibility wrapper for callers that do not use a config store. -#[cfg(feature = "fastly")] -pub fn run_app_with_logging( - logging: FastlyLogging, - req: fastly::Request, -) -> Result { - run_app_with_stores::( - logging, - req, - None, - DEFAULT_KV_STORE_NAME, - StoreRequirements::default(), - ) -} - -/// Whether each optional store is required to be present at startup. +/// Portable store config is baked into `A` by the `app!` macro; adapter-specific +/// values (platform store names, logging level) are read at runtime from +/// `EDGEZERO__*` environment variables. No `edgezero.toml` is required. /// -/// Using a named struct instead of positional `bool` arguments prevents -/// accidental parameter swaps between `kv_required` and `secrets_required`. -#[cfg(feature = "fastly")] -#[derive(Default)] -struct StoreRequirements { - kv_required: bool, - secrets_required: bool, +/// # Errors +/// Returns an error if logger setup fails or any required store cannot be opened. +#[cfg(feature = "fastly")] +#[inline] +pub fn run_app(req: fastly::Request) -> Result { + let env = EnvConfig::from_env(); + let stores = A::stores(); + let logging = logging_from_env(&env); + if logging.use_fastly_logger { + let endpoint = logging.endpoint.as_deref().unwrap_or("stdout"); + init_logger(endpoint, logging.level, logging.echo_stdout)?; + } + let app = A::build_app(); + request::dispatch_with_registries(&app, req, stores.config, stores.kv, stores.secrets, &env) } +/// Dispatch with a config store wired explicitly. Use `run_app` for +/// the manifest-driven flow that resolves stores automatically. KV +/// is NOT auto-injected on this path; chain `.with_kv(name)` on a +/// `FastlyService` builder if you need KV alongside the config store. +/// +/// # Errors +/// Returns an error if logger setup fails or the underlying handler returns an error. #[cfg(feature = "fastly")] -fn run_app_with_stores( - logging: FastlyLogging, +#[inline] +pub fn run_app_with_config( + logging: &FastlyLogging, req: fastly::Request, config_store_name: Option<&str>, - kv_store_name: &str, - requirements: StoreRequirements, ) -> Result { if logging.use_fastly_logger { let endpoint = logging.endpoint.as_deref().unwrap_or("stdout"); - init_logger(endpoint, logging.level, logging.echo_stdout).expect("init fastly logger"); + init_logger(endpoint, logging.level, logging.echo_stdout)?; } - let app = A::build_app(); - crate::request::dispatch_with_store_names( - &app, - req, - config_store_name, - kv_store_name, - requirements.kv_required, - requirements.secrets_required, - ) + let mut service = request::FastlyService::new(&app); + if let Some(name) = config_store_name { + service = service.with_config(name); + } + service.dispatch(req) } -#[cfg(all(test, feature = "fastly"))] +#[cfg(test)] +#[cfg(feature = "fastly")] mod tests { use super::*; + use edgezero_core::manifest::LogLevel; #[test] fn fastly_logging_from_manifest_converts_defaults() { - let config = edgezero_core::manifest::ResolvedLoggingConfig { - endpoint: Some("endpoint".to_string()), + let config = ResolvedLoggingConfig { echo_stdout: Some(false), - level: edgezero_core::manifest::LogLevel::Debug, + endpoint: Some("endpoint".to_owned()), + level: LogLevel::Debug, }; let logging: FastlyLogging = config.into(); diff --git a/crates/edgezero-adapter-fastly/src/logger.rs b/crates/edgezero-adapter-fastly/src/logger.rs index 1fe47166..1b040eaf 100644 --- a/crates/edgezero-adapter-fastly/src/logger.rs +++ b/crates/edgezero-adapter-fastly/src/logger.rs @@ -1,18 +1,37 @@ use log::LevelFilter; +/// Errors that can occur when initialising the Fastly logger. +#[derive(Debug, thiserror::Error)] +pub enum InitLoggerError { + /// The `log_fastly::Logger::builder()` rejected its inputs (e.g. the + /// endpoint string is empty). + #[error("failed to build Fastly logger: {0}")] + Build(String), + /// `log::set_boxed_logger` (via `fern`) failed because a global logger + /// was already installed. + #[error(transparent)] + SetLogger(#[from] log::SetLoggerError), +} + /// Initialize logging (opinionated): formatted timestamps using `fern`, /// chained to the Fastly logger. +/// +/// # Errors +/// Returns [`InitLoggerError::Build`] if the underlying logger builder +/// rejects its inputs (e.g. an empty endpoint), or +/// [`InitLoggerError::SetLogger`] if a global logger is already installed. +#[inline] pub fn init_logger( endpoint: &str, level: LevelFilter, echo_stdout: bool, -) -> Result<(), log::SetLoggerError> { +) -> Result<(), InitLoggerError> { let logger = log_fastly::Logger::builder() .default_endpoint(endpoint) .echo_stdout(echo_stdout) .max_level(level) .build() - .expect("failed to build Fastly logger"); + .map_err(|err| InitLoggerError::Build(err.to_string()))?; // Format timestamps in RFC3339 with milliseconds using UTC to avoid TZ issues in WASM. let dispatch = fern::Dispatch::new() @@ -23,9 +42,12 @@ pub fn init_logger( chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, true), record.level(), message - )) + )); }) - .chain(Box::new(logger) as Box); + .chain({ + let boxed: Box = Box::new(logger); + boxed + }); dispatch.apply()?; log::set_max_level(level); diff --git a/crates/edgezero-adapter-fastly/src/proxy.rs b/crates/edgezero-adapter-fastly/src/proxy.rs index daef2757..2947f33b 100644 --- a/crates/edgezero-adapter-fastly/src/proxy.rs +++ b/crates/edgezero-adapter-fastly/src/proxy.rs @@ -5,25 +5,28 @@ use edgezero_core::body::Body; use edgezero_core::compression::{decode_brotli_stream, decode_gzip_stream}; use edgezero_core::error::EdgeError; use edgezero_core::http::{header, HeaderMap, HeaderValue, Method, Uri}; -use edgezero_core::proxy::{ProxyClient, ProxyRequest, ProxyResponse}; +use edgezero_core::proxy::{ProxyClient, ProxyRequest, ProxyResponse, PROXY_HEADER}; use fastly::{ error::anyhow, http::body::StreamingBody, Backend, Request as FastlyRequest, Response as FastlyResponse, }; -use futures_util::stream::{BoxStream, StreamExt}; -use std::io::{self, Write}; +use futures_util::stream::{BoxStream, StreamExt as _}; +use std::io::{self, Write as _}; use std::time::Duration; const BACKEND_PREFIX: &str = "edgezero-dynamic-"; +type ChunkStream = BoxStream<'static, Result, io::Error>>; + pub struct FastlyProxyClient; #[async_trait(?Send)] impl ProxyClient for FastlyProxyClient { + #[inline] async fn send(&self, request: ProxyRequest) -> Result { let (method, uri, headers, body, _ext) = request.into_parts(); let backend_name = ensure_backend(&uri)?; - let fastly_request = build_fastly_request(method, &uri, headers)?; + let fastly_request = build_fastly_request(method, &uri, &headers); let (mut streaming_body, pending_request) = fastly_request .send_async_streaming(&backend_name) .map_err(EdgeError::internal)?; @@ -31,24 +34,19 @@ impl ProxyClient for FastlyProxyClient { streaming_body.finish().map_err(EdgeError::internal)?; let mut fastly_response = pending_request.wait().map_err(EdgeError::internal)?; - let mut proxy_response = convert_response(&mut fastly_response)?; - proxy_response.headers_mut().insert( - edgezero_core::proxy::PROXY_HEADER, - HeaderValue::from_static("fastly"), - ); + let mut proxy_response = convert_response(&mut fastly_response); + proxy_response + .headers_mut() + .insert(PROXY_HEADER, HeaderValue::from_static("fastly")); Ok(proxy_response) } } -fn build_fastly_request( - method: Method, - uri: &Uri, - headers: HeaderMap, -) -> Result { +fn build_fastly_request(method: Method, uri: &Uri, headers: &HeaderMap) -> FastlyRequest { let mut fastly_request = FastlyRequest::new(method.clone(), uri.to_string()); fastly_request.set_method(method); - for (name, value) in headers.iter() { + for (name, value) in headers { if name.as_str().eq_ignore_ascii_case("host") { continue; } @@ -59,34 +57,38 @@ fn build_fastly_request( fastly_request.set_header("Host", host); } - Ok(fastly_request) + fastly_request } -async fn forward_request_body( - body: Body, - streaming_body: &mut StreamingBody, -) -> Result<(), EdgeError> { - match body { - Body::Once(bytes) => { - if !bytes.is_empty() { - streaming_body - .write_all(bytes.as_ref()) - .map_err(EdgeError::internal)?; - } - } - Body::Stream(mut stream) => { - while let Some(chunk) = stream.next().await { - let chunk = chunk.map_err(EdgeError::internal)?; - streaming_body - .write_all(&chunk) - .map_err(EdgeError::internal)?; - } +fn convert_response(fastly_response: &mut FastlyResponse) -> ProxyResponse { + let status = fastly_response.get_status(); + let mut proxy_response = ProxyResponse::new(status, Body::empty()); + + for header in fastly_response.get_header_names() { + if let Some(value) = fastly_response.get_header(header) { + proxy_response.headers_mut().insert(header, value.clone()); } } - streaming_body.flush().map_err(EdgeError::internal)?; + let encoding = proxy_response + .headers() + .get(header::CONTENT_ENCODING) + .and_then(|value| value.to_str().ok()) + .map(str::to_ascii_lowercase); - Ok(()) + let body = fastly_response.take_body(); + + let chunk_stream = fastly_body_stream(body); + let body_stream = transform_stream(chunk_stream, encoding.as_deref()); + *proxy_response.body_mut() = Body::from_stream(body_stream); + if encoding.as_deref() == Some("gzip") || encoding.as_deref() == Some("br") { + proxy_response + .headers_mut() + .remove(header::CONTENT_ENCODING); + proxy_response.headers_mut().remove(header::CONTENT_LENGTH); + } + + proxy_response } fn ensure_backend(uri: &Uri) -> Result { @@ -98,15 +100,15 @@ fn ensure_backend(uri: &Uri) -> Result { let is_https = scheme.eq_ignore_ascii_case("https"); let target_port = match (uri.port_u16(), is_https) { - (Some(p), _) => p, + (Some(port), _) => port, (None, true) => 443, (None, false) => 80, }; - let host_with_port = format!("{}:{}", host, target_port); + let host_with_port = format!("{host}:{target_port}"); // Human-readable name: backend_{scheme}_{host}_{port} with dots/colons sanitised - let name_base = format!("{}_{}_{}", scheme, host, target_port); + let name_base = format!("{scheme}_{host}_{target_port}"); let backend_name = format!("{}{}", BACKEND_PREFIX, name_base.replace(['.', ':'], "_")); let mut builder = Backend::builder(&backend_name, &host_with_port) @@ -120,78 +122,65 @@ fn ensure_backend(uri: &Uri) -> Result { .enable_ssl() .sni_hostname(host) .check_certificate(host); - log::debug!("enable ssl for backend: {}", backend_name); + log::debug!("enable ssl for backend: {backend_name}"); } match builder.finish() { Ok(_) => { - log::debug!( - "created dynamic backend: {} -> {}", - backend_name, - host_with_port - ); + log::debug!("created dynamic backend: {backend_name} -> {host_with_port}"); Ok(backend_name) } - Err(e) => { - let msg = e.to_string(); + Err(err) => { + let msg = err.to_string(); if msg.contains("NameInUse") || msg.contains("already in use") { - log::debug!("reusing existing dynamic backend: {}", backend_name); + log::debug!("reusing existing dynamic backend: {backend_name}"); Ok(backend_name) } else { Err(EdgeError::internal(anyhow!( - "dynamic backend creation failed ({} -> {}): {}", - backend_name, - host_with_port, - msg + "dynamic backend creation failed ({backend_name} -> {host_with_port}): {msg}" ))) } } } } -fn convert_response(fastly_response: &mut FastlyResponse) -> Result { - let status = fastly_response.get_status(); - let mut proxy_response = ProxyResponse::new(status, Body::empty()); - - for header in fastly_response.get_header_names() { - if let Some(value) = fastly_response.get_header(header) { - proxy_response.headers_mut().insert(header, value.clone()); - } - } - - let encoding = proxy_response - .headers() - .get(header::CONTENT_ENCODING) - .and_then(|value| value.to_str().ok()) - .map(|value| value.to_ascii_lowercase()); - - let body = fastly_response.take_body(); - - let chunk_stream = fastly_body_stream(body); - let body_stream = transform_stream(chunk_stream, encoding.as_deref()); - *proxy_response.body_mut() = Body::from_stream(body_stream); - if encoding.as_deref() == Some("gzip") || encoding.as_deref() == Some("br") { - proxy_response - .headers_mut() - .remove(header::CONTENT_ENCODING); - proxy_response.headers_mut().remove(header::CONTENT_LENGTH); - } - - Ok(proxy_response) -} - -type ChunkStream = BoxStream<'static, Result, io::Error>>; - fn fastly_body_stream(mut body: fastly::Body) -> ChunkStream { try_stream! { - for chunk in body.read_chunks(8 * 1024) { - let chunk = chunk?; + for result in body.read_chunks(8 * 1024) { + let chunk = result?; yield chunk; } } .boxed() } +async fn forward_request_body( + body: Body, + streaming_body: &mut StreamingBody, +) -> Result<(), EdgeError> { + match body { + Body::Once(bytes) => { + if !bytes.is_empty() { + streaming_body + .write_all(bytes.as_ref()) + .map_err(EdgeError::internal)?; + } + } + Body::Stream(mut stream) => { + while let Some(result) = stream.next().await { + let chunk = result.map_err(EdgeError::internal)?; + streaming_body + .write_all(&chunk) + .map_err(EdgeError::internal)?; + } + } + } + + streaming_body.flush().map_err(EdgeError::internal)?; + + Ok(()) +} + fn transform_stream( stream: ChunkStream, encoding: Option<&str>, @@ -209,33 +198,26 @@ mod tests { use brotli::CompressorWriter; use flate2::{write::GzEncoder, Compression}; use futures::executor::block_on; - use std::io::Write; - #[test] - fn stream_handles_identity_and_gzip() { - let mut plain = fastly::Body::new(); - plain.write_all(b"plain").unwrap(); - let body = Body::from_stream(transform_stream(fastly_body_stream(plain), None)); - let collected = collect_body(body); - assert_eq!(collected, b"plain"); - - let mut encoder = GzEncoder::new(Vec::new(), Compression::default()); - encoder.write_all(b"hello gzip").unwrap(); - let compressed = encoder.finish().unwrap(); - let mut gz_body = fastly::Body::new(); - gz_body.write_all(&compressed).unwrap(); - let body = Body::from_stream(transform_stream(fastly_body_stream(gz_body), Some("gzip"))); - let collected = collect_body(body); - assert_eq!(collected, b"hello gzip"); + fn collect_body(body: Body) -> Vec { + match body { + Body::Once(bytes) => bytes.to_vec(), + Body::Stream(mut stream) => block_on(async { + let mut out = Vec::new(); + while let Some(chunk) = stream.next().await { + out.extend_from_slice(&chunk.expect("chunk")); + } + out + }), + } } #[test] fn stream_handles_brotli() { let mut compressed = Vec::new(); - { - let mut compressor = CompressorWriter::new(&mut compressed, 4096, 5, 21); - compressor.write_all(b"hello brotli").unwrap(); - } + let mut compressor = CompressorWriter::new(&mut compressed, 4096, 5, 21); + compressor.write_all(b"hello brotli").unwrap(); + drop(compressor); let mut br_body = fastly::Body::new(); br_body.write_all(&compressed).unwrap(); @@ -244,16 +226,20 @@ mod tests { assert_eq!(collected, b"hello brotli"); } - fn collect_body(body: Body) -> Vec { - match body { - Body::Once(bytes) => bytes.to_vec(), - Body::Stream(mut stream) => block_on(async { - let mut out = Vec::new(); - while let Some(chunk) = stream.next().await { - out.extend_from_slice(&chunk.expect("chunk")); - } - out - }), - } + #[test] + fn stream_handles_identity_and_gzip() { + let mut plain = fastly::Body::new(); + plain.write_all(b"plain").unwrap(); + let plain_body = Body::from_stream(transform_stream(fastly_body_stream(plain), None)); + assert_eq!(collect_body(plain_body), b"plain"); + + let mut encoder = GzEncoder::new(Vec::new(), Compression::default()); + encoder.write_all(b"hello gzip").unwrap(); + let compressed = encoder.finish().unwrap(); + let mut gz_body = fastly::Body::new(); + gz_body.write_all(&compressed).unwrap(); + let gzip_body = + Body::from_stream(transform_stream(fastly_body_stream(gz_body), Some("gzip"))); + assert_eq!(collect_body(gzip_body), b"hello gzip"); } } diff --git a/crates/edgezero-adapter-fastly/src/request.rs b/crates/edgezero-adapter-fastly/src/request.rs index 59f7f976..eaecdbc6 100644 --- a/crates/edgezero-adapter-fastly/src/request.rs +++ b/crates/edgezero-adapter-fastly/src/request.rs @@ -1,26 +1,55 @@ use std::collections::{HashSet, VecDeque}; -use std::io::Read; -use std::sync::{Arc, Mutex, OnceLock}; +use std::fmt::Display; +use std::io::Read as _; +use std::sync::{Arc, Mutex, OnceLock, PoisonError}; -use edgezero_core::app::App; +use edgezero_core::app::{App, StoreMetadata}; use edgezero_core::body::Body; use edgezero_core::config_store::ConfigStoreHandle; +use edgezero_core::env_config::EnvConfig; use edgezero_core::error::EdgeError; use edgezero_core::http::{request_builder, Request}; use edgezero_core::key_value_store::KvHandle; use edgezero_core::proxy::ProxyHandle; use edgezero_core::secret_store::SecretHandle; +use edgezero_core::store_registry::{ + BoundSecretStore, ConfigRegistry, KvRegistry, SecretRegistry, StoreRegistry, +}; use fastly::{Error as FastlyError, Request as FastlyRequest, Response as FastlyResponse}; use futures::executor; +use std::collections::BTreeMap; use crate::config_store::FastlyConfigStore; +use crate::context::FastlyRequestContext; use crate::key_value_store::FastlyKvStore; use crate::proxy::FastlyProxyClient; use crate::response::{from_core_response, parse_uri}; -use crate::FastlyRequestContext; +use crate::secret_store::FastlySecretStore; const WARNED_STORE_CACHE_LIMIT: usize = 64; +#[derive(Default)] +struct RecentStringSet { + keys: HashSet, + order: VecDeque, +} + +impl RecentStringSet { + fn insert(&mut self, key: &str, limit: usize) -> bool { + let owned = key.to_owned(); + if !self.keys.insert(owned.clone()) { + return false; + } + self.order.push_back(owned); + while limit > 0 && self.order.len() > limit { + if let Some(oldest) = self.order.pop_front() { + self.keys.remove(&oldest); + } + } + true + } +} + /// Groups the optional per-request store handles injected at dispatch time. /// /// Use `..Default::default()` for fields you do not need: @@ -29,18 +58,378 @@ const WARNED_STORE_CACHE_LIMIT: usize = 64; /// let stores = Stores { kv: Some(kv_handle), ..Default::default() }; /// ``` #[derive(Default)] -pub(crate) struct Stores { - pub(crate) config_store: Option, - pub(crate) kv: Option, - pub(crate) secrets: Option, +struct Stores { + config_registry: Option, + config_store: Option, + kv: Option, + kv_registry: Option, + secret_registry: Option, + secrets: Option, +} + +enum ConfigSource { + Handle(ConfigStoreHandle), + Name(String), + None, +} + +/// Fastly per-request dispatch service. +/// +/// Builds a router invocation with the stores the operator wants +/// injected into request extensions, then dispatches one request +/// against the wrapped `App`. The store wiring is a per-Service +/// decision; on Fastly Compute that means per-request (the worker +/// model invokes the entrypoint per HTTP request), but the Service +/// type itself is cheap to build. +/// +/// Replaces the prior `dispatch_with_*` variant fan-out. Each +/// builder method is independent: enable any combination of KV, +/// config, and secret stores by chaining the relevant `with_*` / +/// `require_*` calls. The manifest-driven `run_app` is still the +/// recommended entrypoint for normal flows -- the Service builder +/// is for manual / no-manifest deployments. +/// +/// ```rust,ignore +/// FastlyService::new(&app) +/// .with_kv("sessions").require_kv() +/// .with_config("app_config") +/// .with_secrets() +/// .dispatch(req) +/// ``` +pub struct FastlyService<'app> { + app: &'app App, + config: ConfigSource, + kv: Option, + secrets: SecretSource, +} + +struct KvSource { + name: String, + required: bool, +} + +enum SecretSource { + Off, + On { required: bool }, +} + +impl<'app> FastlyService<'app> { + /// Resolve every wired store at request time and dispatch + /// against the wrapped `App`. Consumes the service so a builder + /// can't be reused with stale wiring. + /// + /// # Errors + /// Returns an error if a required store cannot be opened or + /// the underlying handler returns an error. + #[inline] + pub fn dispatch(self, req: FastlyRequest) -> Result { + let config_store = match self.config { + ConfigSource::Handle(handle) => Some(handle), + ConfigSource::Name(name) => match FastlyConfigStore::try_open(&name) { + Ok(store) => Some(ConfigStoreHandle::new(Arc::new(store))), + Err(err) => { + warn_missing_store_once(&name, &err.to_string()); + None + } + }, + ConfigSource::None => None, + }; + let kv = match self.kv { + Some(source) => resolve_kv_handle(&source.name, source.required)?, + None => None, + }; + let secrets = match self.secrets { + SecretSource::Off => None, + SecretSource::On { required } => resolve_secret_handle(required), + }; + dispatch_with_handles( + self.app, + req, + Stores { + config_store, + kv, + secrets, + ..Default::default() + }, + ) + } + + /// Build a new service that dispatches against `app` with NO + /// stores wired. Chain `.with_*` / `.require_*` to add stores. + #[must_use] + #[inline] + pub fn new(app: &'app App) -> Self { + Self { + app, + config: ConfigSource::None, + kv: None, + secrets: SecretSource::Off, + } + } + + /// Promote the previously-wired KV store to required: an + /// unavailable store causes dispatch to return an error + /// instead of silently degrading. No-op when `with_kv` wasn't + /// called. + #[must_use] + #[inline] + pub fn require_kv(mut self) -> Self { + if let Some(kv) = self.kv.as_mut() { + kv.required = true; + } + self + } + + /// Promote the previously-wired secret store to required. + /// No-op when `with_secrets` wasn't called. + #[must_use] + #[inline] + pub fn require_secrets(mut self) -> Self { + if let SecretSource::On { ref mut required } = self.secrets { + *required = true; + } + self + } + + /// Open the Fastly Config Store named `name` and inject its + /// handle into request extensions. If the store is unavailable + /// at request time, the dispatcher logs the warning once and + /// proceeds without it. + #[must_use] + #[inline] + pub fn with_config>(mut self, name: S) -> Self { + self.config = ConfigSource::Name(name.into()); + self + } + + /// Inject a pre-built `ConfigStoreHandle`. Use this when the + /// caller has already opened (or mocked) the backend. Mutually + /// exclusive with `with_config(name)` -- the last call wins. + #[must_use] + #[inline] + pub fn with_config_handle(mut self, handle: ConfigStoreHandle) -> Self { + self.config = ConfigSource::Handle(handle); + self + } + + /// Open a Fastly KV Store by `name` and inject its handle. + /// Non-required by default: an absent store logs once and + /// dispatch continues. Pair with `require_kv()` when the + /// manifest declares `[stores.kv]` and a missing store should + /// fail loudly. + #[must_use] + #[inline] + pub fn with_kv>(mut self, name: S) -> Self { + self.kv = Some(KvSource { + name: name.into(), + required: false, + }); + self + } + + /// Enable the Fastly Secret Store and inject its handle. + /// Non-required by default: an absent store leaves no secret + /// handle in extensions and dispatch continues. Pair with + /// `require_secrets()` when the manifest declares + /// `[stores.secrets]`. + /// + /// Platform-name binding: the synthesised `SecretRegistry` + /// binds the handle to platform store name `"default"`. + /// Handlers reading `ctx.secret_store_default()?.require_str(key)` + /// open a Fastly Secret Store literally named `"default"`. Use + /// the manifest-aware `run_app` if your account uses a + /// different store name -- it routes through the env-overlay + /// resolution path instead. + #[must_use] + #[inline] + pub fn with_secrets(mut self) -> Self { + self.secrets = SecretSource::On { required: false }; + self + } +} + +fn dispatch_core_request( + app: &App, + mut core_request: Request, + stores: Stores, +) -> Result { + // Hard-cutoff: legacy bare handles are no longer + // inserted into request extensions. `with_config_handle` + // still accepts a `ConfigStoreHandle`, but the dispatcher + // synthesises a one-id `Registry` from any wired handle + // and only the registry goes into extensions. The + // `ctx.{config,kv,secret}_handle()` accessors are gone; handlers + // use `ctx.{config,kv,secret}_store_default()` or the + // `Kv` / `Config` / `Secrets` extractors. + let (config_registry, kv_registry, secret_registry) = synthesise_store_registries(stores); + if let Some(registry) = config_registry { + core_request.extensions_mut().insert(registry); + } + if let Some(registry) = kv_registry { + core_request.extensions_mut().insert(registry); + } + if let Some(registry) = secret_registry { + core_request.extensions_mut().insert(registry); + } + let response = executor::block_on(app.router().oneshot(core_request)) + .map_err(|err| map_edge_error(&err))?; + from_core_response(response).map_err(|err| map_edge_error(&err)) } -/// Default Fastly KV Store name. +fn dispatch_with_handles( + app: &App, + req: FastlyRequest, + stores: Stores, +) -> Result { + let core_request = into_core_request(req).map_err(|err| map_edge_error(&err))?; + dispatch_core_request(app, core_request, stores) +} + +/// Dispatch with per-id store registries built from baked metadata. /// -/// If a KV Store with this name exists in your Fastly service, it will -/// be automatically available to handlers via the `Kv` extractor. -pub const DEFAULT_KV_STORE_NAME: &str = edgezero_core::manifest::DEFAULT_KV_STORE_NAME; +/// Fastly is `Multi` for all three kinds, so each declared id resolves to +/// its own platform store via `EDGEZERO__STORES______NAME` (or the +/// id default). KV failures escalate when `kv_required` is set; missing +/// config / secret stores degrade silently with a one-time warning. +pub(crate) fn dispatch_with_registries( + app: &App, + req: FastlyRequest, + config_meta: Option, + kv_meta: Option, + secret_meta: Option, + env: &EnvConfig, +) -> Result { + let kv_registry = build_kv_registry(kv_meta, env)?; + let config_registry = build_config_registry(config_meta, env); + let secret_registry = build_secret_registry(secret_meta, env); + dispatch_with_handles( + app, + req, + Stores { + config_registry, + kv_registry, + secret_registry, + ..Default::default() + }, + ) +} + +/// Pure synthesis: collapse a `Stores` (which may carry both a +/// wired multi-id registry AND a legacy bare handle) into the +/// three registries that go into request extensions. Precedence +/// is "registry wins": a wired registry is taken verbatim; only +/// in its absence is a bare handle wrapped into a one-id registry +/// keyed under `"default"`. The bare handle is never merged +/// in, never used as a fallback for ids the registry doesn't +/// define. Pulled out as a pure function so the precedence +/// contract is unit-testable without spinning up a real +/// `Request` and async dispatcher. +fn synthesise_store_registries( + stores: Stores, +) -> ( + Option, + Option, + Option, +) { + let config_registry = stores.config_registry.or_else(|| { + stores + .config_store + .map(|handle| ConfigRegistry::single_id("default".to_owned(), handle)) + }); + let kv_registry = stores.kv_registry.or_else(|| { + stores + .kv + .map(|handle| KvRegistry::single_id("default".to_owned(), handle)) + }); + let secret_registry = stores.secret_registry.or_else(|| { + stores.secrets.map(|handle| { + SecretRegistry::single_id( + "default".to_owned(), + BoundSecretStore::new(handle, "default".to_owned()), + ) + }) + }); + (config_registry, kv_registry, secret_registry) +} + +fn build_kv_registry( + kv_meta: Option, + env: &EnvConfig, +) -> Result, FastlyError> { + let Some(meta) = kv_meta else { + return Ok(None); + }; + let mut by_id: BTreeMap = BTreeMap::new(); + for id in meta.ids { + let store_name = env.store_name("kv", id); + // KV is required: if `[stores.kv]` is declared, an id failing to open + // is a runtime error rather than a silent degradation. + let Some(handle) = resolve_kv_handle(&store_name, true)? else { + continue; + }; + by_id.insert((*id).to_owned(), handle); + } + let default_id = meta.default.to_owned(); + if !by_id.contains_key(&default_id) { + log::warn!( + "KV registry default id `{default_id}` could not be opened; dropping the KV registry" + ); + } + Ok(StoreRegistry::from_parts(by_id, default_id)) +} + +fn build_config_registry( + config_meta: Option, + env: &EnvConfig, +) -> Option { + let meta = config_meta?; + let mut by_id: BTreeMap = BTreeMap::new(); + for id in meta.ids { + let store_name = env.store_name("config", id); + match FastlyConfigStore::try_open(&store_name) { + Ok(store) => { + by_id.insert((*id).to_owned(), ConfigStoreHandle::new(Arc::new(store))); + } + Err(err) => warn_missing_store_once(&store_name, &err.to_string()), + } + } + let default_id = meta.default.to_owned(); + if !by_id.contains_key(&default_id) { + log::warn!( + "config registry default id `{default_id}` could not be opened; dropping the config registry" + ); + } + StoreRegistry::from_parts(by_id, default_id) +} + +fn build_secret_registry( + secret_meta: Option, + env: &EnvConfig, +) -> Option { + let meta = secret_meta?; + // Fastly is `Multi` for secrets. The provider trait is stateless — + // `FastlySecretStore::get_bytes(store_name, key)` opens the named Fastly + // Secret Store per call — so we share one provider handle across all + // bindings, then capture the per-id platform store name in the bound + // wrapper. `EDGEZERO__STORES__SECRETS____NAME` (default = the logical + // id) decides which Fastly store each id resolves to at runtime. + let handle = SecretHandle::new(Arc::new(FastlySecretStore)); + let mut by_id: BTreeMap = BTreeMap::new(); + for id in meta.ids { + let store_name = env.store_name("secrets", id); + by_id.insert( + (*id).to_owned(), + BoundSecretStore::new(handle.clone(), store_name), + ); + } + // Fastly's secret-store handle wrappers are infallible to construct; + // `from_parts` keeps the API symmetric with the KV / config builders. + StoreRegistry::from_parts(by_id, meta.default.to_owned()) +} +/// # Errors +/// Returns [`EdgeError::Internal`] if the Fastly request cannot be reconstituted into a core request (e.g., method or URI conversion failure). +#[inline] pub fn into_core_request(mut req: FastlyRequest) -> Result { let method = req.get_method().clone(); let uri = parse_uri(req.get_url_str())?; @@ -69,141 +458,50 @@ pub fn into_core_request(mut req: FastlyRequest) -> Result { Ok(request) } -pub(crate) fn dispatch_raw(app: &App, req: FastlyRequest) -> Result { - dispatch_with_kv(app, req, DEFAULT_KV_STORE_NAME, false) -} - -/// Low-level manual dispatch. -/// -/// This path does not resolve or inject config-store metadata from a manifest. -/// Prefer `run_app` or `dispatch_with_config` for normal config-store-aware -/// dispatch. Use `dispatch_with_config_handle` only when you already have a -/// prepared `ConfigStoreHandle`. -#[deprecated( - note = "dispatch() is the low-level manual path and does not inject config-store metadata; prefer run_app(), dispatch_with_config(), or dispatch_with_config_handle()" -)] -pub fn dispatch(app: &App, req: FastlyRequest) -> Result { - dispatch_raw(app, req) -} - -/// Dispatch a request with a prepared config-store handle injected into extensions. -/// -/// This is the advanced/manual path. Prefer `dispatch_with_config` when you -/// want the adapter to resolve the configured backend for you. -/// -/// The KV store named [`DEFAULT_KV_STORE_NAME`] is also resolved and injected -/// (non-required: unavailable stores are silently skipped). -pub fn dispatch_with_config_handle( - app: &App, - req: FastlyRequest, - config_store_handle: ConfigStoreHandle, -) -> Result { - let kv = resolve_kv_handle(DEFAULT_KV_STORE_NAME, false)?; - dispatch_with_handles( - app, - req, - Stores { - config_store: Some(config_store_handle), - kv, - ..Default::default() - }, - ) +fn map_edge_error(err: &EdgeError) -> FastlyError { + FastlyError::msg(err.to_string()) } -/// Dispatch a request with a Fastly Config Store injected into extensions. -/// -/// If the named store is not available, suppresses repeated warnings for -/// recently seen store names and dispatches without it. -/// -/// The KV store named [`DEFAULT_KV_STORE_NAME`] is also resolved and injected -/// (non-required: unavailable stores are silently skipped). -pub fn dispatch_with_config( - app: &App, - req: FastlyRequest, - store_name: &str, -) -> Result { - let config_store_handle = match FastlyConfigStore::try_open(store_name) { - Ok(store) => Some(ConfigStoreHandle::new(Arc::new(store))), +fn resolve_kv_handle( + kv_store_name: &str, + kv_required: bool, +) -> Result, FastlyError> { + match FastlyKvStore::open(kv_store_name) { + Ok(store) => Ok(Some(KvHandle::new(Arc::new(store)))), Err(err) => { - warn_missing_store_once(store_name, &err.to_string()); - None + if kv_required { + return Err(FastlyError::msg(format!( + "KV store '{kv_store_name}' is explicitly configured but could not be opened: {err}" + ))); + } + warn_missing_kv_store_once(kv_store_name, &err); + Ok(None) } - }; - let kv = resolve_kv_handle(DEFAULT_KV_STORE_NAME, false)?; - dispatch_with_handles( - app, - req, - Stores { - config_store: config_store_handle, - kv, - ..Default::default() - }, - ) + } } -/// Dispatch a Fastly request with a custom KV store name. -/// -/// `kv_required` should be `true` when `[stores.kv]` is explicitly present -/// in the manifest, causing the request to fail if the store is unavailable -/// rather than silently degrading. -pub fn dispatch_with_kv( - app: &App, - req: FastlyRequest, - kv_store_name: &str, - kv_required: bool, -) -> Result { - let kv = resolve_kv_handle(kv_store_name, kv_required)?; - dispatch_with_handles( - app, - req, - Stores { - kv, - ..Default::default() - }, - ) +fn resolve_secret_handle(secrets_required: bool) -> Option { + if !secrets_required { + return None; + } + Some(SecretHandle::new(Arc::new(FastlySecretStore))) } -pub(crate) fn dispatch_with_store_names( - app: &App, - req: FastlyRequest, - config_store_name: Option<&str>, - kv_store_name: &str, - kv_required: bool, - secrets_required: bool, -) -> Result { - let config_store_handle = match config_store_name { - Some(store_name) => match FastlyConfigStore::try_open(store_name) { - Ok(store) => Some(ConfigStoreHandle::new(Arc::new(store))), - Err(err) => { - warn_missing_store_once(store_name, &err.to_string()); - None - } - }, - None => None, - }; - let kv = resolve_kv_handle(kv_store_name, kv_required)?; - let secrets = resolve_secret_handle(secrets_required); - dispatch_with_handles( - app, - req, - Stores { - config_store: config_store_handle, - kv, - secrets, - }, - ) +fn warn_missing_kv_store_once(kv_store_name: &str, error: &impl Display) { + static WARNED_KV_STORES: OnceLock> = OnceLock::new(); + warn_missing_once(&WARNED_KV_STORES, "KV store", kv_store_name, error); } fn warn_missing_once( cache: &'static OnceLock>, item_type: &str, name: &str, - detail: &impl std::fmt::Display, + detail: &impl Display, ) { let set = cache.get_or_init(|| Mutex::new(RecentStringSet::default())); - let mut guard = set.lock().unwrap_or_else(|poisoned| poisoned.into_inner()); + let mut guard = set.lock().unwrap_or_else(PoisonError::into_inner); if guard.insert(name, WARNED_STORE_CACHE_LIMIT) { - log::warn!("{} '{}' not available: {}", item_type, name, detail); + log::warn!("{item_type} '{name}' not available: {detail}"); } } @@ -213,138 +511,118 @@ fn warn_missing_store_once(store_name: &str, detail: &str) { &WARNED_STORES, "configured Fastly config store", store_name, - &format!("{}; skipping config-store injection", detail), + &format!("{detail}; skipping config-store injection"), ); } -#[derive(Default)] -struct RecentStringSet { - keys: HashSet, - order: VecDeque, -} +#[cfg(test)] +mod synthesis_tests { + use super::*; + use edgezero_core::config_store::{ConfigStore, ConfigStoreError, ConfigStoreHandle}; + use edgezero_core::key_value_store::{KvStore, NoopKvStore}; + use edgezero_core::secret_store::{NoopSecretStore, SecretHandle}; + use std::collections::BTreeMap; + use std::sync::Arc; -impl RecentStringSet { - fn insert(&mut self, key: &str, limit: usize) -> bool { - let owned = key.to_string(); - if !self.keys.insert(owned.clone()) { - return false; - } - self.order.push_back(owned); - while limit > 0 && self.order.len() > limit { - if let Some(oldest) = self.order.pop_front() { - self.keys.remove(&oldest); - } + struct StubConfig; + #[async_trait::async_trait(?Send)] + impl ConfigStore for StubConfig { + async fn get(&self, _key: &str) -> Result, ConfigStoreError> { + Ok(None) } - true } -} -fn map_edge_error(err: EdgeError) -> FastlyError { - FastlyError::msg(err.to_string()) -} + fn kv_handle() -> KvHandle { + let store: Arc = Arc::new(NoopKvStore); + KvHandle::new(store) + } -fn warn_missing_kv_store_once(kv_store_name: &str, error: &impl std::fmt::Display) { - static WARNED_KV_STORES: OnceLock> = OnceLock::new(); - warn_missing_once(&WARNED_KV_STORES, "KV store", kv_store_name, error); -} + fn config_handle() -> ConfigStoreHandle { + ConfigStoreHandle::new(Arc::new(StubConfig)) + } -/// Dispatch a Fastly request with a secret store attached. -/// -/// For most applications, prefer [`crate::run_app`] which resolves all stores -/// from the manifest automatically. Use `dispatch_with_secrets` only when you -/// need direct control over the dispatch lifecycle without a manifest. -pub fn dispatch_with_secrets( - app: &App, - req: FastlyRequest, - secrets_required: bool, -) -> Result { - let secrets = resolve_secret_handle(secrets_required); - dispatch_with_handles( - app, - req, - Stores { - secrets, - ..Default::default() - }, - ) -} + fn secret_handle() -> SecretHandle { + SecretHandle::new(Arc::new(NoopSecretStore)) + } -/// Dispatch a Fastly request with both KV and secret stores attached. -/// -/// For most applications, prefer [`crate::run_app`] which resolves all stores -/// from the manifest automatically. Use `dispatch_with_kv_and_secrets` only -/// when you need direct control over the dispatch lifecycle without a manifest. -pub fn dispatch_with_kv_and_secrets( - app: &App, - req: FastlyRequest, - kv_store_name: &str, - kv_required: bool, - secrets_required: bool, -) -> Result { - let kv = resolve_kv_handle(kv_store_name, kv_required)?; - let secrets = resolve_secret_handle(secrets_required); - dispatch_with_handles( - app, - req, - Stores { - kv, - secrets, + #[test] + fn synthesis_wraps_bare_kv_handle_under_default_when_no_registry() { + let stores = Stores { + kv: Some(kv_handle()), ..Default::default() - }, - ) -} - -pub(crate) fn dispatch_with_handles( - app: &App, - req: FastlyRequest, - stores: Stores, -) -> Result { - let core_request = into_core_request(req).map_err(map_edge_error)?; - dispatch_core_request(app, core_request, stores) -} - -fn dispatch_core_request( - app: &App, - mut core_request: Request, - stores: Stores, -) -> Result { - if let Some(handle) = stores.config_store { - core_request.extensions_mut().insert(handle); - } - if let Some(handle) = stores.kv { - core_request.extensions_mut().insert(handle); + }; + let (config_out, kv_out, secret_out) = synthesise_store_registries(stores); + assert!( + config_out.is_none(), + "no config wiring -> no config registry" + ); + assert!( + secret_out.is_none(), + "no secret wiring -> no secret registry" + ); + let kv_reg = kv_out.expect("kv registry synthesised from bare handle"); + assert_eq!( + kv_reg.default_id(), + "default", + "synthesised id is `default`" + ); + assert!(kv_reg.named("default").is_some()); + assert!( + kv_reg.named("other").is_none(), + "synthesised registry only knows the `default` id" + ); } - if let Some(handle) = stores.secrets { - core_request.extensions_mut().insert(handle); + + #[test] + fn synthesis_registry_wins_over_bare_handle_when_both_wired() { + // Multi-id registry declaring only `sessions` paired with a + // bare handle that would otherwise synthesise to a + // `default`-keyed entry. Precedence rule: the bare handle + // is dropped entirely; the registry stands alone with no + // `default` id. + let mut by_id: BTreeMap = BTreeMap::new(); + by_id.insert("sessions".to_owned(), kv_handle()); + let registry = KvRegistry::new(by_id, "sessions".to_owned()); + let stores = Stores { + kv: Some(kv_handle()), + kv_registry: Some(registry), + ..Default::default() + }; + let (_, kv_out, _) = synthesise_store_registries(stores); + let kv_reg = kv_out.expect("registry survives synthesis"); + assert_eq!(kv_reg.default_id(), "sessions"); + assert!( + kv_reg.named("default").is_none(), + "bare handle's `default` synth NOT merged in" + ); } - let response = executor::block_on(app.router().oneshot(core_request)); - from_core_response(response).map_err(map_edge_error) -} -pub(crate) fn resolve_kv_handle( - kv_store_name: &str, - kv_required: bool, -) -> Result, FastlyError> { - match FastlyKvStore::open(kv_store_name) { - Ok(store) => Ok(Some(KvHandle::new(std::sync::Arc::new(store)))), - Err(e) => { - if kv_required { - return Err(FastlyError::msg(format!( - "KV store '{}' is explicitly configured but could not be opened: {}", - kv_store_name, e - ))); - } - warn_missing_kv_store_once(kv_store_name, &e); - Ok(None) - } + #[test] + fn synthesis_returns_none_for_each_kind_with_no_wiring() { + let (config, kv, secret) = synthesise_store_registries(Stores::default()); + assert!(config.is_none() && kv.is_none() && secret.is_none()); } -} -pub(crate) fn resolve_secret_handle(secrets_required: bool) -> Option { - if !secrets_required { - return None; + #[test] + fn synthesis_handles_config_and_secret_bare_handles_symmetrically() { + let stores = Stores { + config_store: Some(config_handle()), + secrets: Some(secret_handle()), + ..Default::default() + }; + let (config_out, _, secret_out) = synthesise_store_registries(stores); + let config_reg = config_out.expect("config wrapped"); + assert_eq!(config_reg.default_id(), "default"); + let secret_reg = secret_out.expect("secret wrapped"); + assert_eq!(secret_reg.default_id(), "default"); + // BoundSecretStore binds the synthesised secret to platform + // store name "default" -- if the underlying Fastly account + // has no Secret Store literally named "default", the + // require_str() call from a handler will fail with a clear + // store-name error rather than silent miss. + assert_eq!( + secret_reg.default().expect("default bound").store_name(), + "default" + ); } - Some(SecretHandle::new(std::sync::Arc::new( - crate::secret_store::FastlySecretStore, - ))) } diff --git a/crates/edgezero-adapter-fastly/src/response.rs b/crates/edgezero-adapter-fastly/src/response.rs index 617c501c..075b235a 100644 --- a/crates/edgezero-adapter-fastly/src/response.rs +++ b/crates/edgezero-adapter-fastly/src/response.rs @@ -2,9 +2,13 @@ use edgezero_core::body::Body; use edgezero_core::error::EdgeError; use edgezero_core::http::{Response, Uri}; use fastly::Response as FastlyResponse; -use futures_util::StreamExt; -use std::io::Write; +use futures::executor; +use futures_util::StreamExt as _; +use std::io::Write as _; +/// # Errors +/// Returns [`EdgeError::Internal`] if the response body cannot be streamed to the Fastly send-channel. +#[inline] pub fn from_core_response(response: Response) -> Result { let (parts, body) = response.into_parts(); let mut fastly_response = FastlyResponse::from_status(parts.status.as_u16()); @@ -13,24 +17,24 @@ pub fn from_core_response(response: Response) -> Result fastly_response.set_body(bytes.to_vec()), Body::Stream(mut stream) => { let mut fastly_body = fastly::Body::new(); - while let Some(chunk) = futures::executor::block_on(stream.next()) { - let chunk = chunk.map_err(EdgeError::internal)?; + while let Some(result) = executor::block_on(stream.next()) { + let chunk = result.map_err(EdgeError::internal)?; fastly_body.write_all(&chunk).map_err(EdgeError::internal)?; } fastly_response.set_body(fastly_body); } } - for (name, value) in parts.headers.iter() { + for (name, value) in &parts.headers { fastly_response.set_header(name.as_str(), value.as_bytes()); } Ok(fastly_response) } -pub fn parse_uri(uri: &str) -> Result { +pub(crate) fn parse_uri(uri: &str) -> Result { uri.parse::() - .map_err(|err| EdgeError::bad_request(format!("invalid request URI: {}", err))) + .map_err(|err| EdgeError::bad_request(format!("invalid request URI: {err}"))) } #[cfg(test)] diff --git a/crates/edgezero-adapter-fastly/src/secret_store.rs b/crates/edgezero-adapter-fastly/src/secret_store.rs index 6458aa05..e83b2b33 100644 --- a/crates/edgezero-adapter-fastly/src/secret_store.rs +++ b/crates/edgezero-adapter-fastly/src/secret_store.rs @@ -1,7 +1,7 @@ //! Fastly secret store adapter. //! //! Implements `edgezero_core::secret_store::SecretStore` via -//! `FastlySecretStore`, which opens a named Fastly SecretStore on +//! `FastlySecretStore`, which opens a named Fastly `SecretStore` on //! each lookup. #[cfg(feature = "fastly")] @@ -9,48 +9,53 @@ use async_trait::async_trait; #[cfg(feature = "fastly")] use bytes::Bytes; #[cfg(feature = "fastly")] -use edgezero_core::secret_store::SecretError; +use edgezero_core::secret_store::{SecretError, SecretStore}; +#[cfg(feature = "fastly")] +use fastly::secret_store::SecretStore as FastlyNativeSecretStore; -/// Internal helper that opens a single named Fastly SecretStore. +/// Internal helper that opens a single named Fastly `SecretStore`. #[cfg(feature = "fastly")] pub struct FastlyNamedStore { - store: fastly::secret_store::SecretStore, + store: FastlyNativeSecretStore, } #[cfg(feature = "fastly")] impl FastlyNamedStore { - /// Open a Fastly SecretStore by name. + pub(crate) fn get_bytes_sync(&self, key: &str) -> Result, SecretError> { + let lookup = self + .store + .try_get(key) + .map_err(|err| SecretError::Internal(anyhow::anyhow!("secret lookup failed: {err}")))?; + + match lookup { + Some(secret) => secret.try_plaintext().map(Some).map_err(|err| { + SecretError::Internal(anyhow::anyhow!("secret decryption failed: {err}")) + }), + None => Ok(None), + } + } + + /// Open a Fastly `SecretStore` by name. /// /// Returns `SecretError::Internal` if the store does not exist or cannot - /// be opened. Unlike `KVStore::open`, the Fastly SecretStore API returns + /// be opened. Unlike `KVStore::open`, the Fastly `SecretStore` API returns /// `Result` (not `Result, _>`), so there /// is no `ok_or` unwrap here. + /// + /// # Errors + /// Returns [`SecretError::Internal`] if the named secret store cannot be opened. + #[inline] pub fn open(name: &str) -> Result { - let store = fastly::secret_store::SecretStore::open(name).map_err(|e| { + let store = FastlyNativeSecretStore::open(name).map_err(|err| { SecretError::Internal(anyhow::anyhow!( - "failed to open secret store '{}': {e}", - name + "failed to open secret store '{name}': {err}" )) })?; Ok(Self { store }) } - - pub(crate) fn get_bytes_sync(&self, key: &str) -> Result, SecretError> { - let secret = self - .store - .try_get(key) - .map_err(|e| SecretError::Internal(anyhow::anyhow!("secret lookup failed: {e}")))?; - - match secret { - Some(secret) => secret.try_plaintext().map(Some).map_err(|e| { - SecretError::Internal(anyhow::anyhow!("secret decryption failed: {e}")) - }), - None => Ok(None), - } - } } -/// Multi-store provider backed by Fastly's SecretStore API. +/// Multi-store provider backed by Fastly's `SecretStore` API. /// /// Opens the named store per call — `FastlyNamedStore::open` is cheap /// (no network; just a handle) so there is no caching. @@ -59,12 +64,9 @@ pub struct FastlySecretStore; #[cfg(feature = "fastly")] #[async_trait(?Send)] -impl edgezero_core::secret_store::SecretStore for FastlySecretStore { - async fn get_bytes( - &self, - store_name: &str, - key: &str, - ) -> Result, edgezero_core::secret_store::SecretError> { +impl SecretStore for FastlySecretStore { + #[inline] + async fn get_bytes(&self, store_name: &str, key: &str) -> Result, SecretError> { let store = FastlyNamedStore::open(store_name)?; store.get_bytes_sync(key) } diff --git a/crates/edgezero-adapter-fastly/src/templates/Cargo.toml.hbs b/crates/edgezero-adapter-fastly/src/templates/Cargo.toml.hbs index 238463d8..b8cf4b84 100644 --- a/crates/edgezero-adapter-fastly/src/templates/Cargo.toml.hbs +++ b/crates/edgezero-adapter-fastly/src/templates/Cargo.toml.hbs @@ -4,6 +4,9 @@ version = "0.1.0" edition = "2021" publish = false +[lints] +workspace = true + [[bin]] name = "{{proj_fastly}}" path = "src/main.rs" diff --git a/crates/edgezero-adapter-fastly/src/templates/src/main.rs.hbs b/crates/edgezero-adapter-fastly/src/templates/src/main.rs.hbs index 0972d317..42d97d16 100644 --- a/crates/edgezero-adapter-fastly/src/templates/src/main.rs.hbs +++ b/crates/edgezero-adapter-fastly/src/templates/src/main.rs.hbs @@ -1,16 +1,22 @@ -#![cfg_attr(not(target_arch = "wasm32"), allow(dead_code))] +#![cfg_attr( + not(target_arch = "wasm32"), + allow(dead_code, reason = "Fastly entrypoint is wasm32-only") +)] #[cfg(target_arch = "wasm32")] use fastly::{Error, Request, Response}; -#[cfg(target_arch = "wasm32")] -use {{proj_core_mod}}::App; + #[cfg(target_arch = "wasm32")] #[fastly::main] pub fn main(req: Request) -> Result { - edgezero_adapter_fastly::run_app::(include_str!("../../../edgezero.toml"), req) + edgezero_adapter_fastly::run_app::<{{proj_core_mod}}::App>(req) } #[cfg(not(target_arch = "wasm32"))] +#[expect( + clippy::print_stderr, + reason = "host stub; the real binary only runs on wasm32-wasip1" +)] fn main() { eprintln!("{{proj_fastly}}: target wasm32-wasip1 to run on Fastly."); } diff --git a/crates/edgezero-adapter-fastly/tests/contract.rs b/crates/edgezero-adapter-fastly/tests/contract.rs index edb24987..483ac4ff 100644 --- a/crates/edgezero-adapter-fastly/tests/contract.rs +++ b/crates/edgezero-adapter-fastly/tests/contract.rs @@ -1,194 +1,216 @@ #![cfg(all(feature = "fastly", target_arch = "wasm32"))] -// Keep coverage for the deprecated low-level dispatch path while it remains public. -#![allow(deprecated)] - -use bytes::Bytes; -use edgezero_adapter_fastly::{ - dispatch, dispatch_with_config_handle, from_core_response, into_core_request, - FastlyRequestContext, -}; -use edgezero_core::app::App; -use edgezero_core::body::Body; -use edgezero_core::config_store::{ConfigStore, ConfigStoreError, ConfigStoreHandle}; -use edgezero_core::context::RequestContext; -use edgezero_core::error::EdgeError; -use edgezero_core::http::{response_builder, Method, Response, StatusCode}; -use edgezero_core::router::RouterService; -use fastly::http::{Method as FastlyMethod, StatusCode as FastlyStatus}; -use fastly::Request as FastlyRequest; -use futures::stream; -use std::sync::Arc; - -struct FixedConfigStore(&'static str); - -impl ConfigStore for FixedConfigStore { - fn get(&self, _key: &str) -> Result, ConfigStoreError> { - Ok(Some(self.0.to_string())) - } -} -fn build_test_app() -> App { - async fn capture_uri(ctx: RequestContext) -> Result { - let body = Body::text(ctx.request().uri().to_string()); - let response = response_builder() - .status(StatusCode::OK) - .body(body) - .expect("response"); - Ok(response) - } +// Compile-time check: FastlySecretStore implements SecretStore. +mod secret_store_compile_check { + use edgezero_adapter_fastly::secret_store::FastlySecretStore; + use edgezero_core::secret_store::SecretStore; - async fn mirror_body(ctx: RequestContext) -> Result { - let bytes = ctx.request().body().as_bytes().to_vec(); - let response = response_builder() - .status(StatusCode::OK) - .body(Body::from(bytes)) - .expect("response"); - Ok(response) - } + fn assert_provider_impl() {} - async fn stream_response(_ctx: RequestContext) -> Result { - let chunks = stream::iter(vec![ - Bytes::from_static(b"chunk-1"), - Bytes::from_static(b"chunk-2"), - ]); + // Anonymous const whose initializer is a never-called fn pointer; the + // type bound is checked at type-check time. + const _: fn() = assert_provider_impl::; +} - let response = response_builder() - .status(StatusCode::OK) - .body(Body::stream(chunks)) - .expect("response"); - Ok(response) +#[cfg(test)] +mod tests { + use bytes::Bytes; + use edgezero_adapter_fastly::context::FastlyRequestContext; + use edgezero_adapter_fastly::request::{into_core_request, FastlyService}; + use edgezero_adapter_fastly::response::from_core_response; + use edgezero_core::app::App; + use edgezero_core::body::Body; + use edgezero_core::config_store::{ConfigStore, ConfigStoreError, ConfigStoreHandle}; + use edgezero_core::context::RequestContext; + use edgezero_core::error::EdgeError; + use edgezero_core::http::{response_builder, Method, Response, StatusCode}; + use edgezero_core::router::RouterService; + use fastly::http::{Method as FastlyMethod, StatusCode as FastlyStatus}; + use fastly::Request as FastlyRequest; + use futures::stream; + use std::sync::Arc; + + struct FixedConfigStore(&'static str); + + #[async_trait::async_trait(?Send)] + impl ConfigStore for FixedConfigStore { + async fn get(&self, _key: &str) -> Result, ConfigStoreError> { + Ok(Some(self.0.to_owned())) + } } - async fn config_value(ctx: RequestContext) -> Result { - let value = ctx - .config_store() - .and_then(|store| store.get("greeting").ok().flatten()) - .unwrap_or_else(|| "missing".to_string()); - let response = response_builder() - .status(StatusCode::OK) - .body(Body::text(value)) - .expect("response"); - Ok(response) + fn build_test_app() -> App { + async fn capture_uri(ctx: RequestContext) -> Result { + let body = Body::text(ctx.request().uri().to_string()); + let response = response_builder() + .status(StatusCode::OK) + .body(body) + .expect("response"); + Ok(response) + } + + async fn mirror_body(ctx: RequestContext) -> Result { + let bytes = ctx.request().body().as_bytes().expect("buffered").to_vec(); + let response = response_builder() + .status(StatusCode::OK) + .body(Body::from(bytes)) + .expect("response"); + Ok(response) + } + + async fn stream_response(_ctx: RequestContext) -> Result { + let chunks = stream::iter(vec![ + Bytes::from_static(b"chunk-1"), + Bytes::from_static(b"chunk-2"), + ]); + + let response = response_builder() + .status(StatusCode::OK) + .body(Body::stream(chunks)) + .expect("response"); + Ok(response) + } + + async fn config_value(ctx: RequestContext) -> Result { + // Hard-cutoff: legacy `ctx.config_handle()` is + // gone. The dispatch boundary now synthesises a one-id + // `ConfigRegistry` from the wired `ConfigStoreHandle`. + let value = match ctx.config_store_default() { + Some(store) => store + .get("greeting") + .await + .ok() + .flatten() + .unwrap_or_else(|| "missing".to_owned()), + None => "missing".to_owned(), + }; + let response = response_builder() + .status(StatusCode::OK) + .body(Body::text(value)) + .expect("response"); + Ok(response) + } + + let router = RouterService::builder() + .get("/uri", capture_uri) + .post("/mirror", mirror_body) + .get("/stream", stream_response) + .get("/config", config_value) + .build(); + + App::new(router) } - let router = RouterService::builder() - .get("/uri", capture_uri) - .post("/mirror", mirror_body) - .get("/stream", stream_response) - .get("/config", config_value) - .build(); - - App::new(router) -} - -fn fastly_request(method: FastlyMethod, path: &str, body: Option<&[u8]>) -> FastlyRequest { - // Viceroy validates Fastly request URLs at construction time, so the - // contract tests must use absolute URLs instead of path-only strings. - let mut req = FastlyRequest::new(method, format!("http://example.com{path}")); - req.set_header("host", "example.com"); - req.set_header("x-edgezero-test", "1"); - if let Some(bytes) = body { - req.set_body(bytes.to_vec()); + fn fastly_request(method: FastlyMethod, path: &str, body: Option<&[u8]>) -> FastlyRequest { + // Viceroy validates Fastly request URLs at construction time, so the + // contract tests must use absolute URLs instead of path-only strings. + let mut req = FastlyRequest::new(method, format!("http://example.com{path}")); + req.set_header("host", "example.com"); + req.set_header("x-edgezero-test", "1"); + if let Some(bytes) = body { + req.set_body(bytes.to_vec()); + } + req } - req -} -#[test] -fn into_core_request_preserves_method_uri_headers_body_and_context() { - let req = fastly_request(FastlyMethod::POST, "/mirror?foo=bar", Some(b"payload")); - let expected_ip = req.get_client_ip_addr(); + #[test] + fn into_core_request_preserves_method_uri_headers_body_and_context() { + let req = fastly_request(FastlyMethod::POST, "/mirror?foo=bar", Some(b"payload")); + let expected_ip = req.get_client_ip_addr(); - let core_request = into_core_request(req).expect("core request"); + let core_request = into_core_request(req).expect("core request"); - assert_eq!(core_request.method(), &Method::POST); - assert_eq!(core_request.uri().path(), "/mirror"); - assert_eq!(core_request.uri().query(), Some("foo=bar")); + assert_eq!(core_request.method(), &Method::POST); + assert_eq!(core_request.uri().path(), "/mirror"); + assert_eq!(core_request.uri().query(), Some("foo=bar")); - let headers = core_request.headers(); - assert_eq!( - headers - .get("x-edgezero-test") - .and_then(|value| value.to_str().ok()), - Some("1") - ); + let headers = core_request.headers(); + assert_eq!( + headers + .get("x-edgezero-test") + .and_then(|value| value.to_str().ok()), + Some("1") + ); - assert_eq!(core_request.body().as_bytes(), b"payload"); + assert_eq!( + core_request.body().as_bytes().expect("buffered"), + b"payload" + ); - let context = FastlyRequestContext::get(&core_request).expect("context"); - assert_eq!(context.client_ip, expected_ip); -} + let context = FastlyRequestContext::get(&core_request).expect("context"); + assert_eq!(context.client_ip, expected_ip); + } -#[test] -fn from_core_response_translates_status_headers_and_streaming_body() { - let response = response_builder() - .status(StatusCode::CREATED) - .header("x-edgezero-res", "1") - .body(Body::stream(stream::iter(vec![ - Bytes::from_static(b"hello"), - Bytes::from_static(b" "), - Bytes::from_static(b"world"), - ]))) - .expect("response"); - - let mut fastly_response = from_core_response(response).expect("fastly response"); - - assert_eq!(fastly_response.get_status(), FastlyStatus::CREATED); - assert!(fastly_response.get_header("x-edgezero-res").is_some()); - assert_eq!(fastly_response.take_body_bytes(), b"hello world"); -} + #[test] + fn from_core_response_translates_status_headers_and_streaming_body() { + let response = response_builder() + .status(StatusCode::CREATED) + .header("x-edgezero-res", "1") + .body(Body::stream(stream::iter(vec![ + Bytes::from_static(b"hello"), + Bytes::from_static(b" "), + Bytes::from_static(b"world"), + ]))) + .expect("response"); -#[test] -fn dispatch_runs_router_and_returns_response() { - let app = build_test_app(); - let req = fastly_request(FastlyMethod::GET, "/uri", None); + let mut fastly_response = from_core_response(response).expect("fastly response"); - let mut response = dispatch(&app, req).expect("fastly response"); + assert_eq!(fastly_response.get_status(), FastlyStatus::CREATED); + assert!(fastly_response.get_header("x-edgezero-res").is_some()); + assert_eq!(fastly_response.take_body_bytes(), b"hello world"); + } - assert_eq!(response.get_status(), FastlyStatus::OK); - assert_eq!(response.take_body_bytes(), b"http://example.com/uri"); -} + #[test] + fn dispatch_runs_router_and_returns_response() { + let app = build_test_app(); + let req = fastly_request(FastlyMethod::GET, "/uri", None); -#[test] -fn dispatch_streaming_route_preserves_chunks() { - let app = build_test_app(); - let req = fastly_request(FastlyMethod::GET, "/stream", None); + let mut response = FastlyService::new(&app) + .dispatch(req) + .expect("fastly response"); - let mut response = dispatch(&app, req).expect("fastly response"); + assert_eq!(response.get_status(), FastlyStatus::OK); + assert_eq!(response.take_body_bytes(), b"http://example.com/uri"); + } - assert_eq!(response.get_status(), FastlyStatus::OK); - assert_eq!(response.take_body_bytes(), b"chunk-1chunk-2"); -} + #[test] + fn dispatch_streaming_route_preserves_chunks() { + let app = build_test_app(); + let req = fastly_request(FastlyMethod::GET, "/stream", None); -#[test] -fn dispatch_passes_request_body_to_handlers() { - let app = build_test_app(); - let req = fastly_request(FastlyMethod::POST, "/mirror", Some(b"echo")); + let mut response = FastlyService::new(&app) + .dispatch(req) + .expect("fastly response"); - let mut response = dispatch(&app, req).expect("fastly response"); + assert_eq!(response.get_status(), FastlyStatus::OK); + assert_eq!(response.take_body_bytes(), b"chunk-1chunk-2"); + } - assert_eq!(response.get_status(), FastlyStatus::OK); - assert_eq!(response.take_body_bytes(), b"echo"); -} + #[test] + fn dispatch_passes_request_body_to_handlers() { + let app = build_test_app(); + let req = fastly_request(FastlyMethod::POST, "/mirror", Some(b"echo")); -#[test] -fn dispatch_with_config_handle_injects_handle() { - let app = build_test_app(); - let req = fastly_request(FastlyMethod::GET, "/config", None); - let handle = ConfigStoreHandle::new(Arc::new(FixedConfigStore("hello from fastly test"))); + let mut response = FastlyService::new(&app) + .dispatch(req) + .expect("fastly response"); - let mut response = dispatch_with_config_handle(&app, req, handle).expect("fastly response"); + assert_eq!(response.get_status(), FastlyStatus::OK); + assert_eq!(response.take_body_bytes(), b"echo"); + } - assert_eq!(response.get_status(), FastlyStatus::OK); - assert_eq!(response.take_body_bytes(), b"hello from fastly test"); -} + #[test] + fn service_with_config_handle_injects_handle() { + let app = build_test_app(); + let req = fastly_request(FastlyMethod::GET, "/config", None); + let handle = ConfigStoreHandle::new(Arc::new(FixedConfigStore("hello from fastly test"))); -#[cfg(all(feature = "fastly", target_arch = "wasm32"))] -mod secret_store_compile_check { - use edgezero_adapter_fastly::FastlySecretStore; - use edgezero_core::secret_store::SecretStore; + let mut response = FastlyService::new(&app) + .with_config_handle(handle) + .dispatch(req) + .expect("fastly response"); - fn _assert_provider_impl() {} - fn _check() { - _assert_provider_impl::(); + assert_eq!(response.get_status(), FastlyStatus::OK); + assert_eq!(response.take_body_bytes(), b"hello from fastly test"); } } diff --git a/crates/edgezero-adapter-spin/.cargo/config.toml b/crates/edgezero-adapter-spin/.cargo/config.toml new file mode 100644 index 00000000..788dbb50 --- /dev/null +++ b/crates/edgezero-adapter-spin/.cargo/config.toml @@ -0,0 +1,9 @@ +[build] +target = "wasm32-wasip2" + +# Wasmtime runs the spin contract tests (no Fastly host imports needed). +# Only applies when cargo is invoked from inside this package directory. +# CI overrides via `CARGO_TARGET_WASM32_WASIP2_RUNNER` env var in +# `.github/workflows/test.yml`. +[target.'cfg(target_arch = "wasm32")'] +runner = "wasmtime run" diff --git a/crates/edgezero-adapter-spin/Cargo.toml b/crates/edgezero-adapter-spin/Cargo.toml index 090daad6..875a2d39 100644 --- a/crates/edgezero-adapter-spin/Cargo.toml +++ b/crates/edgezero-adapter-spin/Cargo.toml @@ -5,10 +5,13 @@ version = { workspace = true } authors = { workspace = true } license = { workspace = true } +[lints] +workspace = true + [features] default = [] spin = ["dep:spin-sdk"] -cli = ["dep:edgezero-adapter", "edgezero-adapter/cli", "dep:ctor", "dep:walkdir"] +cli = ["dep:edgezero-adapter", "edgezero-adapter/cli", "dep:ctor", "dep:rusqlite", "dep:toml", "dep:toml_edit", "dep:walkdir"] [dependencies] edgezero-core = { path = "../edgezero-core" } @@ -21,9 +24,24 @@ flate2 = { workspace = true } futures = { workspace = true } futures-util = { workspace = true } log = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } spin-sdk = { workspace = true, optional = true } +subtle = { workspace = true } +thiserror = { workspace = true } ctor = { workspace = true, optional = true } +toml = { workspace = true, optional = true } +toml_edit = { workspace = true, optional = true } walkdir = { workspace = true, optional = true } +# rusqlite is CLI-only and host-only — `bundled` ships SQLite source +# (no host libsqlite3 install needed), and the `[target.…]` gate keeps +# it out of the wasm artifact entirely. The wasm32 builds never see +# rusqlite even when `features = ["spin", "cli"]` is on host. +[target.'cfg(not(target_arch = "wasm32"))'.dependencies] +rusqlite = { workspace = true, optional = true } + [dev-dependencies] +edgezero-core = { path = "../edgezero-core", features = ["test-utils"] } +http-body-util = { workspace = true } tempfile = { workspace = true } diff --git a/crates/edgezero-adapter-spin/src/cli.rs b/crates/edgezero-adapter-spin/src/cli.rs index 1e2cbdd7..b2724254 100644 --- a/crates/edgezero-adapter-spin/src/cli.rs +++ b/crates/edgezero-adapter-spin/src/cli.rs @@ -1,139 +1,70 @@ +#![expect( + clippy::self_named_module_files, + reason = "Workspace lint policy denies BOTH `self_named_module_files` (wants `cli/mod.rs`) and `mod_module_files` (wants `cli.rs`) -- they contradict, so any file with submodules must opt out of one. The repo convention is the self-named form (`cli.rs` with submodules under `cli/`); allow accordingly." +)] +#![expect( + clippy::arbitrary_source_item_ordering, + reason = "submodule declarations sit between the `use` block and the rest of the file's items by Rust convention; the strict-ordering lint disagrees but no human convention puts `mod` blocks AFTER trait impls" +)] + +use std::collections::HashSet; +use std::env; use std::fs; use std::path::{Path, PathBuf}; use std::process::Command; use ctor::ctor; use edgezero_adapter::cli_support::{ - find_manifest_upwards, find_workspace_root, path_distance, read_package_name, + find_manifest_upwards, find_workspace_root, path_distance, read_package_name, run_native_cli, +}; +use edgezero_adapter::registry::{ + register_adapter, Adapter, AdapterAction, AdapterPushContext, ProvisionStores, ResolvedStoreId, }; use edgezero_adapter::scaffold::{ register_adapter_blueprint, AdapterBlueprint, AdapterFileSpec, CommandTemplates, DependencySpec, LoggingDefaults, ManifestSpec, ReadmeInfo, TemplateRegistration, }; -use edgezero_adapter::{register_adapter, Adapter, AdapterAction}; use walkdir::WalkDir; -const TARGET_TRIPLE: &str = "wasm32-wasip1"; - -pub fn build(extra_args: &[String]) -> Result { - let manifest = find_spin_manifest( - std::env::current_dir() - .map_err(|e| e.to_string())? - .as_path(), - )?; - let manifest_dir = manifest - .parent() - .ok_or_else(|| "spin manifest has no parent directory".to_string())?; - let cargo_manifest = manifest_dir.join("Cargo.toml"); - let crate_name = read_package_name(&cargo_manifest)?; +mod push_cloud; +mod push_sqlite; +mod runtime_config; - let status = Command::new("cargo") - .args([ - "build", - "--release", - "--target", - TARGET_TRIPLE, - "--manifest-path", - cargo_manifest - .to_str() - .ok_or("invalid Cargo manifest path")?, - ]) - .args(extra_args) - .status() - .map_err(|e| format!("failed to run cargo build: {e}"))?; - if !status.success() { - return Err(format!("cargo build failed with status {status}")); - } - - let workspace_root = find_workspace_root(manifest_dir); - let artifact = locate_artifact(&workspace_root, manifest_dir, &crate_name)?; - let pkg_dir = workspace_root.join("pkg"); - fs::create_dir_all(&pkg_dir) - .map_err(|e| format!("failed to create {}: {e}", pkg_dir.display()))?; - let dest = pkg_dir.join(format!("{}.wasm", crate_name.replace('-', "_"))); - fs::copy(&artifact, &dest) - .map_err(|e| format!("failed to copy artifact to {}: {e}", dest.display()))?; - - Ok(dest) -} - -pub fn deploy(extra_args: &[String]) -> Result<(), String> { - let manifest = find_spin_manifest( - std::env::current_dir() - .map_err(|e| e.to_string())? - .as_path(), - )?; - let manifest_dir = manifest - .parent() - .ok_or_else(|| "spin manifest has no parent directory".to_string())?; - - let status = Command::new("spin") - .args(["deploy"]) - .args(extra_args) - .current_dir(manifest_dir) - .status() - .map_err(|e| format!("failed to run spin CLI: {e}"))?; - if !status.success() { - return Err(format!("spin deploy failed with status {status}")); - } - - Ok(()) -} - -pub fn serve(extra_args: &[String]) -> Result<(), String> { - let manifest = find_spin_manifest( - std::env::current_dir() - .map_err(|e| e.to_string())? - .as_path(), - )?; - let manifest_dir = manifest - .parent() - .ok_or_else(|| "spin manifest has no parent directory".to_string())?; - - let status = Command::new("spin") - .args(["up"]) - .args(extra_args) - .current_dir(manifest_dir) - .status() - .map_err(|e| format!("failed to run spin CLI: {e}"))?; - if !status.success() { - return Err(format!("spin up failed with status {status}")); - } - - Ok(()) -} - -struct SpinCliAdapter; +static SPIN_ADAPTER: SpinCliAdapter = SpinCliAdapter; -static SPIN_TEMPLATE_REGISTRATIONS: &[TemplateRegistration] = &[ - TemplateRegistration { - name: "spin_Cargo_toml", - contents: include_str!("templates/Cargo.toml.hbs"), - }, - TemplateRegistration { - name: "spin_src_lib_rs", - contents: include_str!("templates/src/lib.rs.hbs"), - }, - TemplateRegistration { - name: "spin_spin_toml", - contents: include_str!("templates/spin.toml.hbs"), +static SPIN_BLUEPRINT: AdapterBlueprint = AdapterBlueprint { + id: "spin", + display_name: "Spin (Fermyon)", + crate_suffix: "adapter-spin", + dependency_crate: "edgezero-adapter-spin", + dependency_repo_path: "crates/edgezero-adapter-spin", + template_registrations: SPIN_TEMPLATE_REGISTRATIONS, + files: SPIN_FILE_SPECS, + extra_dirs: &["src"], + dependencies: SPIN_DEPENDENCIES, + manifest: ManifestSpec { + manifest_filename: "spin.toml", + build_target: "wasm32-wasip2", + build_profile: "release", + build_features: &["spin"], }, -]; - -static SPIN_FILE_SPECS: &[AdapterFileSpec] = &[ - AdapterFileSpec { - template: "spin_Cargo_toml", - output: "Cargo.toml", + commands: CommandTemplates { + build: "cargo build --target wasm32-wasip2 --release -p {crate}", + deploy: "spin deploy --from {crate_dir}", + serve: "spin up --from {crate_dir} --runtime-config-file {crate_dir}/runtime-config.toml", }, - AdapterFileSpec { - template: "spin_src_lib_rs", - output: "src/lib.rs", + logging: LoggingDefaults { + endpoint: None, + level: "info", + echo_stdout: None, }, - AdapterFileSpec { - template: "spin_spin_toml", - output: "spin.toml", + readme: ReadmeInfo { + description: "{display} entrypoint.", + dev_heading: "{display} (local)", + dev_steps: &["`edgezero serve --adapter spin`"], }, -]; + run_module: "edgezero_adapter_spin", +}; static SPIN_DEPENDENCIES: &[DependencySpec] = &[ DependencySpec { @@ -158,68 +89,819 @@ static SPIN_DEPENDENCIES: &[DependencySpec] = &[ }, ]; -static SPIN_BLUEPRINT: AdapterBlueprint = AdapterBlueprint { - id: "spin", - display_name: "Spin (Fermyon)", - crate_suffix: "adapter-spin", - dependency_crate: "edgezero-adapter-spin", - dependency_repo_path: "crates/edgezero-adapter-spin", - template_registrations: SPIN_TEMPLATE_REGISTRATIONS, - files: SPIN_FILE_SPECS, - extra_dirs: &["src"], - dependencies: SPIN_DEPENDENCIES, - manifest: ManifestSpec { - manifest_filename: "spin.toml", - build_target: "wasm32-wasip1", - build_profile: "release", - build_features: &["spin"], +static SPIN_FILE_SPECS: &[AdapterFileSpec] = &[ + AdapterFileSpec { + template: "spin_Cargo_toml", + output: "Cargo.toml", }, - commands: CommandTemplates { - build: "cargo build --target wasm32-wasip1 --release -p {crate}", - deploy: "spin deploy --from {crate_dir}", - serve: "spin up --from {crate_dir}", + AdapterFileSpec { + template: "spin_runtime_config_toml", + output: "runtime-config.toml", }, - logging: LoggingDefaults { - endpoint: None, - level: "info", - echo_stdout: None, + AdapterFileSpec { + template: "spin_src_lib_rs", + output: "src/lib.rs", }, - readme: ReadmeInfo { - description: "{display} entrypoint.", - dev_heading: "{display} (local)", - dev_steps: &["`edgezero-cli serve --adapter spin`"], + AdapterFileSpec { + template: "spin_spin_toml", + output: "spin.toml", }, - run_module: "edgezero_adapter_spin", -}; +]; -static SPIN_ADAPTER: SpinCliAdapter = SpinCliAdapter; +static SPIN_TEMPLATE_REGISTRATIONS: &[TemplateRegistration] = &[ + TemplateRegistration { + name: "spin_Cargo_toml", + contents: include_str!("templates/Cargo.toml.hbs"), + }, + TemplateRegistration { + name: "spin_runtime_config_toml", + contents: include_str!("templates/runtime-config.toml.hbs"), + }, + TemplateRegistration { + name: "spin_src_lib_rs", + contents: include_str!("templates/src/lib.rs.hbs"), + }, + TemplateRegistration { + name: "spin_spin_toml", + contents: include_str!("templates/spin.toml.hbs"), + }, +]; + +const TARGET_TRIPLE: &str = "wasm32-wasip2"; +const SPIN_INSTALL_HINT: &str = "install the Spin CLI (https://spinframework.dev/) and try again"; + +struct SpinCliAdapter; + +#[expect( + clippy::missing_trait_methods, + reason = "Stage 6: KV-backed config dropped Spin's `^[a-z][a-z0-9_]*$` key rule and the config-vs-secret collision check, so `validate_app_config_keys` falls back to the trait default `Ok(())`. `validate_typed_secrets` IS overridden below (secret-value canonicalisation + within-secrets uniqueness still apply). `validate_adapter_manifest` IS overridden below (Spin's multi-component disambiguation)." +)] impl Adapter for SpinCliAdapter { + fn execute(&self, action: AdapterAction, args: &[String]) -> Result<(), String> { + match action { + // `spin cloud {login|logout|info}` is the native sign-in + // surface for Fermyon Cloud. EdgeZero stores no + // credentials — this is a thin shell-out. + AdapterAction::AuthLogin => { + run_native_cli("spin", &["cloud", "login"], SPIN_INSTALL_HINT) + } + AdapterAction::AuthLogout => { + run_native_cli("spin", &["cloud", "logout"], SPIN_INSTALL_HINT) + } + AdapterAction::AuthStatus => { + run_native_cli("spin", &["cloud", "info"], SPIN_INSTALL_HINT) + } + AdapterAction::Build => { + let artifact = build(args)?; + log::info!("[edgezero] Spin build complete -> {}", artifact.display()); + Ok(()) + } + AdapterAction::Deploy => deploy(args), + AdapterAction::Serve => serve(args), + other => Err(format!("spin adapter does not support {other:?}")), + } + } + + fn merged_id_kinds(&self) -> &'static [&'static str] { + // Both KV and Config back to `spin_sdk::key_value::Store` via + // the same `provision` path; declaring the same logical id + // under both kinds resolves to one underlying store with + // silent write-collisions. CLI validate rejects. + &["kv", "config"] + } + fn name(&self) -> &'static str { "spin" } - fn execute(&self, action: AdapterAction, args: &[String]) -> Result<(), String> { - match action { - AdapterAction::Build => { - let artifact = build(args)?; - println!("[edgezero] Spin build complete -> {}", artifact.display()); - Ok(()) - } - AdapterAction::Deploy => deploy(args), - AdapterAction::Serve => serve(args), - } - } -} + fn provision( + &self, + manifest_root: &Path, + adapter_manifest_path: Option<&str>, + component_selector: Option<&str>, + stores: &ProvisionStores<'_>, + dry_run: bool, + ) -> Result, String> { + //: spin provision is pure spin.toml editing — no + // shell-out (Spin KV stores are provisioned by the Spin + // runtime / Fermyon at deploy). For each declared KV id + // AND each declared CONFIG id (KV-backed since Stage 5 + // of the spin-kv-config plan), append the env-resolved + // platform label to the component's `key_value_stores` + // array. Secret variables are manually declared by the + // developer in spin.toml -- secrets stay on Spin + // variables for the platform's `secret = true` flagging. + let Some(rel) = adapter_manifest_path else { + return Err( + "[adapters.spin.adapter].manifest must point at spin.toml for provision".to_owned(), + ); + }; + let spin_path = manifest_root.join(rel); + + let mut out = Vec::new(); + // Resolve the component once if either KV or config has + // anything to provision. + let needs_component = !stores.kv.is_empty() || !stores.config.is_empty(); + if needs_component { + let component_id = resolve_spin_component(&spin_path, component_selector)?; + for (kind, store) in stores + .kv + .iter() + .map(|store| ("KV", store)) + .chain(stores.config.iter().map(|store| ("config", store))) + { + let logical = store.logical.as_str(); + // The label the runtime opens is what + // `EDGEZERO__STORES______NAME` + // resolves to (default = the logical id). Provision + // writes the PLATFORM label into + // `[component.X].key_value_stores` so that both the + // KV runtime lookup AND the KV-backed config + // runtime lookup match. + let label = store.platform.as_str(); + if dry_run { + out.push(format!( + "would ensure {kind} label `{label}` (logical id `{logical}`) is in [component.{component_id}].key_value_stores in {}", + spin_path.display() + )); + continue; + } + let added = ensure_kv_label_in_component(&spin_path, &component_id, label)?; + if added { + out.push(format!( + "added {kind} label `{label}` (logical id `{logical}`) to [component.{component_id}].key_value_stores in {}", + spin_path.display() + )); + } else { + out.push(format!( + "{kind} label `{label}` (logical id `{logical}`) already present in [component.{component_id}].key_value_stores in {}; skipping", + spin_path.display() + )); + } + } + } + for store in stores.secrets { + let logical = store.logical.as_str(); + let platform = store.platform.as_str(); + out.push(format!( + "spin secret id `{logical}` (platform name `{platform}`) requires manual `[variables].* secret = true` + `[component.*.variables].*` declarations in spin.toml; nothing to do here" + )); + } + if out.is_empty() { + out.push("spin has no declared stores to provision".to_owned()); + } + Ok(out) + } + + fn push_config_entries( + &self, + manifest_root: &Path, + adapter_manifest_path: Option<&str>, + _component_selector: Option<&str>, + store: &ResolvedStoreId, + entries: &[(String, String)], + push_ctx: &AdapterPushContext<'_>, + dry_run: bool, + ) -> Result, String> { + dispatch_push( + manifest_root, + adapter_manifest_path, + store, + entries, + push_ctx, + dry_run, + ) + } + + fn push_config_entries_local( + &self, + manifest_root: &Path, + adapter_manifest_path: Option<&str>, + _component_selector: Option<&str>, + store: &ResolvedStoreId, + entries: &[(String, String)], + push_ctx: &AdapterPushContext<'_>, + dry_run: bool, + ) -> Result, String> { + // `--local` lives in `push_ctx.local`. `dispatch_push` honours + // it by suppressing the Fermyon Cloud auto-detect so the + // operator can force a SQLite-direct write even when the + // manifest's deploy command shells to `spin deploy`. + dispatch_push( + manifest_root, + adapter_manifest_path, + store, + entries, + push_ctx, + dry_run, + ) + } + + fn single_store_kinds(&self) -> &'static [&'static str] { + //: Multi for KV AND Config (both label-backed via the + // Spin KV API since Stage 5 of the spin-kv-config plan). + // Single for Secrets (still flat-variable namespace). + &["secrets"] + } + + fn validate_adapter_manifest( + &self, + manifest_root: &Path, + adapter_manifest_path: Option<&str>, + component_selector: Option<&str>, + ) -> Result<(), String> { + // check 3: spin.toml must exist and either declare + // exactly one `[component.*]` or carry an explicit selector + // that matches one of the declared ids. + let Some(rel) = adapter_manifest_path else { + return Err( + "[adapters.spin.adapter].manifest must point at spin.toml for Spin component discovery".to_owned() + ); + }; + let spin_path = manifest_root.join(rel); + let raw = fs::read_to_string(&spin_path).map_err(|err| { + format!( + "failed to read spin manifest at {}: {err}", + spin_path.display() + ) + })?; + let parsed: toml::Value = toml::from_str(&raw) + .map_err(|err| format!("failed to parse {} as TOML: {err}", spin_path.display()))?; + let component_ids = collect_spin_component_ids(&parsed); + + if component_ids.is_empty() { + return Err(format!( + "{}: no [component.*] declarations found", + spin_path.display() + )); + } + + if let Some(selector) = component_selector { + if component_ids.iter().any(|id| id == selector) { + return Ok(()); + } + return Err(format!( + "[adapters.spin.adapter].component = {:?} is not declared in {} (available: {})", + selector, + spin_path.display(), + component_ids.join(", ") + )); + } + + if component_ids.len() == 1 { + return Ok(()); + } + Err(format!( + "{} declares {} components ({}) but [adapters.spin.adapter].component is unset; set one explicitly", + spin_path.display(), + component_ids.len(), + component_ids.join(", ") + )) + } + + fn validate_typed_secrets(&self, plain_secrets: &[(&str, &str)]) -> Result<(), String> { + // Stage 5+: KV-backed config no longer shares Spin's flat + // variable namespace, so config keys are NOT considered here + // (and the trait dropped the parameter in Stage 6+) — config + // can use arbitrary UTF-8 keys without colliding with + // `#[secret]` values. Secrets still resolve through + // `spin_sdk::variables`, so two checks remain: + // 1. each `#[secret]` value canonicalises (lowercase, no + // `.→__` — secrets don't get translated at runtime) + // to a valid Spin variable name, so invalid chars + // (dashes, digit-first) fail validation rather than + // at runtime with an opaque `InvalidName`; + // 2. no two `#[secret]` values collapse to the same + // lowercased Spin variable, since Spin's flat + // namespace cannot disambiguate them. + let mut seen: HashSet = HashSet::with_capacity(plain_secrets.len()); + for (field_name, value) in plain_secrets { + let spin_var = value.to_ascii_lowercase(); + if !is_valid_spin_key(&spin_var) { + let reason = spin_key_rule_violation(&spin_var); + return Err(format!( + "`#[secret]` field `{field_name}` value `{value}` translates to Spin variable `{spin_var}`, which is not a valid Spin variable name. {reason}. Pick a `#[secret]` value that conforms." + )); + } + if !seen.insert(spin_var.clone()) { + return Err(format!( + "Spin variable `{spin_var}` (from `#[secret]` field `{field_name}`) collides with another `#[secret]` value resolving to the same lowercased name; Spin's flat variable namespace cannot disambiguate them" + )); + } + } + Ok(()) + } +} + +fn is_valid_spin_key(key: &str) -> bool { + let mut chars = key.chars(); + let Some(first) = chars.next() else { + return false; + }; + if !first.is_ascii_lowercase() { + return false; + } + chars.all(|ch| ch.is_ascii_lowercase() || ch.is_ascii_digit() || ch == '_') +} + +/// Return a per-failure-mode diagnostic for a key that failed +/// `is_valid_spin_key`. Spin's variable-name rule +/// (`^[a-z][a-z0-9_]*$`) is one regex but the operator usually +/// wants to know WHICH bit they broke: digit-leading, uppercase, +/// or stray punctuation. Returns a short phrase to splice into +/// the caller's full error. +fn spin_key_rule_violation(key: &str) -> &'static str { + // Callers only invoke this AFTER `is_valid_spin_key` returned + // false; in production the per-char branches below exhaust the + // failure modes and the catch-all at the bottom is unreachable. + // It's kept defensively so a future regex tweak (e.g. allowing + // a new char class) doesn't crash the diagnostic helper with + // an unreachable!() before the caller can produce its error. + // + // Reachability notes for the per-mode branches: + // - `push_config_entries` translates keys via + // `translate_key_for_spin` (which lowercases) BEFORE this + // call, so the uppercase-first branch is unreachable from + // that site. It IS reachable from `validate_app_config_keys` + // and `validate_typed_secrets`, which check raw user input. + let mut chars = key.chars(); + let Some(first) = chars.next() else { + return "Spin variable names must not be empty"; + }; + if first.is_ascii_digit() { + return "Spin variable names must start with a lowercase letter, not a digit"; + } + if first.is_ascii_uppercase() { + return "Spin variable names must be lowercase (uppercase letters are not allowed)"; + } + if !first.is_ascii_lowercase() { + return "Spin variable names must start with a lowercase ASCII letter"; + } + for ch in chars { + if ch.is_ascii_uppercase() { + return "Spin variable names must be lowercase (uppercase letters are not allowed)"; + } + if !(ch.is_ascii_lowercase() || ch.is_ascii_digit() || ch == '_') { + return "Spin variable names may only contain lowercase letters, digits, and underscores"; + } + } + debug_assert!( + false, + "spin_key_rule_violation called with key `{key}` that satisfies the regex; check is_valid_spin_key + caller agreement" + ); + "Spin variable names must match `^[a-z][a-z0-9_]*$`" +} + +fn collect_spin_component_ids(parsed: &toml::Value) -> Vec { + parsed + .as_table() + .and_then(|root| root.get("component")) + .and_then(toml::Value::as_table) + .map(|components| components.keys().cloned().collect()) + .unwrap_or_default() +} + +/// Read `[application].name` from `spin.toml`. Required by the +/// Fermyon Cloud writer to address KV stores via the app-scoped +/// label model (`--app --label
__…__` (the prefix is the project +# name, uppercased with `-`→`_`; nested sections are joined by `__`) +# as long as the key already exists below. The loader infers the type +# from the parsed value and coerces the env string accordingly. +# Example: `{{EnvPrefix}}__SERVICE__TIMEOUT_MS=2500` overrides the +# `[service] timeout_ms` field below. + +greeting = "hello from {{name}}" + +[service] +timeout_ms = 1500 + +# When you uncomment `#[secret] api_token` in the AppConfig struct +# (see `crates/{{proj_core}}/src/config.rs`), the matching key here +# is the *name* of the secret -- the runtime resolves it through +# the wired secret store via +# `ctx.secret_store_default()?.require_str(&cfg.api_token)`. +# Uncomment alongside the corresponding `[stores.secrets]` block +# in `edgezero.toml`. +# +# api_token = "demo_api_token" diff --git a/crates/edgezero-cli/src/templates/cli/Cargo.toml.hbs b/crates/edgezero-cli/src/templates/cli/Cargo.toml.hbs new file mode 100644 index 00000000..d9ce46cb --- /dev/null +++ b/crates/edgezero-cli/src/templates/cli/Cargo.toml.hbs @@ -0,0 +1,14 @@ +[package] +name = "{{proj_cli}}" +version = "0.1.0" +edition = "2021" +publish = false + +[lints] +workspace = true + +[dependencies] +{{proj_core}} = { path = "../{{proj_core}}" } +{{{dep_edgezero_cli}}} +clap = { workspace = true } +log = { workspace = true } diff --git a/crates/edgezero-cli/src/templates/cli/src/main.rs.hbs b/crates/edgezero-cli/src/templates/cli/src/main.rs.hbs new file mode 100644 index 00000000..8a4f97e0 --- /dev/null +++ b/crates/edgezero-cli/src/templates/cli/src/main.rs.hbs @@ -0,0 +1,87 @@ +//! {{name}} CLI — built on the `edgezero-cli` library. +//! +//! This binary reuses every built-in `edgezero` command via the +//! `edgezero_cli` library and is the place to add your own +//! subcommands. The `Config` arm dispatches the **typed** validate +//! and push paths, parameterised over `{{NameUpperCamel}}Config` — +//! the struct your `{{proj_core}}` crate owns. The default +//! `edgezero` binary runs the *raw* paths because it has no typed +//! struct in scope; a downstream CLI like this one upgrades to +//! typed so `validator` rules, `#[secret]` / `#[secret(store_ref)]` +//! checks, and the Spin namespace collision check all run. + +use clap::{Parser, Subcommand}; +use edgezero_cli::args::{ + AuthArgs, BuildArgs, ConfigPushArgs, ConfigValidateArgs, DeployArgs, NewArgs, ProvisionArgs, + ServeArgs, +}; +use {{proj_core_mod}}::config::{{NameUpperCamel}}Config; + +#[derive(Parser, Debug)] +#[command(name = "{{proj_cli}}", about = "{{name}} edge CLI")] +struct Args { + #[command(subcommand)] + cmd: Cmd, +} + +#[derive(Subcommand, Debug)] +enum Cmd { + /// Sign in / out / status against the adapter's native CLI + /// (`wrangler` / `fastly` / `spin`). See spec. + Auth(AuthArgs), + /// Build the project for a target edge. + Build(BuildArgs), + /// Inspect or mutate the typed `{{name}}.toml` app config. + #[command(subcommand)] + Config({{NameUpperCamel}}ConfigCmd), + /// Deploy to a target edge. + Deploy(DeployArgs), + /// Create a new `EdgeZero` app skeleton. + New(NewArgs), + /// Create the platform resources backing the declared + /// `[stores.].ids`. + Provision(ProvisionArgs), + /// Run a local simulation (adapter-specific). + Serve(ServeArgs), +} + +/// Mirrors `edgezero_cli::args::ConfigCmd` but dispatches both +/// `validate` and `push` to the **typed** entry points +/// parameterised over `{{NameUpperCamel}}Config` — the downstream +/// project owns the struct, so it can enforce the typed +/// deserialise, `validator` rules, and `#[secret]` / +/// `#[secret(store_ref)]` checks the raw default-binary path skips +///. +#[derive(Subcommand, Debug)] +enum {{NameUpperCamel}}ConfigCmd { + /// Push `{{name}}.toml` (flattened, secret-stripped) to the + /// adapter's config store. + Push(ConfigPushArgs), + /// Validate `edgezero.toml` and `{{name}}.toml` against the + /// typed `{{NameUpperCamel}}Config` contract. + Validate(ConfigValidateArgs), +} + +fn main() { + use std::process; + + edgezero_cli::init_cli_logger(); + let result = match Args::parse().cmd { + Cmd::Auth(args) => edgezero_cli::run_auth(&args), + Cmd::Build(args) => edgezero_cli::run_build(&args), + Cmd::Config({{NameUpperCamel}}ConfigCmd::Push(args)) => { + edgezero_cli::run_config_push_typed::<{{NameUpperCamel}}Config>(&args) + } + Cmd::Config({{NameUpperCamel}}ConfigCmd::Validate(args)) => { + edgezero_cli::run_config_validate_typed::<{{NameUpperCamel}}Config>(&args) + } + Cmd::Deploy(args) => edgezero_cli::run_deploy(&args), + Cmd::New(args) => edgezero_cli::run_new(&args), + Cmd::Provision(args) => edgezero_cli::run_provision(&args), + Cmd::Serve(args) => edgezero_cli::run_serve(&args), + }; + if let Err(err) = result { + log::error!("[{{name}}] {err}"); + process::exit(1); + } +} diff --git a/crates/edgezero-cli/src/templates/core/Cargo.toml.hbs b/crates/edgezero-cli/src/templates/core/Cargo.toml.hbs index 4dc4f0a4..578cfa62 100644 --- a/crates/edgezero-cli/src/templates/core/Cargo.toml.hbs +++ b/crates/edgezero-cli/src/templates/core/Cargo.toml.hbs @@ -4,11 +4,18 @@ version = "0.1.0" edition = "2021" publish = false +[lints] +workspace = true + [dependencies] bytes = { workspace = true } {{{dep_edgezero_core}}} futures = { workspace = true } serde = { workspace = true } +# `#[derive(Validate)]` on the generated `{{NameUpperCamel}}Config` +# struct. `edgezero_core::AppConfig` comes through the +# `edgezero-core` re-export — no `edgezero-macros` dep needed. +validator = { workspace = true } [dev-dependencies] async-trait = "0.1" diff --git a/crates/edgezero-cli/src/templates/core/src/config.rs.hbs b/crates/edgezero-cli/src/templates/core/src/config.rs.hbs new file mode 100644 index 00000000..af20d750 --- /dev/null +++ b/crates/edgezero-cli/src/templates/core/src/config.rs.hbs @@ -0,0 +1,62 @@ +//! Typed application config, loaded from `{{name}}.toml` via +//! `edgezero_core::app_config::load_app_config::<{{NameUpperCamel}}Config>`. +//! +//! The TOML file maps directly onto this struct — there is no +//! `[config]` wrapper; top-level keys correspond to top-level +//! fields. The `{{EnvPrefix}}__
__…__` env-var +//! overlay (project name uppercased with `-`→`_`, nested sections +//! joined by `__`) overrides any key already present. + +#![expect( + clippy::module_name_repetitions, + reason = "`Config` is the canonical struct name the generator emits; the duplication with the `config` module is intentional" +)] + +use serde::{Deserialize, Serialize}; +use validator::Validate; + +#[derive(Debug, Deserialize, Serialize, Validate, edgezero_core::AppConfig)] +#[serde(deny_unknown_fields)] +pub struct {{NameUpperCamel}}Config { + /// Free-form greeting surfaced by example handlers. Replace or + /// remove as the app grows. + pub greeting: String, + + /// Nested section — exercises the env-var overlay + /// (`{{EnvPrefix}}__SERVICE__TIMEOUT_MS=…` at runtime). + /// `#[validate(nested)]` makes the outer `validate()` recurse + /// into `ServiceConfig`; without it the inner `range` rule on + /// `timeout_ms` silently no-ops. + #[validate(nested)] + pub service: ServiceConfig, + // `#[secret]` — uncomment when the project declares + // `[stores.secrets]` in `edgezero.toml` (with at least a + // `default` id) and the handler loads the secret bytes at + // runtime via `ctx.secret_store_default()?.require_str(&cfg.api_token)`. + // The value here is the *key* in the default secret store, + // NOT the secret bytes; the runtime resolves it through the + // wired adapter binding (Cloudflare worker secret, Fastly + // secret-store, Spin secret variable, ...). EdgeZero's typed + // validator rejects the field unless the secret store is + // declared, so this is opt-in to keep the scaffold's `serve` + // path runnable out of the box. + // + // #[secret] + // pub api_token: String, + // + // `#[secret(store_ref)]` — uncomment when the project declares + // more than one secret store id under `[stores.secrets].ids`. + // The value is then the logical id of the secret store to + // resolve at runtime via `ctx.secret_store(&cfg.vault)?`. + // Single-secret-store projects don't need this. + // + // #[secret(store_ref)] + // pub vault: String, +} + +#[derive(Debug, Deserialize, Serialize, Validate)] +#[serde(deny_unknown_fields)] +pub struct ServiceConfig { + #[validate(range(min = 100_u32, max = 60_000_u32))] + pub timeout_ms: u32, +} diff --git a/crates/edgezero-cli/src/templates/core/src/handlers.rs.hbs b/crates/edgezero-cli/src/templates/core/src/handlers.rs.hbs index 1641a1fd..382a2ad5 100644 --- a/crates/edgezero-cli/src/templates/core/src/handlers.rs.hbs +++ b/crates/edgezero-cli/src/templates/core/src/handlers.rs.hbs @@ -1,3 +1,4 @@ +use bytes::Bytes; use edgezero_core::action; use edgezero_core::body::Body; use edgezero_core::context::RequestContext; @@ -6,19 +7,19 @@ use edgezero_core::extractor::{Headers, Json, Path}; use edgezero_core::http::{self, Response, StatusCode, Uri}; use edgezero_core::proxy::ProxyRequest; use edgezero_core::response::Text; -use bytes::Bytes; -use futures::{stream, StreamExt}; +use futures::{stream, StreamExt as _}; +use std::env; const DEFAULT_PROXY_BASE: &str = "https://httpbin.org"; #[derive(serde::Deserialize)] -pub(crate) struct EchoParams { - pub(crate) name: String, +pub struct EchoBody { + pub name: String, } #[derive(serde::Deserialize)] -pub(crate) struct EchoBody { - pub(crate) name: String, +pub struct EchoParams { + pub name: String, } #[derive(serde::Deserialize)] @@ -28,45 +29,44 @@ struct ProxyPath { } #[action] -pub(crate) async fn root() -> Text<&'static str> { +pub async fn root() -> Text<&'static str> { Text::new("{{name}} app") } #[action] -pub(crate) async fn echo(Path(params): Path) -> Text { +pub async fn echo(Path(params): Path) -> Text { Text::new(format!("Hello, {}!", params.name)) } #[action] -pub(crate) async fn headers(Headers(headers): Headers) -> Text { +pub async fn headers(Headers(headers): Headers) -> Text { let ua = headers .get("user-agent") .and_then(|value| value.to_str().ok()) .unwrap_or("(unknown)"); - Text::new(format!("ua={}", ua)) + Text::new(format!("ua={ua}")) } #[action] -pub(crate) async fn stream() -> Response { - let body = Body::stream(stream::iter(0..3).map(|index| Bytes::from(format!( - "chunk {}\n", - index - )))); +pub async fn stream() -> Result { + let body = Body::stream( + stream::iter(0_i32..3_i32).map(|index| Bytes::from(format!("chunk {index}\n"))), + ); http::response_builder() .status(StatusCode::OK) .header("content-type", "text/plain; charset=utf-8") .body(body) - .expect("static stream response") + .map_err(EdgeError::internal) } #[action] -pub(crate) async fn echo_json(Json(body): Json) -> Text { +pub async fn echo_json(Json(body): Json) -> Text { Text::new(format!("Hello, {}!", body.name)) } #[action] -pub(crate) async fn proxy_demo(RequestContext(ctx): RequestContext) -> Result { +pub async fn proxy_demo(RequestContext(ctx): RequestContext) -> Result { let params: ProxyPath = ctx.path()?; let proxy_handle = ctx.proxy_handle(); let request = ctx.into_request(); @@ -81,8 +81,8 @@ pub(crate) async fn proxy_demo(RequestContext(ctx): RequestContext) -> Result Result { - let base = std::env::var("API_BASE_URL").unwrap_or_else(|_| DEFAULT_PROXY_BASE.to_string()); - let mut target = base.trim_end_matches('/').to_string(); + let base = env::var("API_BASE_URL").unwrap_or_else(|_| DEFAULT_PROXY_BASE.to_owned()); + let mut target = base.trim_end_matches('/').to_owned(); let trimmed_rest = rest.trim_start_matches('/'); if !trimmed_rest.is_empty() { target.push('/'); @@ -115,31 +115,82 @@ fn proxy_not_available_response() -> Result { #[cfg(test)] mod tests { use super::*; + use async_trait::async_trait; use edgezero_core::body::Body; use edgezero_core::context::RequestContext; use edgezero_core::http::header::{HeaderName, HeaderValue}; use edgezero_core::http::{request_builder, Method, StatusCode, Uri}; use edgezero_core::params::PathParams; use edgezero_core::proxy::{ProxyClient, ProxyHandle, ProxyResponse}; - use edgezero_core::response::IntoResponse; - use async_trait::async_trait; - use futures::{executor::block_on, StreamExt}; + use edgezero_core::response::IntoResponse as _; + use futures::executor::block_on; use std::collections::HashMap; use std::env; + use std::sync::{Mutex, MutexGuard, OnceLock}; + + struct TestProxyClient; + + #[async_trait(?Send)] + impl ProxyClient for TestProxyClient { + async fn send(&self, request: ProxyRequest) -> Result { + let (_method, uri, _headers, _body, _) = request.into_parts(); + assert!(uri.to_string().contains("status/201")); + Ok(ProxyResponse::new(StatusCode::CREATED, Body::empty())) + } + } + + /// Serializes every test that reads or writes the `API_BASE_URL` + /// process-global env var — concurrent `env::set_var` / `env::var` + /// across threads is unsound, so these tests must not overlap. + fn env_lock() -> MutexGuard<'static, ()> { + static LOCK: OnceLock> = OnceLock::new(); + LOCK.get_or_init(|| Mutex::new(())) + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + } + + /// Restores `API_BASE_URL` to its prior value when dropped, so a + /// panicking assertion cannot leak process-global state. + struct EnvVarGuard { + original: Option, + } + + impl EnvVarGuard { + fn set(value: &str) -> Self { + let original = env::var("API_BASE_URL").ok(); + env::set_var("API_BASE_URL", value); + Self { original } + } + } + + impl Drop for EnvVarGuard { + fn drop(&mut self) { + match &self.original { + Some(value) => env::set_var("API_BASE_URL", value), + None => env::remove_var("API_BASE_URL"), + } + } + } #[test] fn root_returns_static_body() { let ctx = empty_context("/"); - let response = block_on(root(ctx)).expect("handler ok").into_response(); - let bytes = response.into_body().into_bytes(); + let response = block_on(root(ctx)) + .expect("handler ok") + .into_response() + .expect("response"); + let bytes = response.into_body().into_bytes().expect("buffered"); assert_eq!(bytes.as_ref(), b"{{name}} app"); } #[test] fn echo_formats_name_from_path() { let ctx = context_with_params("/echo/alice", &[("name", "alice")]); - let response = block_on(echo(ctx)).expect("handler ok").into_response(); - let bytes = response.into_body().into_bytes(); + let response = block_on(echo(ctx)) + .expect("handler ok") + .into_response() + .expect("response"); + let bytes = response.into_body().into_bytes().expect("buffered"); assert_eq!(bytes.as_ref(), b"Hello, alice!"); } @@ -151,8 +202,11 @@ mod tests { HeaderValue::from_static("DemoAgent"), ); - let response = block_on(headers(ctx)).expect("handler ok").into_response(); - let bytes = response.into_body().into_bytes(); + let response = block_on(headers(ctx)) + .expect("handler ok") + .into_response() + .expect("response"); + let bytes = response.into_body().into_bytes().expect("buffered"); assert_eq!(bytes.as_ref(), b"ua=DemoAgent"); } @@ -165,8 +219,8 @@ mod tests { let mut chunks = response.into_body().into_stream().expect("stream body"); let collected = block_on(async { let mut buf = Vec::new(); - while let Some(chunk) = chunks.next().await { - let chunk = chunk.expect("chunk"); + while let Some(item) = chunks.next().await { + let chunk = item.expect("chunk"); buf.extend_from_slice(&chunk); } buf @@ -179,47 +233,38 @@ mod tests { #[test] fn echo_json_formats_payload() { - let ctx = context_with_json( - "/echo", - r#"{"name":"Edge"}"#, - ); - let response = block_on(echo_json(ctx)).expect("handler ok").into_response(); - let bytes = response.into_body().into_bytes(); + let ctx = context_with_json("/echo", r#"{"name":"Edge"}"#); + let response = block_on(echo_json(ctx)) + .expect("handler ok") + .into_response() + .expect("response"); + let bytes = response.into_body().into_bytes().expect("buffered"); assert_eq!(bytes.as_ref(), b"Hello, Edge!"); } #[test] fn build_proxy_target_merges_segments_and_query() { - env::set_var("API_BASE_URL", "https://example.com/api"); + let _lock = env_lock(); + let _env = EnvVarGuard::set("https://example.com/api"); let original = Uri::from_static("/proxy/status?foo=bar"); let target = build_proxy_target("status/200", &original).expect("target uri"); - assert_eq!(target.to_string(), "https://example.com/api/status/200?foo=bar"); - env::remove_var("API_BASE_URL"); + assert_eq!( + target.to_string(), + "https://example.com/api/status/200?foo=bar" + ); } #[test] fn proxy_demo_without_handle_returns_placeholder() { - env::set_var("API_BASE_URL", "https://example.com/api"); + let _lock = env_lock(); let ctx = context_with_params("/proxy/status/200", &[("rest", "status/200")]); let response = block_on(proxy_demo(ctx)).expect("response"); assert_eq!(response.status(), StatusCode::NOT_IMPLEMENTED); - env::remove_var("API_BASE_URL"); - } - - struct TestProxyClient; - - #[async_trait(?Send)] - impl ProxyClient for TestProxyClient { - async fn send(&self, request: ProxyRequest) -> Result { - let (_method, uri, _headers, _body, _) = request.into_parts(); - assert!(uri.to_string().contains("status/201")); - Ok(ProxyResponse::new(StatusCode::CREATED, Body::empty())) - } } #[test] fn proxy_demo_uses_injected_handle() { - env::set_var("API_BASE_URL", "https://example.com/api"); + let _lock = env_lock(); let mut request = request_builder() .method(Method::GET) @@ -231,13 +276,11 @@ mod tests { .insert(ProxyHandle::with_client(TestProxyClient)); let mut params = HashMap::new(); - params.insert("rest".to_string(), "status/201".to_string()); + params.insert("rest".to_owned(), "status/201".to_owned()); let ctx = RequestContext::new(request, PathParams::new(params)); let response = block_on(proxy_demo(ctx)).expect("response"); assert_eq!(response.status(), StatusCode::CREATED); - - env::remove_var("API_BASE_URL"); } fn empty_context(path: &str) -> RequestContext { @@ -257,16 +300,12 @@ mod tests { .expect("request"); let map = params .iter() - .map(|(k, v)| ((*k).to_string(), (*v).to_string())) + .map(|&(key, value)| (key.to_owned(), value.to_owned())) .collect::>(); RequestContext::new(request, PathParams::new(map)) } - fn context_with_header( - path: &str, - header: HeaderName, - value: HeaderValue, - ) -> RequestContext { + fn context_with_header(path: &str, header: HeaderName, value: HeaderValue) -> RequestContext { let mut request = request_builder() .method(Method::GET) .uri(path) diff --git a/crates/edgezero-cli/src/templates/core/src/lib.rs.hbs b/crates/edgezero-cli/src/templates/core/src/lib.rs.hbs index d8939a11..dee67bfe 100644 --- a/crates/edgezero-cli/src/templates/core/src/lib.rs.hbs +++ b/crates/edgezero-cli/src/templates/core/src/lib.rs.hbs @@ -1,3 +1,4 @@ +pub mod config; mod handlers; edgezero_core::app!("../../edgezero.toml"); diff --git a/crates/edgezero-cli/src/templates/root/Cargo.toml.hbs b/crates/edgezero-cli/src/templates/root/Cargo.toml.hbs index 1b637bdb..a7c02b6a 100644 --- a/crates/edgezero-cli/src/templates/root/Cargo.toml.hbs +++ b/crates/edgezero-cli/src/templates/root/Cargo.toml.hbs @@ -1,6 +1,7 @@ [workspace] members = [ "crates/{{proj_core}}", + "crates/{{proj_cli}}", {{{workspace_members}}} ] resolver = "2" @@ -12,3 +13,33 @@ resolver = "2" debug = 1 codegen-units = 1 lto = "fat" + +[workspace.lints.clippy] +# Strict gate matching the EdgeZero workspace. The allow-list below tracks +# the entries the EdgeZero demo legitimately needs — extend it lazily when +# a real failure surfaces in your generated code. +pedantic = { level = "warn", priority = -1 } +restriction = { level = "deny", priority = -1 } + +# Meta — required when enabling `restriction` as a group. +blanket_clippy_restriction_lints = "allow" + +# Documentation — private items don't need full docs in app code. +missing_docs_in_private_items = "allow" + +# Style / formatting — match idiomatic Rust conventions. +implicit_return = "allow" +question_mark_used = "allow" +single_call_fn = "allow" +separated_literal_suffix = "allow" + +# API design — `exhaustive_structs` fires on the unit struct generated by +# `edgezero_core::app!`. +exhaustive_structs = "allow" + +# Imports / paths — generated binaries are std applications, not no_std libraries. +std_instead_of_alloc = "allow" +std_instead_of_core = "allow" + +[workspace.lints.rust] +unsafe_code = "deny" diff --git a/crates/edgezero-cli/src/templates/root/README.md.hbs b/crates/edgezero-cli/src/templates/root/README.md.hbs index 376fc87e..59c7fb6c 100644 --- a/crates/edgezero-cli/src/templates/root/README.md.hbs +++ b/crates/edgezero-cli/src/templates/root/README.md.hbs @@ -3,6 +3,7 @@ This workspace demonstrates a multi-target EdgeZero app. - `crates/{{proj_core}}`: reusable application logic built with `edgezero-core`. +- `crates/{{proj_cli}}`: your project's CLI binary, built on the reusable `edgezero-cli` library. Extend it with your own subcommands here — `edgezero` itself stays generic. {{{readme_adapter_crates}}} ## Routes @@ -20,4 +21,4 @@ This workspace demonstrates a multi-target EdgeZero app. ## Configuration -Environment variables are declared in `edgezero.toml`. Set `API_BASE_URL` to the upstream origin you want `/proxy/...` to target and provide adapter-specific secrets (for example `API_TOKEN`) when deploying. +Environment variables are declared in `edgezero.toml`. Set `API_BASE_URL` to the upstream origin you want `/proxy/...` to target. Adapter-specific secrets are opt-in: uncomment the `#[secret]` field in `crates/{{proj_core}}/src/config.rs` and the matching `[stores.secrets]` block in `edgezero.toml`, then provide the secret value through the wired backend (Cloudflare Worker secret, Fastly Secret Store, Spin secret variable, ...). diff --git a/crates/edgezero-cli/src/templates/root/clippy.toml.hbs b/crates/edgezero-cli/src/templates/root/clippy.toml.hbs new file mode 100644 index 00000000..36e6164c --- /dev/null +++ b/crates/edgezero-cli/src/templates/root/clippy.toml.hbs @@ -0,0 +1,10 @@ +# Clippy configuration. See https://doc.rust-lang.org/clippy/lint_configuration.html +# +# Test code uses `.unwrap()`, `.expect()`, `panic!`, `assert!`, indexing, and +# other "if-this-fails-the-test-fails" idioms by convention. Mirror the +# EdgeZero workspace policy and exempt tests from the corresponding +# restriction lints. +allow-expect-in-tests = true +allow-indexing-slicing-in-tests = true +allow-panic-in-tests = true +allow-unwrap-in-tests = true diff --git a/crates/edgezero-cli/src/templates/root/edgezero.toml.hbs b/crates/edgezero-cli/src/templates/root/edgezero.toml.hbs index 48c902d2..5f5daae7 100644 --- a/crates/edgezero-cli/src/templates/root/edgezero.toml.hbs +++ b/crates/edgezero-cli/src/templates/root/edgezero.toml.hbs @@ -52,6 +52,28 @@ methods = ["GET", "POST"] handler = "{{proj_core_mod}}::handlers::proxy_demo" adapters = [{{{adapter_list}}}] +# -- Stores ---------------------------------------------------------------- +# +# `[stores.]` declares logical store ids only. `default` is required +# when more than one id is declared; with a single id it resolves to that id. +# +# The default scaffold ships with no stores so `edgezero serve --adapter +# ` starts cleanly without per-platform KV / config / secret bindings. +# The scaffolded `::AppConfig` correspondingly has no `#[secret]` / +# `#[kv]` fields; uncomment the kinds your handlers will use, then provision +# the matching platform bindings (see docs/guide/manifest-store-migration.md +# and the per-adapter guides for the wrangler.toml / spin.toml / fastly.toml +# entries). +# +# [stores.kv] +# ids = ["app_kv"] +# +# [stores.config] +# ids = ["app_config"] +# +# [stores.secrets] +# ids = ["default"] + # [environment] # # [[environment.variables]] diff --git a/crates/edgezero-cli/src/templates/root/gitignore.hbs b/crates/edgezero-cli/src/templates/root/gitignore.hbs index b99f0fe0..5bf2f985 100644 --- a/crates/edgezero-cli/src/templates/root/gitignore.hbs +++ b/crates/edgezero-cli/src/templates/root/gitignore.hbs @@ -3,6 +3,17 @@ bin/ pkg/ target/ +# local emulator / runtime state — each adapter's local KV / config +# store lives under one of these directories: +# - Cloudflare local KV -> .wrangler/state/v3/kv/*/db.sqlite +# - Spin local KV -> .spin/sqlite_key_value.db +# - Axum local config -> .edgezero/local-config-.json +# All three are populated by `config push --local` (or by the +# runtime itself on first read) and should not be committed. +.wrangler/ +.spin/ +.edgezero/ + # env .env diff --git a/crates/edgezero-cli/src/test_support.rs b/crates/edgezero-cli/src/test_support.rs new file mode 100644 index 00000000..55e1efdd --- /dev/null +++ b/crates/edgezero-cli/src/test_support.rs @@ -0,0 +1,144 @@ +//! Test-only fixtures shared across `auth`, `provision`, `build`, +//! `deploy`, `serve`, and `config` test modules. +//! +//! Each of those modules calls into the global `EDGEZERO_MANIFEST` +//! env var and the adapter registry, both of which are process-wide +//! state. The `manifest_guard()` mutex serialises tests that touch +//! either; the `EnvOverride` RAII guard restores the prior env value +//! when dropped, so a panic in one test cannot leak state into the +//! next. +//! +//! Kept under `pub(crate)` so the in-module test files (per the +//! "colocate tests with implementation" convention in CLAUDE.md) +//! can share the harness without each duplicating the BASIC / +//! PROVISION manifest fixtures. + +use std::env; +use std::sync::{Mutex, OnceLock}; + +/// `provision` dispatch fixture: declares axum + fastly + +/// cloudflare + spin (every adapter the build registers), with +/// store ids per kind so axum has something to print and the +/// other adapters' stubs are exercised against a non-empty input. +pub(crate) const PROVISION_MANIFEST: &str = r#" +[app] +name = "demo-app" + +[adapters.axum.adapter] +crate = "crates/demo-axum" +[adapters.axum.commands] +build = "echo" +deploy = "echo" +serve = "echo" + +[adapters.cloudflare.adapter] +crate = "crates/demo-cf" +manifest = "wrangler.toml" + +[adapters.cloudflare.commands] +build = "echo" +deploy = "echo" +serve = "echo" + +[adapters.fastly.adapter] +crate = "crates/demo-fastly" +manifest = "fastly.toml" + +[adapters.fastly.commands] +build = "echo" +deploy = "echo" +serve = "echo" + +[adapters.spin.adapter] +crate = "crates/demo-spin" +manifest = "spin.toml" +[adapters.spin.commands] +build = "echo" +deploy = "echo" +serve = "echo" + +[stores.kv] +ids = ["sessions", "cache"] +default = "sessions" + +[stores.config] +ids = ["app_config"] + +[stores.secrets] +ids = ["default"] +"#; + +/// Minimal manifest covering the auth + build/deploy/serve dispatch +/// surface. Only fastly is declared because its command overrides +/// (`auth-login` etc.) are what the auth orchestration tests +/// substitute with `echo` to keep CI hermetic. +pub(crate) const BASIC_MANIFEST: &str = r#" +[app] +name = "demo-app" +entry = "crates/demo-core" + +[adapters.fastly.adapter] +crate = "crates/demo-fastly" +manifest = "crates/demo-fastly/fastly.toml" + +[adapters.fastly.build] +target = "wasm32-unknown-unknown" +profile = "release" + +[adapters.fastly.commands] +build = "echo build" +deploy = "echo deploy" +serve = "echo serve" +auth-login = "echo logged in" +auth-logout = "echo logged out" +auth-status = "echo whoami" +"#; + +/// RAII guard that sets a process-global env var for the duration +/// of a test and restores the prior value (or removes it) on drop. +/// Use together with [`manifest_guard`] when overriding +/// `EDGEZERO_MANIFEST` so concurrent tests don't observe the +/// override. +pub(crate) struct EnvOverride { + key: &'static str, + original: Option, +} + +impl Drop for EnvOverride { + fn drop(&mut self) { + if let Some(original) = &self.original { + env::set_var(self.key, original); + } else { + env::remove_var(self.key); + } + } +} + +impl EnvOverride { + /// Remove the env var (if set) for the duration of the test + /// scope, capturing the prior value so drop can restore it. + /// Use when a test needs the "no override" code path but the + /// parent shell may have exported a value. + pub(crate) fn remove(key: &'static str) -> Self { + let original = env::var(key).ok(); + env::remove_var(key); + Self { key, original } + } + + /// Set the env var to `value` for the duration of the test + /// scope, capturing the prior value so drop can restore it. + pub(crate) fn set(key: &'static str, value: &str) -> Self { + let original = env::var(key).ok(); + env::set_var(key, value); + Self { key, original } + } +} + +/// Process-wide mutex serialising tests that mutate `EDGEZERO_MANIFEST` +/// or otherwise observe global adapter-registry state. Acquire it +/// BEFORE constructing the `EnvOverride` so two parallel tests +/// don't race the env-var write. +pub(crate) fn manifest_guard() -> &'static Mutex<()> { + static GUARD: OnceLock> = OnceLock::new(); + GUARD.get_or_init(|| Mutex::new(())) +} diff --git a/crates/edgezero-cli/tests/generated_project_builds.rs b/crates/edgezero-cli/tests/generated_project_builds.rs new file mode 100644 index 00000000..adae2db7 --- /dev/null +++ b/crates/edgezero-cli/tests/generated_project_builds.rs @@ -0,0 +1,160 @@ +//! Opt-in integration test: a freshly scaffolded project compiles. +//! +//! Ignored by default — it runs `cargo check` on a generated workspace +//! (host plus each adapter's wasm target), which recompiles the edgezero +//! stack and may fetch crates (minutes, not milliseconds). The fast +//! `generator` unit tests assert that the scaffold resolves edgezero crates +//! to local path dependencies; this test additionally proves the generated +//! workspace — the CLI crate that imports `edgezero_cli`, and the +//! target-gated adapter entrypoints — compiles end to end. +//! +//! Run it explicitly (and in CI): +//! +//! ```sh +//! cargo test -p edgezero-cli --test generated_project_builds -- --ignored +//! ``` + +#[cfg(test)] +mod tests { + use std::path::Path; + use std::process::Command; + + /// Targets installed for the toolchain that builds `project`. A wasm + /// check is skipped when its target is absent (e.g. a local run where + /// the project sits outside a checkout that pins the wasm targets); CI + /// installs both wasm targets, so the full set always runs there. + fn installed_targets(project: &Path) -> String { + Command::new("rustup") + .args(["target", "list", "--installed"]) + .current_dir(project) + .output() + .map(|out| String::from_utf8_lossy(&out.stdout).into_owned()) + .unwrap_or_default() + } + + #[test] + #[ignore = "compiles a generated workspace and may fetch crates; run explicitly"] + #[expect( + clippy::print_stderr, + reason = "an opt-in test surfacing a skipped wasm check" + )] + fn generated_workspace_compiles() { + let temp = tempfile::tempdir().expect("temp dir"); + let new_status = Command::new(env!("CARGO_BIN_EXE_edgezero")) + .arg("new") + .arg("scaffold-probe") + .arg("--dir") + .arg(temp.path()) + .status() + .expect("run `edgezero new`"); + assert!(new_status.success(), "`edgezero new` should succeed"); + + let project = temp.path().join("scaffold-probe"); + + // The scaffold's `edgezero.toml` + `.toml` + AppConfig + // must be internally consistent (no `#[secret]` field + // without a matching `[stores.secrets]`, no env-overlay + // mismatches). `edgezero config validate` exercises the + // typed config validator end-to-end. We do this BEFORE + // `cargo check` so a manifest/config drift surfaces as a + // fast, clear error -- not as a compilation cascade from + // a downstream macro tripping over the bad config. + let validate = Command::new(env!("CARGO_BIN_EXE_edgezero")) + .args(["config", "validate"]) + .current_dir(&project) + .status() + .expect("run `edgezero config validate` on the generated workspace"); + assert!( + validate.success(), + "generated workspace should pass `edgezero config validate`", + ); + + // Also exercise --strict so the capability matrix + // (`strict_capability_completeness`) and the handler-path + // rule (`strict_handler_paths`) fire against a freshly + // generated project. A scaffold that emits a triggers list + // with a malformed handler or a manifest that violates the + // adapter capability matrix would silently pass plain + // validate but fail under strict. + let validate_strict = Command::new(env!("CARGO_BIN_EXE_edgezero")) + .args(["config", "validate", "--strict"]) + .current_dir(&project) + .status() + .expect("run `edgezero config validate --strict` on the generated workspace"); + assert!( + validate_strict.success(), + "generated workspace should pass `edgezero config validate --strict`", + ); + + // Host target: the whole workspace, including the generated CLI + // crate that imports `edgezero_cli`. + let host = Command::new(env!("CARGO")) + .args(["check", "--workspace"]) + .current_dir(&project) + .status() + .expect("run `cargo check` on the generated workspace"); + assert!( + host.success(), + "generated workspace should compile for the host target", + ); + + // Typed config validation via the generated `-cli` binary. + // The raw `edgezero config validate` above exercises the manifest + // schema and capability matrix; the generated CLI additionally + // runs the user's `#[derive(Validate)]` impl on `AppConfig` plus + // the `#[app]` macro-emitted `#[secret]` discovery. Without this + // step, template drift that compiles but produces an invalid + // typed config (e.g. `#[secret]` on a non-scalar field) would + // slip through. + let typed_validate = Command::new(env!("CARGO")) + .args([ + "run", + "-p", + "scaffold-probe-cli", + "--quiet", + "--", + "config", + "validate", + "--strict", + ]) + .current_dir(&project) + .status() + .expect("run the generated typed CLI `config validate --strict`"); + assert!( + typed_validate.success(), + "generated typed CLI should pass `config validate --strict`", + ); + + // Per-adapter wasm targets: where target-gated template code lives + // (entrypoint signatures, macro-generated unsafe exports). + let targets = installed_targets(&project); + for (adapter, target) in [ + ("cloudflare", "wasm32-unknown-unknown"), + ("fastly", "wasm32-wasip1"), + ("spin", "wasm32-wasip2"), + ] { + if !targets.contains(target) { + eprintln!("skipping {adapter} wasm check: target {target} not installed"); + continue; + } + let crate_name = format!("scaffold-probe-adapter-{adapter}"); + let wasm = Command::new(env!("CARGO")) + .args([ + "check", + "-p", + &crate_name, + "--target", + target, + "--features", + adapter, + ]) + .current_dir(&project) + .status() + .expect("run `cargo check` for a wasm adapter target"); + assert!( + wasm.success(), + "generated {adapter} adapter should compile for {target}", + ); + } + } +} diff --git a/crates/edgezero-cli/tests/lib_consumer.rs b/crates/edgezero-cli/tests/lib_consumer.rs new file mode 100644 index 00000000..d164f911 --- /dev/null +++ b/crates/edgezero-cli/tests/lib_consumer.rs @@ -0,0 +1,68 @@ +//! External-consumer integration test. +//! +//! Exercises the `edgezero_cli` public API exactly as a downstream +//! binary would — proving the library surface (`args::BuildArgs`, +//! `run_build`) is usable from outside the crate. +//! +//! This module deliberately contains exactly one `#[test]`: it mutates +//! the process-global `EDGEZERO_MANIFEST` env var, and a single test +//! means no in-binary parallelism on it. If a second env-touching test +//! is ever added here, gate both with a shared `Mutex` guard. + +#[cfg(test)] +mod tests { + use edgezero_cli::args::BuildArgs; + use edgezero_cli::run_build; + use std::env; + use std::fs; + use tempfile::TempDir; + + const BASIC_MANIFEST: &str = r#" +[app] +name = "consumer-app" +entry = "crates/consumer-core" + +[adapters.fastly.commands] +build = "echo build" +deploy = "echo deploy" +serve = "echo serve" +"#; + + /// RAII guard that restores `EDGEZERO_MANIFEST` to its prior value on drop. + struct EnvOverride { + original: Option, + } + + impl Drop for EnvOverride { + fn drop(&mut self) { + match &self.original { + Some(value) => env::set_var("EDGEZERO_MANIFEST", value), + None => env::remove_var("EDGEZERO_MANIFEST"), + } + } + } + + impl EnvOverride { + fn set(value: &str) -> Self { + let original = env::var("EDGEZERO_MANIFEST").ok(); + env::set_var("EDGEZERO_MANIFEST", value); + Self { original } + } + } + + #[cfg(not(windows))] + #[test] + fn external_consumer_can_call_run_build() { + let temp = TempDir::new().expect("temp dir"); + let manifest_path = temp.path().join("edgezero.toml"); + fs::write(&manifest_path, BASIC_MANIFEST).expect("write manifest"); + let _env = EnvOverride::set(&manifest_path.to_string_lossy()); + + // Construct via `Default` + field mutation — the path that works for + // an external crate even though `BuildArgs` is `#[non_exhaustive]`. + let mut args = BuildArgs::default(); + args.adapter = "fastly".to_owned(); + + run_build(&args).expect("external consumer can run_build"); + } +} diff --git a/crates/edgezero-core/Cargo.toml b/crates/edgezero-core/Cargo.toml index 1bbf05b4..8e531318 100644 --- a/crates/edgezero-core/Cargo.toml +++ b/crates/edgezero-core/Cargo.toml @@ -5,6 +5,9 @@ version = { workspace = true } authors = { workspace = true } license = { workspace = true } +[lints] +workspace = true + [dependencies] edgezero-macros = { path = "../edgezero-macros" } anyhow = { workspace = true } diff --git a/crates/edgezero-core/src/addr.rs b/crates/edgezero-core/src/addr.rs index 60bb99ca..50aa2130 100644 --- a/crates/edgezero-core/src/addr.rs +++ b/crates/edgezero-core/src/addr.rs @@ -1,12 +1,12 @@ -//! Shared bind-address resolution for EdgeZero dev servers. +//! Shared bind-address resolution for `EdgeZero` dev servers. //! //! Centralises the precedence logic (env vars > config > defaults) so that //! both the Axum adapter and the CLI dev server produce consistent results. -use std::net::{IpAddr, SocketAddr}; +use std::net::{IpAddr, Ipv4Addr, SocketAddr}; /// Default bind host: localhost (`127.0.0.1`). -pub const DEFAULT_HOST: IpAddr = IpAddr::V4(std::net::Ipv4Addr::new(127, 0, 0, 1)); +pub const DEFAULT_HOST: IpAddr = IpAddr::V4(Ipv4Addr::LOCALHOST); /// Default bind port (`8787`). pub const DEFAULT_PORT: u16 = 8787; @@ -20,11 +20,13 @@ pub struct BindAddrResolution { /// Resolve a bind address from optional environment and config values. /// /// Precedence (highest wins): -/// 1. `env_host` / `env_port` (typically `EDGEZERO_HOST` / `EDGEZERO_PORT`) +/// 1. `env_host` / `env_port` (typically `EDGEZERO__ADAPTER__HOST` / `EDGEZERO__ADAPTER__PORT`) /// 2. `config_host` / `config_port` (from manifest or adapter config) /// 3. Defaults: `127.0.0.1:8787` /// /// Invalid values produce warnings and fall back to the next precedence level. +#[inline] +#[must_use] pub fn resolve_bind_addr( env_host: Option<&str>, env_port: Option<&str>, @@ -50,7 +52,7 @@ fn resolve_host( match value.parse() { Ok(host) => return host, Err(_) => warnings.push(format!( - "EDGEZERO_HOST={value:?} is not a valid IP address (hostnames like \"localhost\" are not supported); falling back" + "EDGEZERO__ADAPTER__HOST={value:?} is not a valid IP address (hostnames like \"localhost\" are not supported); falling back" )), } } @@ -75,23 +77,23 @@ fn resolve_port( if let Some(value) = env_port { match value.parse::() { Ok(0) => warnings.push( - "EDGEZERO_PORT=\"0\" is not supported (would bind to a random OS port); falling back" - .to_string(), + "EDGEZERO__ADAPTER__PORT=\"0\" is not supported (would bind to a random OS port); falling back" + .to_owned(), ), Ok(port) => return port, Err(_) => warnings.push(format!( - "EDGEZERO_PORT={value:?} is not a valid port number; falling back" + "EDGEZERO__ADAPTER__PORT={value:?} is not a valid port number; falling back" )), } } - if let Some(0) = config_port { - warnings.push( + match config_port { + Some(0) => warnings.push( "configured port=0 is not supported (would bind to a random OS port); falling back" - .to_string(), - ); - } else if let Some(port) = config_port { - return port; + .to_owned(), + ), + Some(port) => return port, + None => {} } DEFAULT_PORT @@ -112,7 +114,7 @@ mod tests { #[test] fn config_overrides_defaults() { let resolution = resolve_bind_addr(None, None, Some("0.0.0.0"), Some(3000)); - assert_eq!(resolution.addr.ip(), IpAddr::V4(Ipv4Addr::new(0, 0, 0, 0))); + assert_eq!(resolution.addr.ip(), IpAddr::V4(Ipv4Addr::UNSPECIFIED)); assert_eq!(resolution.addr.port(), 3000); assert!(resolution.warnings.is_empty()); } @@ -121,7 +123,7 @@ mod tests { fn env_overrides_config() { let resolution = resolve_bind_addr(Some("0.0.0.0"), Some("4000"), Some("127.0.0.1"), Some(3000)); - assert_eq!(resolution.addr.ip(), IpAddr::V4(Ipv4Addr::new(0, 0, 0, 0))); + assert_eq!(resolution.addr.ip(), IpAddr::V4(Ipv4Addr::UNSPECIFIED)); assert_eq!(resolution.addr.port(), 4000); assert!(resolution.warnings.is_empty()); } @@ -129,23 +131,23 @@ mod tests { #[test] fn partial_env_override_host_only() { let resolution = resolve_bind_addr(Some("0.0.0.0"), None, None, Some(5000)); - assert_eq!(resolution.addr.ip(), IpAddr::V4(Ipv4Addr::new(0, 0, 0, 0))); + assert_eq!(resolution.addr.ip(), IpAddr::V4(Ipv4Addr::UNSPECIFIED)); assert_eq!(resolution.addr.port(), 5000); } #[test] fn partial_env_override_port_only() { let resolution = resolve_bind_addr(None, Some("9000"), Some("0.0.0.0"), None); - assert_eq!(resolution.addr.ip(), IpAddr::V4(Ipv4Addr::new(0, 0, 0, 0))); + assert_eq!(resolution.addr.ip(), IpAddr::V4(Ipv4Addr::UNSPECIFIED)); assert_eq!(resolution.addr.port(), 9000); } #[test] fn invalid_env_host_falls_back_to_config() { let resolution = resolve_bind_addr(Some("not-an-ip"), None, Some("0.0.0.0"), None); - assert_eq!(resolution.addr.ip(), IpAddr::V4(Ipv4Addr::new(0, 0, 0, 0))); + assert_eq!(resolution.addr.ip(), IpAddr::V4(Ipv4Addr::UNSPECIFIED)); assert_eq!(resolution.warnings.len(), 1); - assert!(resolution.warnings[0].contains("EDGEZERO_HOST")); + assert!(resolution.warnings[0].contains("EDGEZERO__ADAPTER__HOST")); assert!(resolution.warnings[0].contains("not a valid IP address")); } @@ -154,7 +156,7 @@ mod tests { let resolution = resolve_bind_addr(None, Some("abc"), None, Some(3000)); assert_eq!(resolution.addr.port(), 3000); assert_eq!(resolution.warnings.len(), 1); - assert!(resolution.warnings[0].contains("EDGEZERO_PORT")); + assert!(resolution.warnings[0].contains("EDGEZERO__ADAPTER__PORT")); assert!(resolution.warnings[0].contains("not a valid port number")); } diff --git a/crates/edgezero-core/src/app.rs b/crates/edgezero-core/src/app.rs index 0be7d724..462e0fd9 100644 --- a/crates/edgezero-core/src/app.rs +++ b/crates/edgezero-core/src/app.rs @@ -1,95 +1,59 @@ use crate::router::RouterService; -const DEFAULT_APP_NAME: &str = "EdgeZero App"; - /// Canonical adapter name for the Axum adapter. pub const AXUM_ADAPTER: &str = "axum"; /// Canonical adapter name for the Cloudflare adapter. pub const CLOUDFLARE_ADAPTER: &str = "cloudflare"; +const DEFAULT_APP_NAME: &str = "EdgeZero App"; /// Canonical adapter name for the Fastly adapter. pub const FASTLY_ADAPTER: &str = "fastly"; /// Canonical adapter name for the Spin adapter. pub const SPIN_ADAPTER: &str = "spin"; -/// Adapter-specific config-store override metadata generated from `[stores.config.adapters.*]`. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct ConfigStoreAdapterMetadata { - adapter: &'static str, - name: &'static str, -} - -impl ConfigStoreAdapterMetadata { - pub const fn new(adapter: &'static str, name: &'static str) -> Self { - Self { adapter, name } - } - - pub fn adapter(&self) -> &'static str { - self.adapter - } - - pub fn name(&self) -> &'static str { - self.name - } -} - -/// Provider-neutral config-store metadata generated from `[stores.config]`. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct ConfigStoreMetadata { - default_name: &'static str, - adapters: &'static [ConfigStoreAdapterMetadata], +/// Lightweight container around a `RouterService` that can be extended via hook implementations. +pub struct App { + name: String, + router: RouterService, } -impl ConfigStoreMetadata { - pub const fn new( - default_name: &'static str, - adapters: &'static [ConfigStoreAdapterMetadata], - ) -> Self { - Self { - default_name, - adapters, - } - } - - pub fn default_name(&self) -> &'static str { - self.default_name +impl App { + /// Default name used when none is provided. + #[must_use] + #[inline] + pub fn default_name() -> &'static str { + DEFAULT_APP_NAME } - pub fn adapters(&self) -> &'static [ConfigStoreAdapterMetadata] { - self.adapters + /// Consume the app and return the contained router service. + #[must_use] + #[inline] + pub fn into_router(self) -> RouterService { + self.router } - pub fn name_for_adapter(&self, adapter: &str) -> &'static str { - self.adapters - .iter() - .find(|entry| entry.adapter.eq_ignore_ascii_case(adapter)) - .map(|entry| entry.name) - .unwrap_or(self.default_name) + /// Name assigned to the application. + #[must_use] + #[inline] + pub fn name(&self) -> &str { + &self.name } -} -/// Lightweight container around a `RouterService` that can be extended via hook implementations. -pub struct App { - router: RouterService, - name: String, -} - -impl App { /// Create a new application wrapper from the supplied router service. + #[must_use] + #[inline] pub fn new(router: RouterService) -> Self { Self::with_name(router, DEFAULT_APP_NAME) } /// Access the underlying router service. + #[must_use] + #[inline] pub fn router(&self) -> &RouterService { &self.router } - /// Name assigned to the application. - pub fn name(&self) -> &str { - &self.name - } - /// Update the application name. + #[inline] pub fn set_name(&mut self, name: S) where S: Into, @@ -97,12 +61,8 @@ impl App { self.name = name.into(); } - /// Consume the app and return the contained router service. - pub fn into_router(self) -> RouterService { - self.router - } - /// Construct a new application with the provided router and name. + #[inline] pub fn with_name(router: RouterService, name: S) -> Self where S: Into, @@ -112,42 +72,73 @@ impl App { name: name.into(), } } +} - /// Default name used when none is provided. - pub fn default_name() -> &'static str { - DEFAULT_APP_NAME - } +/// Compile-time metadata for one logical store kind, baked by the `app!` macro. +/// +/// Carries only the portable facts declared in `[stores.]`: the logical +/// store ids and the resolved default. Platform names are resolved at runtime +/// from `EDGEZERO__STORES__*` environment variables. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct StoreMetadata { + /// Resolved default logical store id. + pub default: &'static str, + /// All declared logical store ids (non-empty). + pub ids: &'static [&'static str], +} + +/// Portable store config baked into the `App` by the `app!` macro. +/// +/// A `Hooks` implementation built without the macro leaves every field `None`, +/// so a downstream binary compiles and runs with no `edgezero.toml` present. +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub struct StoresMetadata { + /// `[stores.config]` declaration, if present. + pub config: Option, + /// `[stores.kv]` declaration, if present. + pub kv: Option, + /// `[stores.secrets]` declaration, if present. + pub secrets: Option, } /// Trait implemented by application hook adapters. pub trait Hooks { + /// Construct an `App` by wiring the routes and invoking the configuration hook. + #[must_use] + #[inline] + fn build_app() -> App + where + Self: Sized, + { + let mut app = App::with_name(Self::routes(), Self::name()); + Self::configure(&mut app); + app + } + /// Allow implementations to mutate the freshly constructed application before use. /// The default implementation performs no changes. + #[inline] fn configure(_app: &mut App) {} - /// Build the router service for the application. - fn routes() -> RouterService; - /// Display name for the application. Defaults to `"EdgeZero App"`. + #[must_use] + #[inline] fn name() -> &'static str { App::default_name() } - /// Structured config-store metadata for the application, if declared. - /// - /// Macro-generated apps derive this from `[stores.config]` in `edgezero.toml`. - fn config_store() -> Option<&'static ConfigStoreMetadata> { - None - } + /// Build the router service for the application. + fn routes() -> RouterService; - /// Construct an `App` by wiring the routes and invoking the configuration hook. - fn build_app() -> App - where - Self: Sized, - { - let mut app = App::with_name(Self::routes(), Self::name()); - Self::configure(&mut app); - app + /// Portable store metadata for the application. + /// + /// Macro-generated apps derive this from `[stores.*]` in `edgezero.toml`. + /// The default is empty, so an `App` built without the `app!` macro — and a + /// downstream binary built without an `edgezero.toml` — still compiles. + #[must_use] + #[inline] + fn stores() -> StoresMetadata { + StoresMetadata::default() } } @@ -159,27 +150,39 @@ mod tests { use crate::error::EdgeError; use crate::http::{request_builder, Method, StatusCode}; use futures::executor::block_on; - use tower_service::Service; + use tower_service::Service as _; - fn empty_router() -> RouterService { - RouterService::builder().build() - } - - #[test] - fn default_app_uses_constant_name() { - let app = App::new(empty_router()); - assert_eq!(app.name(), App::default_name()); - } + struct DefaultHooks; struct TestHooks; - impl Hooks for TestHooks { + impl Hooks for DefaultHooks { + fn build_app() -> App { + let mut app = App::with_name(Self::routes(), Self::name()); + Self::configure(&mut app); + app + } + + fn configure(_app: &mut App) {} + + fn name() -> &'static str { + App::default_name() + } + fn routes() -> RouterService { - async fn handler(_ctx: RequestContext) -> Result { - Ok("ok".to_string()) - } + RouterService::builder().build() + } - RouterService::builder().get("/test", handler).build() + fn stores() -> StoresMetadata { + StoresMetadata::default() + } + } + + impl Hooks for TestHooks { + fn build_app() -> App { + let mut app = App::with_name(Self::routes(), Self::name()); + Self::configure(&mut app); + app } fn configure(app: &mut App) { @@ -190,28 +193,45 @@ mod tests { "hooks-name" } - fn config_store() -> Option<&'static ConfigStoreMetadata> { - static CONFIG_STORE: ConfigStoreMetadata = ConfigStoreMetadata::new( - "default-config", - &[ConfigStoreAdapterMetadata::new( - CLOUDFLARE_ADAPTER, - "cf-config", - )], - ); - Some(&CONFIG_STORE) + fn routes() -> RouterService { + async fn handler(_ctx: RequestContext) -> Result { + Ok("ok".to_owned()) + } + + RouterService::builder().get("/test", handler).build() + } + + fn stores() -> StoresMetadata { + StoresMetadata { + config: Some(StoreMetadata { + default: "app_config", + ids: &["app_config"], + }), + kv: Some(StoreMetadata { + default: "sessions", + ids: &["sessions", "cache"], + }), + secrets: None, + } } } + fn empty_router() -> RouterService { + RouterService::builder().build() + } + #[test] fn build_app_invokes_hooks_for_routes_and_configuration() { let app = TestHooks::build_app(); assert_eq!(app.name(), "configured"); - let config = TestHooks::config_store().expect("config store metadata"); - assert_eq!(config.name_for_adapter(CLOUDFLARE_ADAPTER), "cf-config"); - assert_eq!(config.name_for_adapter("CLOUDFLARE"), "cf-config"); - assert_eq!(config.name_for_adapter(FASTLY_ADAPTER), "default-config"); - assert_eq!(config.default_name(), "default-config"); - assert_eq!(config.adapters().len(), 1); + let stores = TestHooks::stores(); + let config = stores.config.expect("config store metadata"); + assert_eq!(config.default, "app_config"); + assert_eq!(config.ids, &["app_config"]); + let kv = stores.kv.expect("kv store metadata"); + assert_eq!(kv.default, "sessions"); + assert_eq!(kv.ids, &["sessions", "cache"]); + assert!(stores.secrets.is_none()); let request = request_builder() .method(Method::GET) @@ -221,22 +241,20 @@ mod tests { let response = block_on(app.router().clone().call(request)).expect("response"); assert_eq!(response.status(), StatusCode::OK); - assert_eq!(response.body().as_bytes(), b"ok"); + assert_eq!(response.body().as_bytes().expect("buffered"), b"ok"); } - struct DefaultHooks; - - impl Hooks for DefaultHooks { - fn routes() -> RouterService { - RouterService::builder().build() - } + #[test] + fn default_app_uses_constant_name() { + let app = App::new(empty_router()); + assert_eq!(app.name(), App::default_name()); } #[test] fn default_hooks_use_default_name_and_into_router() { let app = DefaultHooks::build_app(); assert_eq!(app.name(), App::default_name()); - assert_eq!(DefaultHooks::config_store(), None); + assert_eq!(DefaultHooks::stores(), StoresMetadata::default()); let router = app.into_router(); assert!(router.routes().is_empty()); } diff --git a/crates/edgezero-core/src/app_config.rs b/crates/edgezero-core/src/app_config.rs new file mode 100644 index 00000000..1a08ccbc --- /dev/null +++ b/crates/edgezero-core/src/app_config.rs @@ -0,0 +1,734 @@ +//! Typed app-config loading. +//! +//! Loader for downstream `.toml` files (e.g. `app-demo.toml`). +//! Reads the file's top-level table verbatim — there is no `[config]` +//! wrapper — optionally applies the `__
__…` +//! env-var overlay, and either: +//! +//! - Deserialises into a downstream `C: DeserializeOwned + Validate` +//! and runs `validator::Validate::validate()` — +//! [`load_app_config`] / [`load_app_config_with_options`]. +//! - Returns the parsed root table as raw `toml::Value` for tools +//! that don't have access to the typed struct (the raw `config +//! push` flow) — [`load_app_config_raw`] / +//! [`load_app_config_raw_with_options`]. + +use std::any; +use std::collections::HashMap; +use std::env; +use std::fs; +use std::io; +use std::path::{Path, PathBuf}; + +use serde::de::DeserializeOwned; +use thiserror::Error; +use toml::de::Error as TomlDeError; +use toml::value::Datetime; +use toml::Value; +use validator::{Validate, ValidationErrors}; + +/// Per-field metadata emitted by `#[derive(AppConfig)]`. The +/// derive enumerates every field annotated with `#[secret]` / +/// `#[secret(store_ref)]`; `config validate` and `config push` +/// reflect over this array to gate secret-aware behaviour. +pub trait AppConfigMeta { + /// Every `#[secret]` / `#[secret(store_ref)]` field on the struct. + const SECRET_FIELDS: &'static [SecretField]; +} + +/// One field's worth of secret-annotation metadata. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct SecretField { + /// Whether the field's value is a key in the default secret store + /// or the logical id of a `[stores.secrets]` entry. + pub kind: SecretKind, + /// Rust field name verbatim (no `serde(rename)` translation — + /// `#[secret]` rejects renames at compile time). + pub name: &'static str, +} + +/// Discriminator on a [`SecretField`] capturing which secret-store +/// resolution the field participates in. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum SecretKind { + /// `#[secret]` — the field's value is a key in the resolved + /// default secret store. + KeyInDefault, + /// `#[secret(store_ref)]` — the field's value is the logical id + /// of a `[stores.secrets]` declaration. + StoreRef, +} + +/// Options for the app-config loader. +/// +/// Constructed with `Default::default()` (overlay on) by the simple +/// loader functions; `--no-env` on the CLI flips `env_overlay` to +/// `false`. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[non_exhaustive] +pub struct AppConfigLoadOptions { + /// When `true`, apply the `__…__` env-var overlay + /// after parsing the file's root table; when `false`, the parsed + /// values are used as-is. + pub env_overlay: bool, +} + +impl Default for AppConfigLoadOptions { + #[inline] + fn default() -> Self { + Self { env_overlay: true } + } +} + +/// Errors returned by the app-config loader. +/// +/// The TOML errors are boxed because `toml::de::Error` is large and a +/// fat `Err` variant would inflate every `Result` on the loader's +/// hot path (`clippy::result_large_err`). +#[derive(Debug, Error)] +#[non_exhaustive] +pub enum AppConfigError { + /// Deserialising the file's top-level table into the typed `C` + /// failed — missing required fields, wrong types, unknown fields + /// (when the struct opts in to `#[serde(deny_unknown_fields)]`), + /// etc. + #[error("failed to deserialise {} into {target_type}: {source}", path.display())] + Deserialize { + path: PathBuf, + target_type: &'static str, + #[source] + source: Box, + }, + /// The env-overlay step failed — ambiguous sibling-key + /// mapping, value not parseable against the existing TOML type, + /// etc. + #[error("env overlay failed for {}: {message}", path.display())] + EnvOverlay { path: PathBuf, message: String }, + /// Failed to read the on-disk file (missing, permission denied, + /// etc.). + #[error("failed to read {}: {source}", path.display())] + Io { + path: PathBuf, + #[source] + source: io::Error, + }, + /// The file exists but is not valid TOML. + #[error("failed to parse {} as TOML: {source}", path.display())] + Parse { + path: PathBuf, + #[source] + source: Box, + }, + /// `validator::Validate::validate()` rejected the parsed values + /// (range / length / regex / custom validators). + #[error("validation failed for {}: {source}", path.display())] + Validation { + path: PathBuf, + #[source] + source: Box, + }, +} + +/// Env-var lookup abstracted over the process env so tests can stub +/// it without manipulating `std::env`. +struct EnvLookup { + vars: HashMap, +} + +impl EnvLookup { + #[cfg(test)] + fn from_pairs(pairs: I) -> Self + where + I: IntoIterator, + K: Into, + V: Into, + { + Self { + vars: pairs + .into_iter() + .map(|(key, val)| (key.into(), val.into())) + .collect(), + } + } + + fn from_process_env() -> Self { + Self { + vars: env::vars().collect(), + } + } + + fn get(&self, key: &str) -> Option<&str> { + self.vars.get(key).map(String::as_str) + } +} + +/// Load and validate a typed app-config from `.toml`. +/// +/// `env_overlay` is on by default; pass [`AppConfigLoadOptions`] +/// explicitly via [`load_app_config_with_options`] to disable it. +/// +/// `app_name` is `[app].name` (uppercased + `-`→`_`) used as the env-var +/// prefix when the overlay is on. It is accepted (not derived from the +/// file) so the loader is decoupled from manifest discovery — callers +/// (`config validate`, `config push`, the axum demo server) already have +/// it. +/// +/// # Errors +/// See [`AppConfigError`]. +#[inline] +pub fn load_app_config(path: &Path, app_name: &str) -> Result +where + C: DeserializeOwned + Validate + AppConfigMeta, +{ + load_app_config_with_options(path, app_name, &AppConfigLoadOptions::default()) +} + +/// [`load_app_config`] with an explicit [`AppConfigLoadOptions`]. +/// +/// # Errors +/// See [`AppConfigError`]. +#[inline] +pub fn load_app_config_with_options( + path: &Path, + app_name: &str, + opts: &AppConfigLoadOptions, +) -> Result +where + C: DeserializeOwned + Validate + AppConfigMeta, +{ + let config_table = load_app_config_raw_with_options(path, app_name, opts)?; + let typed: C = + config_table + .try_into() + .map_err(|source: TomlDeError| AppConfigError::Deserialize { + path: path.to_path_buf(), + target_type: any::type_name::(), + source: Box::new(source), + })?; + typed + .validate() + .map_err(|source| AppConfigError::Validation { + path: path.to_path_buf(), + source: Box::new(source), + })?; + Ok(typed) +} + +/// Read the file's root table as a raw `toml::Value`, with the env +/// overlay applied (when on). Used by `config push` and +/// other tools that don't have access to the typed struct. +/// +/// # Errors +/// See [`AppConfigError`]. +#[inline] +pub fn load_app_config_raw(path: &Path, app_name: &str) -> Result { + load_app_config_raw_with_options(path, app_name, &AppConfigLoadOptions::default()) +} + +/// [`load_app_config_raw`] with an explicit [`AppConfigLoadOptions`]. +/// +/// # Errors +/// See [`AppConfigError`]. +#[inline] +pub fn load_app_config_raw_with_options( + path: &Path, + app_name: &str, + opts: &AppConfigLoadOptions, +) -> Result { + let raw = fs::read_to_string(path).map_err(|source| AppConfigError::Io { + path: path.to_path_buf(), + source, + })?; + let mut document: Value = toml::from_str(&raw).map_err(|source| AppConfigError::Parse { + path: path.to_path_buf(), + source: Box::new(source), + })?; + if opts.env_overlay { + apply_env_overlay(&mut document, app_name, path)?; + } + Ok(document) +} + +/// Apply the `__
__…__` env-var overlay +/// against the parsed root table. +/// +/// The overlay only overrides keys that already exist in the parsed +/// tree (the existing TOML value's type drives coercion of the env +/// string). Two sibling keys mapping to the same env segment is an +/// `AppConfigError::EnvOverlay`; a string that can't be coerced to +/// the existing type is also an `EnvOverlay` error. +fn apply_env_overlay( + config_table: &mut Value, + app_name: &str, + path: &Path, +) -> Result<(), AppConfigError> { + let prefix = app_name_prefix(app_name); + let lookup = EnvLookup::from_process_env(); + walk_and_overlay(config_table, &prefix, &lookup, path) +} + +/// Normalise an app name to the env-var prefix (`` form +/// from): uppercase, `-`→`_`. A single leading `_` from a +/// project name that starts with a digit is preserved. +/// +/// Exposed as `pub` so the scaffold generator can mirror this rule +/// exactly when emitting `{{EnvPrefix}}__...` documentation -- if +/// the two derivations drift, operators see env-var spellings the +/// runtime silently ignores. +#[must_use] +#[inline] +pub fn app_name_prefix(app_name: &str) -> String { + app_name.to_ascii_uppercase().replace('-', "_") +} + +/// Parse `raw` (env string) into the same `toml::Value` variant as +/// `existing`. Parse failure → `AppConfigError::EnvOverlay`. +fn coerce_env_value( + existing: &Value, + raw: &str, + env_var: &str, + path: &Path, +) -> Result { + let coerced = match existing { + Value::String(_) => Value::String(raw.to_owned()), + Value::Integer(_) => raw + .parse::() + .map(Value::Integer) + .map_err(|err| coercion_error(env_var, raw, "integer", &err.to_string(), path))?, + Value::Float(_) => raw + .parse::() + .map(Value::Float) + .map_err(|err| coercion_error(env_var, raw, "float", &err.to_string(), path))?, + Value::Boolean(_) => match raw { + "true" | "1" => Value::Boolean(true), + "false" | "0" => Value::Boolean(false), + other => { + return Err(coercion_error( + env_var, + other, + "boolean (true/false/1/0)", + "expected true/false/1/0", + path, + )); + } + }, + Value::Datetime(_) => raw + .parse::() + .map(Value::Datetime) + .map_err(|err| coercion_error(env_var, raw, "datetime", &err.to_string(), path))?, + Value::Array(_) | Value::Table(_) => { + return Err(AppConfigError::EnvOverlay { + path: path.to_path_buf(), + message: format!( + "env var `{env_var}` cannot override array / table values — \ + env overlay supports scalar leaves only" + ), + }); + } + }; + Ok(coerced) +} + +fn coercion_error( + env_var: &str, + raw: &str, + target: &str, + detail: &str, + path: &Path, +) -> AppConfigError { + AppConfigError::EnvOverlay { + path: path.to_path_buf(), + message: format!("env var `{env_var}={raw}` cannot be coerced to {target}: {detail}"), + } +} + +/// Translate a config field name into its env-segment form: +/// uppercase, `_` left as-is. Sibling keys that produce the same +/// segment are rejected by the caller as ambiguous. +fn env_segment(field_name: &str) -> String { + field_name.to_ascii_uppercase() +} + +fn walk_and_overlay( + node: &mut Value, + env_prefix: &str, + lookup: &EnvLookup, + path: &Path, +) -> Result<(), AppConfigError> { + let Value::Table(table) = node else { + return Ok(()); + }; + + // Detect ambiguous sibling-key mappings before applying any + // overlay so a failure leaves the table untouched. + let mut segment_owners: HashMap = HashMap::new(); + for key in table.keys() { + let segment = env_segment(key); + if let Some(prior) = segment_owners.insert(segment.clone(), key.clone()) { + return Err(AppConfigError::EnvOverlay { + path: path.to_path_buf(), + message: format!( + "sibling config keys `{prior}` and `{key}` both map to env segment \ + `{segment}` under prefix `{env_prefix}__…`; rename one to disambiguate" + ), + }); + } + } + + // Iterate over a snapshot of the keys so we can mutate `table` + // inside the loop without borrowing it twice. + let snapshot: Vec = table.keys().cloned().collect(); + for key in snapshot { + let segment = env_segment(&key); + let next_prefix = format!("{env_prefix}__{segment}"); + let Some(value) = table.get_mut(&key) else { + continue; + }; + match value { + Value::Table(_) => walk_and_overlay(value, &next_prefix, lookup, path)?, + Value::String(_) + | Value::Integer(_) + | Value::Float(_) + | Value::Boolean(_) + | Value::Datetime(_) + | Value::Array(_) => { + if let Some(raw) = lookup.get(&next_prefix) { + *value = coerce_env_value(value, raw, &next_prefix, path)?; + } + } + } + } + Ok(()) +} + +#[cfg(test)] +#[expect( + clippy::default_numeric_fallback, + clippy::wildcard_enum_match_arm, + reason = "test fixtures: `validator` range bounds default to the field's int type; \ + match arms in `expect_err` assertions intentionally collapse all unexpected \ + variants into a single panic" +)] +mod tests { + use super::*; + use serde::Deserialize; + use std::io::Write as _; + use tempfile::NamedTempFile; + + // `AppConfigMeta` is hand-impl'd here rather than derived: the + // `#[derive(AppConfig)]` proc macro emits absolute paths + // (`::edgezero_core::…`) that don't resolve inside the defining + // crate's own modules. The downstream integration test in + // `edgezero-macros/tests/app_config_derive.rs` exercises the derive + // itself; this fixture only needs the trait bound to satisfy + // `load_app_config`. + #[derive(Debug, Deserialize, Validate, PartialEq)] + #[serde(deny_unknown_fields)] + struct FixtureConfig { + greeting: String, + #[validate(range(min = 100, max = 60_000))] + timeout_ms: u32, + } + + impl AppConfigMeta for FixtureConfig { + const SECRET_FIELDS: &'static [SecretField] = &[]; + } + + fn write_fixture(contents: &str) -> NamedTempFile { + let mut file = NamedTempFile::new().expect("tempfile"); + file.write_all(contents.as_bytes()).expect("write"); + file + } + + #[test] + fn load_app_config_round_trips_a_valid_file() { + let file = write_fixture( + r#" +greeting = "hello" +timeout_ms = 1500 +"#, + ); + let cfg: FixtureConfig = load_app_config(file.path(), "fixture").expect("load"); + assert_eq!( + cfg, + FixtureConfig { + greeting: "hello".to_owned(), + timeout_ms: 1500, + } + ); + } + + #[test] + fn load_app_config_errors_with_io_variant_for_missing_file() { + let path = PathBuf::from("/definitely/not/a/real/path/app.toml"); + let err = load_app_config::(&path, "fixture") + .expect_err("missing file must error"); + assert!( + matches!(err, AppConfigError::Io { .. }), + "expected Io variant, got {err:?}" + ); + } + + #[test] + fn load_app_config_errors_with_parse_variant_for_bad_toml() { + let file = write_fixture("{not toml"); + let err = load_app_config::(file.path(), "fixture") + .expect_err("bad TOML must error"); + assert!( + matches!(err, AppConfigError::Parse { .. }), + "expected Parse variant, got {err:?}" + ); + } + + #[test] + fn load_app_config_errors_with_deserialize_variant_for_unknown_fields() { + let file = write_fixture( + r#" +greeting = "hello" +timeout_ms = 1500 +extra_unknown = "rejected by deny_unknown_fields" +"#, + ); + let err = load_app_config::(file.path(), "fixture") + .expect_err("unknown field must error"); + assert!( + matches!(err, AppConfigError::Deserialize { .. }), + "expected Deserialize variant, got {err:?}" + ); + } + + #[test] + fn load_app_config_errors_with_validation_variant() { + // `timeout_ms = 99` violates `range(min = 100, ..)`. + let file = write_fixture( + r#" +greeting = "hello" +timeout_ms = 99 +"#, + ); + let err = load_app_config::(file.path(), "fixture") + .expect_err("validation must error"); + assert!( + matches!(err, AppConfigError::Validation { .. }), + "expected Validation variant, got {err:?}" + ); + } + + #[test] + fn load_app_config_raw_returns_the_root_table() { + let file = write_fixture( + r#" +greeting = "hello" + +[service] +timeout_ms = 1500 +"#, + ); + let raw = load_app_config_raw(file.path(), "fixture").expect("load raw"); + let table = raw.as_table().expect("raw value is a table"); + assert_eq!(table.get("greeting").and_then(Value::as_str), Some("hello"),); + assert!( + table.get("service").and_then(Value::as_table).is_some(), + "nested [service] survives raw load" + ); + } + + #[test] + fn default_load_options_have_env_overlay_on() { + assert_eq!( + AppConfigLoadOptions::default(), + AppConfigLoadOptions { env_overlay: true } + ); + } + + // -- Env overlay ------------------------------------------------ + + fn parse_root_table(contents: &str) -> Value { + toml::from_str(contents).expect("parse fixture") + } + + fn overlay_with_lookup( + config_table: &mut Value, + app_name: &str, + pairs: &[(&str, &str)], + ) -> Result<(), AppConfigError> { + let lookup = EnvLookup::from_pairs(pairs.iter().copied()); + let prefix = app_name_prefix(app_name); + walk_and_overlay(config_table, &prefix, &lookup, Path::new("fixture.toml")) + } + + #[test] + fn env_overlay_overrides_top_level_string() { + let mut table = parse_root_table( + r#" +greeting = "hello" +"#, + ); + overlay_with_lookup(&mut table, "app-demo", &[("APP_DEMO__GREETING", "hola")]) + .expect("overlay"); + assert_eq!(table.get("greeting").and_then(Value::as_str), Some("hola")); + } + + #[test] + fn env_overlay_overrides_nested_integer_with_coercion() { + let mut table = parse_root_table( + " +[service] +timeout_ms = 1500 +", + ); + overlay_with_lookup( + &mut table, + "app-demo", + &[("APP_DEMO__SERVICE__TIMEOUT_MS", "3000")], + ) + .expect("overlay"); + assert_eq!( + table + .get("service") + .and_then(Value::as_table) + .and_then(|service| service.get("timeout_ms")) + .and_then(Value::as_integer), + Some(3000) + ); + } + + #[test] + fn env_overlay_coerces_boolean_from_true_false_or_numeric() { + for (raw, expected) in [("true", true), ("false", false), ("1", true), ("0", false)] { + let mut table = parse_root_table( + " +feature_new_checkout = false +", + ); + overlay_with_lookup( + &mut table, + "app-demo", + &[("APP_DEMO__FEATURE_NEW_CHECKOUT", raw)], + ) + .expect("overlay"); + assert_eq!( + table.get("feature_new_checkout").and_then(Value::as_bool), + Some(expected), + "raw={raw:?}" + ); + } + } + + #[test] + fn env_overlay_errors_when_value_cannot_be_coerced_to_existing_type() { + let mut table = parse_root_table( + " +[service] +timeout_ms = 1500 +", + ); + let err = overlay_with_lookup( + &mut table, + "app-demo", + &[("APP_DEMO__SERVICE__TIMEOUT_MS", "not-a-number")], + ) + .expect_err("non-numeric env value must error"); + match err { + AppConfigError::EnvOverlay { message, .. } => { + assert!( + message.contains("APP_DEMO__SERVICE__TIMEOUT_MS"), + "error names the env var: {message}" + ); + assert!( + message.contains("integer"), + "error names the target type: {message}" + ); + } + other => panic!("expected EnvOverlay variant, got {other:?}"), + } + } + + #[test] + fn env_overlay_rejects_sibling_keys_with_same_env_segment() { + // `greeting_a` and `GREETING_A` would both translate to env + // segment `GREETING_A` (uppercase). Since TOML keys are + // case-sensitive but env segments aren't, we need a guard. + let mut table = parse_root_table( + r#" +greeting_a = "lower" +GREETING_A = "upper" +"#, + ); + let err = overlay_with_lookup(&mut table, "app-demo", &[]) + .expect_err("ambiguous siblings must error"); + match err { + AppConfigError::EnvOverlay { message, .. } => { + assert!( + message.contains("GREETING_A"), + "names env segment: {message}" + ); + assert!( + message.contains("rename one to disambiguate"), + "explains the remediation: {message}" + ); + } + other => panic!("expected EnvOverlay variant, got {other:?}"), + } + } + + #[test] + fn env_overlay_disabled_skips_walker_entirely() { + // With `env_overlay: false`, even when the env var is set the + // parsed value is returned untouched. Uses a unique app-name + // prefix so the temporary env var can't leak into other + // tests run in parallel (cargo test does not isolate + // process env between threads). + let file = write_fixture( + r#" +greeting = "hello" +timeout_ms = 1500 +"#, + ); + let app_name = "overlay_disabled_test"; + let env_key = "OVERLAY_DISABLED_TEST__GREETING"; + env::set_var(env_key, "should-be-ignored"); + let cfg = load_app_config_with_options::( + file.path(), + app_name, + &AppConfigLoadOptions { env_overlay: false }, + ) + .expect("load"); + env::remove_var(env_key); + assert_eq!(cfg.greeting, "hello", "overlay disabled: file value wins"); + } + + #[test] + fn env_overlay_only_overrides_existing_keys() { + // An env var for a key that is not already present in the + // parsed table is silently ignored (the overlay never adds + // new keys — "env vars override existing keys only"). + let mut table = parse_root_table( + r#" +greeting = "hello" +"#, + ); + overlay_with_lookup( + &mut table, + "app-demo", + &[("APP_DEMO__UNKNOWN_KEY", "ignored")], + ) + .expect("overlay"); + assert!( + table.get("unknown_key").is_none(), + "overlay must not synthesise keys" + ); + assert_eq!( + table.get("greeting").and_then(Value::as_str), + Some("hello"), + "existing key untouched when no env var present" + ); + } + + #[test] + fn app_name_prefix_uppercases_and_translates_dash_to_underscore() { + assert_eq!(app_name_prefix("app-demo"), "APP_DEMO"); + assert_eq!(app_name_prefix("my_app"), "MY_APP"); + assert_eq!(app_name_prefix("a-b-c"), "A_B_C"); + } +} diff --git a/crates/edgezero-core/src/body.rs b/crates/edgezero-core/src/body.rs index f933baeb..33934a28 100644 --- a/crates/edgezero-core/src/body.rs +++ b/crates/edgezero-core/src/body.rs @@ -6,6 +6,8 @@ use futures_util::stream::{LocalBoxStream, Stream, StreamExt}; use serde::de::DeserializeOwned; use serde::Serialize; +use crate::error::EdgeError; + /// Lightweight HTTP body that can either contain a single `Bytes` buffer or a streaming source of /// chunks. The streaming variant is implemented with `LocalBoxStream` so it remains compatible with /// `wasm32` targets that lack thread support. @@ -15,10 +17,24 @@ pub enum Body { } impl Body { + /// Returns the in-memory bytes for a buffered body, or `None` if this is + /// a streaming body. To consume a streaming body into bytes, use + /// [`Body::into_bytes_bounded`]. + #[inline] + pub fn as_bytes(&self) -> Option<&[u8]> { + match self { + Body::Once(bytes) => Some(bytes.as_ref()), + Body::Stream(_) => None, + } + } + + #[must_use] + #[inline] pub fn empty() -> Self { Self::from_bytes(Bytes::new()) } + #[inline] pub fn from_bytes(bytes: B) -> Self where B: Into, @@ -26,6 +42,7 @@ impl Body { Self::Once(bytes.into()) } + #[inline] pub fn from_stream(stream: S) -> Self where S: Stream> + 'static, @@ -38,27 +55,47 @@ impl Body { ) } - pub fn stream(stream: S) -> Self - where - S: Stream + 'static, - { - Self::Stream(stream.map(Ok::).boxed_local()) - } - - pub fn as_bytes(&self) -> &[u8] { + /// Consume a buffered body and return its bytes, or `None` if this is a + /// streaming body. To collect a streaming body, use + /// [`Body::into_bytes_bounded`]. + #[inline] + pub fn into_bytes(self) -> Option { match self { - Body::Once(bytes) => bytes.as_ref(), - Body::Stream(_) => panic!("streaming body does not expose in-memory bytes"), + Body::Once(bytes) => Some(bytes), + Body::Stream(_) => None, } } - pub fn into_bytes(self) -> Bytes { + /// Drain the body into a single `Bytes` buffer, enforcing `max_size`. + /// + /// Works for both buffered and streaming variants. + /// + /// # Errors + /// Returns [`EdgeError::bad_request`] if the body exceeds `max_size` bytes; or [`EdgeError::internal`] if the upstream stream errors. + #[inline] + pub async fn into_bytes_bounded(self, max_size: usize) -> Result { match self { - Body::Once(bytes) => bytes, - Body::Stream(_) => panic!("streaming body cannot be converted into bytes"), + Body::Once(bytes) => { + if bytes.len() > max_size { + return Err(EdgeError::bad_request("request body too large")); + } + Ok(bytes) + } + Body::Stream(mut stream) => { + let mut buf = Vec::new(); + while let Some(result) = StreamExt::next(&mut stream).await { + let chunk = result.map_err(EdgeError::internal)?; + buf.extend_from_slice(&chunk); + if buf.len() > max_size { + return Err(EdgeError::bad_request("request body too large")); + } + } + Ok(Bytes::from(buf)) + } } } + #[inline] pub fn into_stream(self) -> Option>> { match self { Body::Once(_) => None, @@ -66,56 +103,40 @@ impl Body { } } + #[inline] pub fn is_stream(&self) -> bool { matches!(self, Body::Stream(_)) } - /// Drain the body into a single `Bytes` buffer, enforcing `max_size`. - /// - /// Works for both buffered and streaming variants. Returns an error if - /// the body exceeds `max_size` bytes. - pub async fn into_bytes_bounded( - self, - max_size: usize, - ) -> Result { - if self.is_stream() { - let mut stream = self.into_stream().expect("checked is_stream"); - let mut buf = Vec::new(); - while let Some(chunk) = StreamExt::next(&mut stream).await { - let chunk = chunk.map_err(crate::error::EdgeError::internal)?; - buf.extend_from_slice(&chunk); - if buf.len() > max_size { - return Err(crate::error::EdgeError::bad_request( - "request body too large", - )); - } - } - Ok(Bytes::from(buf)) - } else { - let bytes = self.into_bytes(); - if bytes.len() > max_size { - return Err(crate::error::EdgeError::bad_request( - "request body too large", - )); - } - Ok(bytes) - } + /// # Errors + /// Returns the underlying [`serde_json::Error`] if `value` cannot be serialized. + #[inline] + pub fn json(value: &T) -> Result + where + T: Serialize, + { + serde_json::to_vec(value).map(Self::from_bytes) } - pub fn text(text: S) -> Self + #[inline] + pub fn stream(stream: S) -> Self where - S: Into, + S: Stream + 'static, { - Self::from_bytes(text.into().into_bytes()) + Self::Stream(stream.map(Ok::).boxed_local()) } - pub fn json(value: &T) -> Result + #[inline] + pub fn text(text: S) -> Self where - T: Serialize, + S: Into, { - serde_json::to_vec(value).map(Self::from_bytes) + Self::from_bytes(text.into().into_bytes()) } + /// # Errors + /// Returns [`serde_json::Error`] if the body is streaming or its bytes are not valid JSON for `T`. + #[inline] pub fn to_json(&self) -> Result where T: DeserializeOwned, @@ -130,12 +151,14 @@ impl Body { } impl Default for Body { + #[inline] fn default() -> Self { Self::empty() } } impl fmt::Debug for Body { + #[inline] fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { Body::Once(bytes) => f @@ -148,24 +171,28 @@ impl fmt::Debug for Body { } impl From> for Body { + #[inline] fn from(value: Vec) -> Self { Body::from_bytes(value) } } impl From<&[u8]> for Body { + #[inline] fn from(value: &[u8]) -> Self { Body::from_bytes(Bytes::copy_from_slice(value)) } } impl From<&str> for Body { + #[inline] fn from(value: &str) -> Self { Body::text(value) } } impl From for Body { + #[inline] fn from(value: String) -> Self { Body::text(value) } @@ -175,12 +202,18 @@ impl From for Body { mod tests { use super::*; use futures::executor::block_on; - use futures_util::StreamExt; + use futures_util::stream; use std::io; + #[test] + fn as_bytes_returns_none_for_stream() { + let body = Body::stream(stream::iter(vec![Bytes::from_static(b"data")])); + assert!(body.as_bytes().is_none()); + } + #[test] fn collect_stream_body() { - let body = Body::stream(futures_util::stream::iter(vec![ + let body = Body::stream(stream::iter(vec![ Bytes::from_static(b"a"), Bytes::from_static(b"b"), ])); @@ -188,8 +221,8 @@ mod tests { let mut stream = body.into_stream().expect("stream"); let collected = block_on(async { let mut data = Vec::new(); - while let Some(chunk) = stream.next().await { - let chunk = chunk.expect("chunk"); + while let Some(result) = stream.next().await { + let chunk = result.expect("chunk"); data.extend_from_slice(&chunk); } data @@ -197,17 +230,34 @@ mod tests { assert_eq!(collected, b"ab"); } + #[test] + fn debug_formats_both_body_variants() { + let buffered = Body::from("payload"); + let buffered_debug = format!("{buffered:?}"); + assert!(buffered_debug.contains("Body::Once")); + + let stream = Body::stream(stream::iter(vec![Bytes::from_static(b"chunk")])); + let stream_debug = format!("{stream:?}"); + assert!(stream_debug.contains("Body::Stream")); + } + + #[test] + fn default_body_is_empty() { + let body = Body::default(); + assert!(body.as_bytes().expect("buffered").is_empty()); + } + #[test] fn from_stream_maps_errors() { - let stream = futures_util::stream::iter(vec![ + let source = stream::iter(vec![ Ok(Bytes::from_static(b"ok")), Err(io::Error::other("boom")), ]); - let body = Body::from_stream(stream); - let mut stream = body.into_stream().expect("stream"); + let body = Body::from_stream(source); + let mut chunks = body.into_stream().expect("stream"); let (first, second) = block_on(async { - let first = stream.next().await.expect("first").expect("ok"); - let second = stream.next().await.expect("second"); + let first = chunks.next().await.expect("first").expect("ok"); + let second = chunks.next().await.expect("second"); (first, second) }); assert_eq!(first, Bytes::from_static(b"ok")); @@ -215,68 +265,10 @@ mod tests { assert!(err.to_string().contains("boom")); } - #[test] - fn to_json_fails_for_streaming_body() { - let body = Body::stream(futures_util::stream::iter(vec![ - Bytes::from_static(b"{"), - Bytes::from_static(b"}"), - ])); - assert!(body.to_json::().is_err()); - } - - #[test] - fn into_bytes_panics_for_stream() { - let body = Body::stream(futures_util::stream::iter(vec![Bytes::from_static( - b"data", - )])); - let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| body.into_bytes())); - assert!(result.is_err()); - } - - #[test] - fn as_bytes_panics_for_stream() { - let body = Body::stream(futures_util::stream::iter(vec![Bytes::from_static( - b"data", - )])); - let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| body.as_bytes())); - assert!(result.is_err()); - } - - #[test] - fn into_stream_returns_none_for_buffered_body() { - let body = Body::from("payload"); - assert!(body.into_stream().is_none()); - } - - #[test] - fn is_stream_returns_false_for_buffered_body() { - let body = Body::from("payload"); - assert!(!body.is_stream()); - } - - #[test] - fn default_body_is_empty() { - let body = Body::default(); - assert!(body.as_bytes().is_empty()); - } - - #[test] - fn debug_formats_both_body_variants() { - let buffered = Body::from("payload"); - let buffered_debug = format!("{:?}", buffered); - assert!(buffered_debug.contains("Body::Once")); - - let stream = Body::stream(futures_util::stream::iter(vec![Bytes::from_static( - b"chunk", - )])); - let stream_debug = format!("{:?}", stream); - assert!(stream_debug.contains("Body::Stream")); - } - #[test] fn from_vec_u8_builds_buffered_body() { - let body = Body::from(vec![1u8, 2u8, 3u8]); - assert_eq!(body.as_bytes(), &[1u8, 2u8, 3u8]); + let body = Body::from(vec![1_u8, 2_u8, 3_u8]); + assert_eq!(body.as_bytes().expect("buffered"), &[1_u8, 2_u8, 3_u8]); } #[test] @@ -289,13 +281,12 @@ mod tests { #[test] fn into_bytes_bounded_buffered_too_large() { let body = Body::from("hello"); - let result = block_on(body.into_bytes_bounded(3)); - assert!(result.is_err()); + block_on(body.into_bytes_bounded(3)).expect_err("body exceeds max_size"); } #[test] fn into_bytes_bounded_stream_ok() { - let body = Body::stream(futures_util::stream::iter(vec![ + let body = Body::stream(stream::iter(vec![ Bytes::from_static(b"ab"), Bytes::from_static(b"cd"), ])); @@ -305,11 +296,38 @@ mod tests { #[test] fn into_bytes_bounded_stream_too_large() { - let body = Body::stream(futures_util::stream::iter(vec![ + let body = Body::stream(stream::iter(vec![ Bytes::from_static(b"ab"), Bytes::from_static(b"cd"), ])); - let result = block_on(body.into_bytes_bounded(3)); - assert!(result.is_err()); + block_on(body.into_bytes_bounded(3)).expect_err("stream exceeds max_size"); + } + + #[test] + fn into_bytes_returns_none_for_stream() { + let body = Body::stream(stream::iter(vec![Bytes::from_static(b"data")])); + assert!(body.into_bytes().is_none()); + } + + #[test] + fn into_stream_returns_none_for_buffered_body() { + let body = Body::from("payload"); + assert!(body.into_stream().is_none()); + } + + #[test] + fn is_stream_returns_false_for_buffered_body() { + let body = Body::from("payload"); + assert!(!body.is_stream()); + } + + #[test] + fn to_json_fails_for_streaming_body() { + let body = Body::stream(stream::iter(vec![ + Bytes::from_static(b"{"), + Bytes::from_static(b"}"), + ])); + body.to_json::() + .expect_err("streaming body cannot deserialize as JSON"); } } diff --git a/crates/edgezero-core/src/compression.rs b/crates/edgezero-core/src/compression.rs index e2f882b9..ee4bf1a9 100644 --- a/crates/edgezero-core/src/compression.rs +++ b/crates/edgezero-core/src/compression.rs @@ -3,14 +3,15 @@ use std::io; use async_compression::futures::bufread::{BrotliDecoder, GzipDecoder}; use async_stream::try_stream; use bytes::Bytes; -use futures::io::{AsyncReadExt, BufReader}; +use futures::io::{AsyncReadExt as _, BufReader}; use futures::stream::Stream; use futures::TryStream; -use futures_util::TryStreamExt; +use futures_util::TryStreamExt as _; const BUFFER_SIZE: usize = 8 * 1024; /// Decode a stream of gzip-compressed chunks into plain bytes. +#[inline] pub fn decode_gzip_stream(stream: S) -> impl Stream> where S: TryStream, Error = io::Error> + Unpin, @@ -18,20 +19,25 @@ where try_stream! { let reader = BufReader::new(stream.into_async_read()); let mut decoder = GzipDecoder::new(reader); - let mut buffer = vec![0u8; BUFFER_SIZE]; + let mut buffer = vec![0_u8; BUFFER_SIZE]; loop { let read = decoder.read(&mut buffer).await?; if read == 0 { break; } - - yield Bytes::copy_from_slice(&buffer[..read]); + let chunk = buffer.get(..read).ok_or_else(|| { + io::Error::other(format!( + "decoder reported {read}-byte read into a {BUFFER_SIZE}-byte buffer" + )) + })?; + yield Bytes::copy_from_slice(chunk); } } } /// Decode a stream of brotli-compressed chunks into plain bytes. +#[inline] pub fn decode_brotli_stream(stream: S) -> impl Stream> where S: TryStream, Error = io::Error> + Unpin, @@ -39,15 +45,19 @@ where try_stream! { let reader = BufReader::new(stream.into_async_read()); let mut decoder = BrotliDecoder::new(reader); - let mut buffer = vec![0u8; BUFFER_SIZE]; + let mut buffer = vec![0_u8; BUFFER_SIZE]; loop { let read = decoder.read(&mut buffer).await?; if read == 0 { break; } - - yield Bytes::copy_from_slice(&buffer[..read]); + let chunk = buffer.get(..read).ok_or_else(|| { + io::Error::other(format!( + "decoder reported {read}-byte read into a {BUFFER_SIZE}-byte buffer" + )) + })?; + yield Bytes::copy_from_slice(chunk); } } } @@ -58,8 +68,8 @@ mod tests { use brotli::CompressorWriter; use flate2::{write::GzEncoder, Compression}; use futures::executor::block_on; - use futures_util::{stream, TryStreamExt}; - use std::io::Write; + use futures_util::stream; + use std::io::Write as _; #[test] fn decode_gzip_stream_yields_plain_bytes() { @@ -82,10 +92,9 @@ mod tests { #[test] fn decode_brotli_stream_yields_plain_bytes() { let mut brotli_bytes = Vec::new(); - { - let mut compressor = CompressorWriter::new(&mut brotli_bytes, 4096, 5, 21); - compressor.write_all(b"hello brotli").unwrap(); - } + let mut compressor = CompressorWriter::new(&mut brotli_bytes, 4096, 5, 21); + compressor.write_all(b"hello brotli").unwrap(); + drop(compressor); let stream = stream::iter(vec![Ok::, io::Error>(brotli_bytes)]); let decoded = block_on(async { diff --git a/crates/edgezero-core/src/config_store.rs b/crates/edgezero-core/src/config_store.rs index 696dfc47..67086233 100644 --- a/crates/edgezero-core/src/config_store.rs +++ b/crates/edgezero-core/src/config_store.rs @@ -1,100 +1,16 @@ //! Provider-neutral read-only configuration store abstraction. //! -//! All platforms expose config reads as synchronous operations, so no -//! `async_trait` is needed here. +//! `ConfigStore::get` is `async` because the Cloudflare config store reads +//! from a KV namespace whose `get` is JS-interop and asynchronous. Other +//! backends complete synchronously and resolve immediately. use std::fmt; use std::sync::Arc; use anyhow::Error as AnyError; +use async_trait::async_trait; use thiserror::Error; -// --------------------------------------------------------------------------- -// Trait -// --------------------------------------------------------------------------- - -/// Errors returned by config-store backends. -/// -/// Missing keys are represented as `Ok(None)` from [`ConfigStore::get`]. -#[derive(Debug, Error)] -pub enum ConfigStoreError { - /// The caller asked for a key that is malformed for the active backend. - #[error("{message}")] - InvalidKey { message: String }, - /// The configured backend cannot currently serve requests. - #[error("config store unavailable: {message}")] - Unavailable { message: String }, - /// An unexpected backend or provider failure occurred. - #[error("config store error: {source}")] - Internal { source: AnyError }, -} - -impl ConfigStoreError { - /// Create an error for malformed or backend-invalid keys. - pub fn invalid_key(message: impl Into) -> Self { - Self::InvalidKey { - message: message.into(), - } - } - - /// Create an error for temporarily unavailable backends. - pub fn unavailable(message: impl Into) -> Self { - Self::Unavailable { - message: message.into(), - } - } - - /// Wrap an unexpected backend or provider failure. - pub fn internal(error: E) -> Self - where - E: Into, - { - Self::Internal { - source: error.into(), - } - } -} - -/// Object-safe interface for read-only configuration store backends. -/// -/// Implementations exist per adapter: -/// - `AxumConfigStore` (axum adapter) — env vars + in-memory defaults for dev -/// - `FastlyConfigStore` (fastly adapter) — Fastly Config Store -/// - `CloudflareConfigStore` (cloudflare adapter) — Cloudflare env bindings -/// - `SpinConfigStore` (spin adapter) — Spin component variables -pub trait ConfigStore: Send + Sync { - /// Retrieve a config value by key. Returns `None` if the key does not exist. - fn get(&self, key: &str) -> Result, ConfigStoreError>; -} - -// --------------------------------------------------------------------------- -// Handle -// --------------------------------------------------------------------------- - -/// A cloneable handle to a config store. -#[derive(Clone)] -pub struct ConfigStoreHandle { - store: Arc, -} - -impl fmt::Debug for ConfigStoreHandle { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.debug_struct("ConfigStoreHandle").finish_non_exhaustive() - } -} - -impl ConfigStoreHandle { - /// Create a new handle wrapping a config store implementation. - pub fn new(store: Arc) -> Self { - Self { store } - } - - /// Get a config value by key. - pub fn get(&self, key: &str) -> Result, ConfigStoreError> { - self.store.get(key) - } -} - // --------------------------------------------------------------------------- // Contract test macro // --------------------------------------------------------------------------- @@ -113,13 +29,10 @@ impl ConfigStoreHandle { /// /// ```rust,ignore /// edgezero_core::config_store_contract_tests!(axum_config_store_contract, { -/// AxumConfigStore::new( -/// [ -/// ("contract.key.a".to_string(), "value_a".to_string()), -/// ("contract.key.b".to_string(), "value_b".to_string()), -/// ], -/// [], -/// ) +/// AxumConfigStore::from_map([ +/// ("contract.key.a".to_owned(), "value_a".to_owned()), +/// ("contract.key.b".to_owned(), "value_b".to_owned()), +/// ]) /// }); /// ``` #[macro_export] @@ -129,52 +42,72 @@ macro_rules! config_store_contract_tests { use super::*; use $crate::config_store::ConfigStore; + fn run(future: Fut) -> Fut::Output { + ::futures::executor::block_on(future) + } + #[$test_attr] fn contract_get_returns_value_for_existing_key() { let store = $factory; - assert_eq!( - store.get("contract.key.a").expect("config value"), - Some("value_a".to_string()) - ); + run(async { + assert_eq!( + store.get("contract.key.a").await.expect("config value"), + Some("value_a".to_owned()) + ); + }); } #[$test_attr] fn contract_get_returns_none_for_missing_key() { let store = $factory; - assert_eq!(store.get("contract.key.missing").expect("config miss"), None); + run(async { + assert_eq!( + store.get("contract.key.missing").await.expect("config miss"), + None + ); + }); } #[$test_attr] fn contract_multiple_keys_are_independent() { let store = $factory; - assert_eq!( - store.get("contract.key.a").expect("first config value"), - Some("value_a".to_string()) - ); - assert_eq!( - store.get("contract.key.b").expect("second config value"), - Some("value_b".to_string()) - ); + run(async { + assert_eq!( + store.get("contract.key.a").await.expect("first config value"), + Some("value_a".to_owned()) + ); + assert_eq!( + store.get("contract.key.b").await.expect("second config value"), + Some("value_b".to_owned()) + ); + }); } #[$test_attr] fn contract_key_lookup_is_case_sensitive() { let store = $factory; - // lowercase "contract.key.a" exists; uppercase must not match - assert_eq!(store.get("CONTRACT.KEY.A").expect("case-sensitive miss"), None); + run(async { + // lowercase "contract.key.a" exists; uppercase must not match + assert_eq!( + store.get("CONTRACT.KEY.A").await.expect("case-sensitive miss"), + None + ); + }); } #[$test_attr] fn contract_empty_key_returns_none_or_invalid_key() { let store = $factory; - // Backends may either return Ok(None) or Err(InvalidKey) for an empty key. - // Fastly's Config Store SDK may reject empty keys rather than returning None. - match store.get("") { - Ok(None) => {} - Ok(Some(_)) => panic!("empty key should not return a value"), - Err($crate::config_store::ConfigStoreError::InvalidKey { .. }) => {} - Err(e) => panic!("unexpected error for empty key: {}", e), - } + run(async { + // Backends may either return Ok(None) or Err(InvalidKey) for an empty key. + // Fastly's Config Store SDK may reject empty keys rather than returning None. + match store.get("").await { + Ok(None) => {} + Ok(Some(_)) => panic!("empty key should not return a value"), + Err($crate::config_store::ConfigStoreError::InvalidKey { .. }) => {} + Err(err) => panic!("unexpected error for empty key: {}", err), + } + }); } #[$test_attr] @@ -183,11 +116,16 @@ macro_rules! config_store_contract_tests { use $crate::config_store::ConfigStoreHandle; let handle = ConfigStoreHandle::new(Arc::new($factory)); - assert_eq!( - handle.get("contract.key.a").expect("handle value"), - Some("value_a".to_string()) - ); - assert_eq!(handle.get("contract.key.missing").expect("handle miss"), None); + run(async { + assert_eq!( + handle.get("contract.key.a").await.expect("handle value"), + Some("value_a".to_owned()) + ); + assert_eq!( + handle.get("contract.key.missing").await.expect("handle miss"), + None + ); + }); } #[$test_attr] @@ -197,14 +135,16 @@ macro_rules! config_store_contract_tests { let h1 = ConfigStoreHandle::new(Arc::new($factory)); let h2 = h1.clone(); - assert_eq!( - h1.get("contract.key.a").expect("first handle value"), - h2.get("contract.key.a").expect("second handle value") - ); - assert_eq!( - h1.get("contract.key.missing").expect("first handle miss"), - h2.get("contract.key.missing").expect("second handle miss") - ); + run(async { + assert_eq!( + h1.get("contract.key.a").await.expect("first handle value"), + h2.get("contract.key.a").await.expect("second handle value") + ); + assert_eq!( + h1.get("contract.key.missing").await.expect("first handle miss"), + h2.get("contract.key.missing").await.expect("second handle miss") + ); + }); } } }; @@ -213,63 +153,180 @@ macro_rules! config_store_contract_tests { }; } +// --------------------------------------------------------------------------- +// Trait +// --------------------------------------------------------------------------- + +/// Errors returned by config-store backends. +/// +/// Missing keys are represented as `Ok(None)` from [`ConfigStore::get`]. +#[derive(Debug, Error)] +#[non_exhaustive] +pub enum ConfigStoreError { + /// An unexpected backend or provider failure occurred. + #[error("config store error: {source}")] + Internal { source: AnyError }, + /// The caller asked for a key that is malformed for the active backend. + #[error("{message}")] + InvalidKey { message: String }, + /// The configured backend cannot currently serve requests. + #[error("config store unavailable: {message}")] + Unavailable { message: String }, +} + +impl ConfigStoreError { + /// Wrap an unexpected backend or provider failure. + #[inline] + pub fn internal(error: E) -> Self + where + E: Into, + { + Self::Internal { + source: error.into(), + } + } + + /// Create an error for malformed or backend-invalid keys. + #[inline] + pub fn invalid_key>(message: S) -> Self { + Self::InvalidKey { + message: message.into(), + } + } + + /// Create an error for temporarily unavailable backends. + #[inline] + pub fn unavailable>(message: S) -> Self { + Self::Unavailable { + message: message.into(), + } + } +} + +/// Object-safe interface for read-only configuration store backends. +/// +/// Implementations exist per adapter: +/// - `AxumConfigStore` (axum adapter) — env vars + in-memory defaults for dev +/// - `FastlyConfigStore` (fastly adapter) — Fastly Config Store +/// - `CloudflareConfigStore` (cloudflare adapter) — Cloudflare KV namespace +/// - `SpinConfigStore` (spin adapter) — Spin KV (`spin_sdk::key_value::Store`) +#[async_trait(?Send)] +pub trait ConfigStore: Send + Sync { + /// Retrieve a config value by key. Returns `None` if the key does not exist. + /// + /// # Errors + /// Returns [`ConfigStoreError`] if `key` is invalid or the backend is unavailable. + async fn get(&self, key: &str) -> Result, ConfigStoreError>; +} + +// --------------------------------------------------------------------------- +// Handle +// --------------------------------------------------------------------------- + +/// A cloneable handle to a config store. +#[derive(Clone)] +pub struct ConfigStoreHandle { + store: Arc, +} + +impl fmt::Debug for ConfigStoreHandle { + #[inline] + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("ConfigStoreHandle").finish_non_exhaustive() + } +} + +impl ConfigStoreHandle { + /// Get a config value by key. + /// + /// # Errors + /// Returns [`ConfigStoreError`] if `key` is invalid or the backend is unavailable. + #[inline] + pub async fn get(&self, key: &str) -> Result, ConfigStoreError> { + self.store.get(key).await + } + + /// Create a new handle wrapping a config store implementation. + #[inline] + pub fn new(store: Arc) -> Self { + Self { store } + } +} + // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- #[cfg(test)] mod tests { + // Run the shared contract tests against TestConfigStore. + crate::config_store_contract_tests!( + test_config_store_contract, + TestConfigStore::new(&[("contract.key.a", "value_a"), ("contract.key.b", "value_b"),]) + ); + use super::*; + use futures::executor::block_on; use std::collections::HashMap; + struct FailingConfigStore; + struct TestConfigStore { data: HashMap, } + #[async_trait(?Send)] + impl ConfigStore for FailingConfigStore { + async fn get(&self, _key: &str) -> Result, ConfigStoreError> { + Err(ConfigStoreError::unavailable("backend offline")) + } + } + + #[async_trait(?Send)] + impl ConfigStore for TestConfigStore { + async fn get(&self, key: &str) -> Result, ConfigStoreError> { + Ok(self.data.get(key).cloned()) + } + } + impl TestConfigStore { fn new(entries: &[(&str, &str)]) -> Self { Self { data: entries .iter() - .map(|(k, v)| (k.to_string(), v.to_string())) + .map(|(key, value)| ((*key).to_owned(), (*value).to_owned())) .collect(), } } } - impl ConfigStore for TestConfigStore { - fn get(&self, key: &str) -> Result, ConfigStoreError> { - Ok(self.data.get(key).cloned()) - } - } - fn handle(entries: &[(&str, &str)]) -> ConfigStoreHandle { ConfigStoreHandle::new(Arc::new(TestConfigStore::new(entries))) } #[test] - fn config_store_get_returns_value_for_existing_key() { - let h = handle(&[("feature.checkout", "true")]); + fn config_store_get_returns_none_for_missing_key() { + let store_handle = handle(&[]); assert_eq!( - h.get("feature.checkout").expect("config value"), - Some("true".to_string()) + block_on(store_handle.get("nonexistent")).expect("missing config"), + None ); } #[test] - fn config_store_get_returns_none_for_missing_key() { - let h = handle(&[]); - assert_eq!(h.get("nonexistent").expect("missing config"), None); + fn config_store_get_returns_value_for_existing_key() { + let store_handle = handle(&[("feature.checkout", "true")]); + assert_eq!( + block_on(store_handle.get("feature.checkout")).expect("config value"), + Some("true".to_owned()) + ); } #[test] - fn config_store_handle_wraps_and_delegates() { - let h = handle(&[("timeout_ms", "1500")]); - assert_eq!( - h.get("timeout_ms").expect("config value"), - Some("1500".to_string()) - ); - assert_eq!(h.get("missing").expect("missing config"), None); + fn config_store_handle_debug_output() { + let store_handle = handle(&[]); + let debug = format!("{store_handle:?}"); + assert!(debug.contains("ConfigStoreHandle")); } #[test] @@ -277,48 +334,38 @@ mod tests { let h1 = handle(&[("key", "val")]); let h2 = h1.clone(); assert_eq!( - h1.get("key").expect("first handle value"), - h2.get("key").expect("second handle value") + block_on(h1.get("key")).expect("first handle value"), + block_on(h2.get("key")).expect("second handle value") ); } #[test] fn config_store_handle_new_accepts_arc() { let store = Arc::new(TestConfigStore::new(&[("a", "1")])); - let h = ConfigStoreHandle::new(store); + let store_handle = ConfigStoreHandle::new(store); assert_eq!( - h.get("a").expect("arc-backed config"), - Some("1".to_string()) + block_on(store_handle.get("a")).expect("arc-backed config"), + Some("1".to_owned()) ); } - #[test] - fn config_store_handle_debug_output() { - let h = handle(&[]); - let debug = format!("{:?}", h); - assert!(debug.contains("ConfigStoreHandle")); - } - - struct FailingConfigStore; - - impl ConfigStore for FailingConfigStore { - fn get(&self, _key: &str) -> Result, ConfigStoreError> { - Err(ConfigStoreError::unavailable("backend offline")) - } - } - #[test] fn config_store_handle_propagates_backend_errors() { let handle = ConfigStoreHandle::new(Arc::new(FailingConfigStore)); - let err = handle - .get("feature.checkout") - .expect_err("expected backend error"); + let err = block_on(handle.get("feature.checkout")).expect_err("expected backend error"); assert!(matches!(err, ConfigStoreError::Unavailable { .. })); } - // Run the shared contract tests against TestConfigStore. - crate::config_store_contract_tests!( - test_config_store_contract, - TestConfigStore::new(&[("contract.key.a", "value_a"), ("contract.key.b", "value_b"),]) - ); + #[test] + fn config_store_handle_wraps_and_delegates() { + let store_handle = handle(&[("timeout_ms", "1500")]); + assert_eq!( + block_on(store_handle.get("timeout_ms")).expect("config value"), + Some("1500".to_owned()) + ); + assert_eq!( + block_on(store_handle.get("missing")).expect("missing config"), + None + ); + } } diff --git a/crates/edgezero-core/src/context.rs b/crates/edgezero-core/src/context.rs index 92dd176b..c2b38c08 100644 --- a/crates/edgezero-core/src/context.rs +++ b/crates/edgezero-core/src/context.rs @@ -1,61 +1,75 @@ use crate::body::Body; -use crate::config_store::ConfigStoreHandle; use crate::error::EdgeError; use crate::http::Request; -use crate::key_value_store::KvHandle; use crate::params::PathParams; use crate::proxy::ProxyHandle; -use crate::secret_store::SecretHandle; +use crate::store_registry::{ + BoundConfigStore, BoundKvStore, BoundSecretStore, ConfigRegistry, KvRegistry, SecretRegistry, + StoreRegistry, +}; use serde::de::DeserializeOwned; /// Request context exposed to handlers and middleware. pub struct RequestContext { - request: Request, path_params: PathParams, + request: Request, } impl RequestContext { - pub fn new(request: Request, params: PathParams) -> Self { - Self { - request, - path_params: params, - } - } - - pub fn request(&self) -> &Request { - &self.request - } - - pub fn request_mut(&mut self) -> &mut Request { - &mut self.request + #[inline] + pub fn body(&self) -> &Body { + self.request.body() } - pub fn into_request(self) -> Request { + /// Resolve the [`BoundConfigStore`] for `id`. Strict lookup: when a + /// [`ConfigRegistry`] is wired, an unregistered id yields `None`. When + /// no registry is wired this returns `None` — adapter dispatchers + /// normalise legacy bare-handle inputs to a single-id registry under + /// the conventional `"default"` id, so a missing registry is a real + /// bug rather than a hand-wired single-handle adapter (spec hard-cutoff). + #[inline] + pub fn config_store(&self, id: &str) -> Option { self.request + .extensions() + .get::() + .and_then(|registry| registry.named(id)) } - pub fn path_params(&self) -> &PathParams { - &self.path_params + /// Resolve the default [`BoundConfigStore`] — the wired registry's + /// declared default id, or `None` when no registry is in extensions. + /// See [`Self::config_store`] for the hard-cutoff rationale. + #[inline] + pub fn config_store_default(&self) -> Option { + self.request + .extensions() + .get::() + .and_then(StoreRegistry::default) } - pub fn path(&self) -> Result + /// # Errors + /// Returns [`EdgeError::bad_request`] if the body cannot be deserialized as form-urlencoded data into `T`, or the body is streaming. + #[inline] + pub fn form(&self) -> Result where T: DeserializeOwned, { - self.path_params - .deserialize() - .map_err(|err| EdgeError::bad_request(format!("invalid path parameters: {}", err))) + match self.request.body() { + Body::Once(bytes) => serde_urlencoded::from_bytes(bytes.as_ref()) + .map_err(|err| EdgeError::bad_request(format!("invalid form payload: {err}"))), + Body::Stream(_) => Err(EdgeError::bad_request( + "streaming bodies are not supported for form extraction", + )), + } } - pub fn query(&self) -> Result - where - T: DeserializeOwned, - { - let query = self.request.uri().query().unwrap_or(""); - serde_urlencoded::from_str(query) - .map_err(|err| EdgeError::bad_request(format!("invalid query string: {}", err))) + #[inline] + pub fn into_request(self) -> Request { + self.request } + /// # Errors + /// Returns [`EdgeError::bad_request`] if the body is not valid JSON for `T`. + #[inline] pub fn json(&self) -> Result where T: DeserializeOwned, @@ -63,45 +77,107 @@ impl RequestContext { self.request .body() .to_json() - .map_err(|err| EdgeError::bad_request(format!("invalid JSON payload: {}", err))) + .map_err(|err| EdgeError::bad_request(format!("invalid JSON payload: {err}"))) } - pub fn body(&self) -> &Body { - self.request.body() + /// Resolve the [`BoundKvStore`] for `id`. Strict lookup: when a + /// [`KvRegistry`] is wired, an unregistered id yields `None`. When no + /// registry is wired this returns `None` — adapter dispatchers + /// normalise legacy bare-handle inputs to a single-id registry under + /// the conventional `"default"` id (spec hard-cutoff). + #[inline] + pub fn kv_store(&self, id: &str) -> Option { + self.request + .extensions() + .get::() + .and_then(|registry| registry.named(id)) } - pub fn form(&self) -> Result + /// Resolve the default [`BoundKvStore`] — the wired registry's + /// declared default id, or `None` when no registry is in extensions. + /// See [`Self::kv_store`] for the hard-cutoff rationale. + #[inline] + pub fn kv_store_default(&self) -> Option { + self.request + .extensions() + .get::() + .and_then(StoreRegistry::default) + } + + #[inline] + pub fn new(request: Request, params: PathParams) -> Self { + Self { + path_params: params, + request, + } + } + + /// # Errors + /// Returns [`EdgeError::bad_request`] if the path parameters cannot be deserialized into `T`. + #[inline] + pub fn path(&self) -> Result where T: DeserializeOwned, { - match self.request.body() { - Body::Once(bytes) => serde_urlencoded::from_bytes(bytes.as_ref()) - .map_err(|err| EdgeError::bad_request(format!("invalid form payload: {}", err))), - Body::Stream(_) => Err(EdgeError::bad_request( - "streaming bodies are not supported for form extraction", - )), - } + self.path_params + .deserialize() + .map_err(|err| EdgeError::bad_request(format!("invalid path parameters: {err}"))) } + #[inline] + pub fn path_params(&self) -> &PathParams { + &self.path_params + } + + #[inline] pub fn proxy_handle(&self) -> Option { self.request.extensions().get::().cloned() } - pub fn config_store(&self) -> Option { - self.request - .extensions() - .get::() - .cloned() + /// # Errors + /// Returns [`EdgeError::bad_request`] if the query string cannot be deserialized into `T`. + #[inline] + pub fn query(&self) -> Result + where + T: DeserializeOwned, + { + let query = self.request.uri().query().unwrap_or(""); + serde_urlencoded::from_str(query) + .map_err(|err| EdgeError::bad_request(format!("invalid query string: {err}"))) + } + + #[inline] + pub fn request(&self) -> &Request { + &self.request } - /// Returns the KV store handle if one was configured for this request. - pub fn kv_handle(&self) -> Option { - self.request.extensions().get::().cloned() + #[inline] + pub fn request_mut(&mut self) -> &mut Request { + &mut self.request + } + + /// Resolve the [`BoundSecretStore`] for `id`. Strict lookup: when a + /// [`SecretRegistry`] is wired, an unregistered id yields `None`. + /// When no registry is wired this returns `None` — adapter + /// dispatchers normalise legacy bare-handle inputs to a single-id + /// registry under the conventional `"default"` id (spec hard-cutoff). + #[inline] + pub fn secret_store(&self, id: &str) -> Option { + self.request + .extensions() + .get::() + .and_then(|registry| registry.named(id)) } - /// Returns the secret store handle if one was configured for this request. - pub fn secret_handle(&self) -> Option { - self.request.extensions().get::().cloned() + /// Resolve the default [`BoundSecretStore`] — the wired registry's + /// declared default id, or `None` when no registry is in extensions. + /// See [`Self::secret_store`] for the hard-cutoff rationale. + #[inline] + pub fn secret_store_default(&self) -> Option { + self.request + .extensions() + .get::() + .and_then(StoreRegistry::default) } } @@ -113,10 +189,25 @@ mod tests { use crate::proxy::{ProxyClient, ProxyHandle, ProxyRequest, ProxyResponse}; use async_trait::async_trait; use bytes::Bytes; + use futures::executor::block_on; use futures::stream; use serde::{Deserialize, Serialize}; use std::collections::HashMap; + struct DummyClient; + + #[derive(Debug, PartialEq, Deserialize, Serialize)] + struct PathData { + id: String, + } + + #[async_trait(?Send)] + impl ProxyClient for DummyClient { + async fn send(&self, _request: ProxyRequest) -> Result { + Ok(ProxyResponse::new(StatusCode::OK, Body::empty())) + } + } + fn ctx(path: &str, body: Body, params: PathParams) -> RequestContext { let request = request_builder() .method(Method::GET) @@ -129,28 +220,83 @@ mod tests { fn params(map: &[(&str, &str)]) -> PathParams { let inner = map .iter() - .map(|(k, v)| (k.to_string(), v.to_string())) + .map(|(key, value)| ((*key).to_owned(), (*value).to_owned())) .collect::>(); PathParams::new(inner) } - #[derive(Debug, PartialEq, Deserialize, Serialize)] - struct PathData { - id: String, + // `RequestContext::config_handle()` was removed. The + // present/absent behaviour is now covered by + // `config_store_*` tests against a wired `ConfigRegistry`. + + #[test] + fn form_deserialises_successfully() { + #[derive(Deserialize, PartialEq, Debug)] + struct FormData { + name: String, + } + let body = Body::from("name=demo"); + let ctx = ctx("/submit", body, PathParams::default()); + let parsed: FormData = ctx.form().expect("form data"); + assert_eq!( + parsed, + FormData { + name: "demo".into() + } + ); + let debug = format!("{parsed:?}"); + assert!(debug.contains("demo")); } #[test] - fn path_deserialises_successfully() { - let ctx = ctx("/items/42", Body::empty(), params(&[("id", "42")])); - let parsed: PathData = ctx.path().expect("path parameters"); - assert_eq!(parsed, PathData { id: "42".into() }); - let serialized = serde_json::to_string(&parsed).expect("serialize"); - assert!(serialized.contains("42")); + fn form_streaming_body_not_supported() { + let stream = stream::iter(vec![Ok::(Bytes::from("name=demo"))]); + let body = Body::from_stream(stream); + let ctx = ctx("/submit", body, PathParams::default()); + let err = ctx.form::().expect_err("expected error"); + assert_eq!(err.status(), StatusCode::BAD_REQUEST); + assert!(err + .message() + .contains("streaming bodies are not supported for form extraction")); + } + + #[test] + fn form_value_deserialises_successfully() { + let body = Body::from("name=demo"); + let ctx = ctx("/submit", body, PathParams::default()); + let parsed: serde_json::Value = ctx.form().expect("form data"); + assert_eq!( + parsed.get("name").and_then(|value| value.as_str()), + Some("demo") + ); + } + + #[test] + fn invalid_form_returns_bad_request() { + #[expect(dead_code, reason = "field exercised only via Deserialize")] + #[derive(Deserialize)] + struct FormData { + age: u8, + } + let body = Body::from("age=not-a-number"); + let ctx = ctx("/submit", body, PathParams::default()); + let err = ctx.form::().err().expect("expected error"); + assert_eq!(err.status(), StatusCode::BAD_REQUEST); + assert!(err.message().contains("invalid form payload")); + } + + #[test] + fn invalid_json_returns_bad_request() { + let body = Body::from(&b"not json"[..]); + let ctx = ctx("/echo", body, PathParams::default()); + let err = ctx.json::().expect_err("expected error"); + assert_eq!(err.status(), StatusCode::BAD_REQUEST); + assert!(err.message().contains("invalid JSON payload")); } #[test] fn invalid_path_returns_bad_request() { - #[allow(dead_code)] + #[expect(dead_code, reason = "field exercised only via Deserialize")] #[derive(Debug, Deserialize)] struct NumericPath { id: u32, @@ -163,31 +309,9 @@ mod tests { assert!(err.message().contains("invalid path parameters")); } - #[test] - fn query_deserialises_successfully() { - #[derive(Debug, Deserialize, PartialEq)] - struct Query { - page: u8, - } - let ctx = ctx("/items?page=5", Body::empty(), PathParams::default()); - let parsed: Query = ctx.query().expect("query"); - assert_eq!(parsed, Query { page: 5 }); - } - - #[test] - fn query_defaults_to_empty_when_missing() { - #[derive(Debug, Deserialize, PartialEq)] - struct Query { - page: Option, - } - let ctx = ctx("/items", Body::empty(), PathParams::default()); - let parsed: Query = ctx.query().expect("query"); - assert_eq!(parsed.page, None); - } - #[test] fn invalid_query_returns_bad_request() { - #[allow(dead_code)] + #[expect(dead_code, reason = "field exercised only via Deserialize")] #[derive(Debug, Deserialize)] struct Query { page: u8, @@ -220,78 +344,25 @@ mod tests { ); } - #[test] - fn invalid_json_returns_bad_request() { - let body = Body::from(&b"not json"[..]); - let ctx = ctx("/echo", body, PathParams::default()); - let err = ctx.json::().expect_err("expected error"); - assert_eq!(err.status(), StatusCode::BAD_REQUEST); - assert!(err.message().contains("invalid JSON payload")); - } + // `RequestContext::kv_handle()` was removed. The + // present/absent behaviour is now covered by `kv_store_*` + // tests against a wired `KvRegistry`. #[test] - fn form_deserialises_successfully() { - #[derive(Deserialize, PartialEq, Debug)] - struct FormData { - name: String, - } - let body = Body::from("name=demo"); - let ctx = ctx("/submit", body, PathParams::default()); - let parsed: FormData = ctx.form().expect("form data"); - assert_eq!( - parsed, - FormData { - name: "demo".into() - } - ); - let debug = format!("{:?}", parsed); - assert!(debug.contains("demo")); - } - - #[test] - fn invalid_form_returns_bad_request() { - #[allow(dead_code)] - #[derive(Deserialize)] - struct FormData { - age: u8, - } - let body = Body::from("age=not-a-number"); - let ctx = ctx("/submit", body, PathParams::default()); - let err = ctx.form::().err().expect("expected error"); - assert_eq!(err.status(), StatusCode::BAD_REQUEST); - assert!(err.message().contains("invalid form payload")); - } - - #[test] - fn form_value_deserialises_successfully() { - let body = Body::from("name=demo"); - let ctx = ctx("/submit", body, PathParams::default()); - let parsed: serde_json::Value = ctx.form().expect("form data"); - assert_eq!( - parsed.get("name").and_then(|value| value.as_str()), - Some("demo") - ); + fn path_deserialises_successfully() { + let ctx = ctx("/items/42", Body::empty(), params(&[("id", "42")])); + let parsed: PathData = ctx.path().expect("path parameters"); + assert_eq!(parsed, PathData { id: "42".into() }); + let serialized = serde_json::to_string(&parsed).expect("serialize"); + assert!(serialized.contains("42")); } #[test] - fn form_streaming_body_not_supported() { - let stream = stream::iter(vec![Ok::(Bytes::from("name=demo"))]); - let body = Body::from_stream(stream); - let ctx = ctx("/submit", body, PathParams::default()); - let err = ctx.form::().expect_err("expected error"); - assert_eq!(err.status(), StatusCode::BAD_REQUEST); - assert!(err - .message() - .contains("streaming bodies are not supported for form extraction")); - } - - struct DummyClient; - - #[async_trait(?Send)] - impl ProxyClient for DummyClient { - async fn send(&self, _request: ProxyRequest) -> Result { - Ok(ProxyResponse::new(StatusCode::OK, Body::empty())) - } + fn proxy_handle_forwards_with_dummy_client() { + let handle = ProxyHandle::with_client(DummyClient); + let request = ProxyRequest::new(Method::GET, Uri::from_static("https://example.com")); + let response = block_on(handle.forward(request)).expect("response"); + assert_eq!(response.status(), StatusCode::OK); } #[test] @@ -309,6 +380,28 @@ mod tests { assert!(ctx.proxy_handle().is_some()); } + #[test] + fn query_defaults_to_empty_when_missing() { + #[derive(Debug, Deserialize, PartialEq)] + struct Query { + page: Option, + } + let ctx = ctx("/items", Body::empty(), PathParams::default()); + let parsed: Query = ctx.query().expect("query"); + assert_eq!(parsed.page, None); + } + + #[test] + fn query_deserialises_successfully() { + #[derive(Debug, Deserialize, PartialEq)] + struct Query { + page: u8, + } + let ctx = ctx("/items?page=5", Body::empty(), PathParams::default()); + let parsed: Query = ctx.query().expect("query"); + assert_eq!(parsed, Query { page: 5 }); + } + #[test] fn request_context_accessors_return_expected_values() { let mut ctx = ctx( @@ -324,67 +417,65 @@ mod tests { ctx.request() .headers() .get("x-test") - .and_then(|v| v.to_str().ok()), + .and_then(|value| value.to_str().ok()), Some("value") ); assert_eq!(ctx.path_params().get("id"), Some("123")); - assert_eq!(ctx.body().as_bytes(), b"payload"); + assert_eq!(ctx.body().as_bytes().expect("buffered"), b"payload"); let request = ctx.into_request(); assert_eq!(request.uri().path(), "/items/123"); } - #[test] - fn proxy_handle_forwards_with_dummy_client() { - let handle = ProxyHandle::with_client(DummyClient); - let request = ProxyRequest::new(Method::GET, Uri::from_static("https://example.com")); - let response = futures::executor::block_on(handle.forward(request)).expect("response"); - assert_eq!(response.status(), StatusCode::OK); - } + // `RequestContext::secret_handle()` was removed. The + // present/absent behaviour is now covered by `secret_store_*` + // tests against a wired `SecretRegistry`. #[test] - fn config_store_is_retrieved_when_present() { - use crate::config_store::{ConfigStore, ConfigStoreHandle}; + fn kv_store_resolves_named_handle_from_registry() { + use crate::key_value_store::{KvHandle, NoopKvStore}; + use crate::store_registry::{KvRegistry, StoreRegistry}; + use std::collections::BTreeMap; use std::sync::Arc; - struct FixedStore; - impl ConfigStore for FixedStore { - fn get( - &self, - _key: &str, - ) -> Result, crate::config_store::ConfigStoreError> { - Ok(Some("value".to_string())) - } - } + let sessions = KvHandle::new(Arc::new(NoopKvStore)); + let cache = KvHandle::new(Arc::new(NoopKvStore)); + let by_id: BTreeMap = [ + ("sessions".to_owned(), sessions), + ("cache".to_owned(), cache), + ] + .into_iter() + .collect(); + let registry: KvRegistry = StoreRegistry::new(by_id, "sessions".to_owned()); let mut request = request_builder() .method(Method::GET) - .uri("/config") + .uri("/kv") .body(Body::empty()) .expect("request"); - request - .extensions_mut() - .insert(ConfigStoreHandle::new(Arc::new(FixedStore))); + request.extensions_mut().insert(registry); let ctx = RequestContext::new(request, PathParams::default()); - assert!(ctx.config_store().is_some()); - assert_eq!( - ctx.config_store() - .unwrap() - .get("any") - .expect("config value"), - Some("value".to_string()) + assert!(ctx.kv_store("sessions").is_some()); + assert!(ctx.kv_store("cache").is_some()); + assert!( + ctx.kv_store("unknown").is_none(), + "registry lookups are strict: unknown ids must yield None" ); + assert!(ctx.kv_store_default().is_some()); } #[test] - fn config_store_returns_none_when_absent() { - let ctx = ctx("/test", Body::empty(), PathParams::default()); - assert!(ctx.config_store().is_none()); - } - - #[test] - fn kv_handle_is_retrieved_when_present() { + fn kv_store_returns_none_when_only_legacy_handle_wired() { + // Hard-cutoff: a bare `KvHandle` in extensions + // is ignored by the registry-aware accessor. Adapter + // dispatchers no longer insert bare handles — they + // always synthesise a `KvRegistry` from any wired handle + // first — so this code path only fires when a test or + // callsite bypasses the dispatcher and inserts a bare + // handle directly into extensions. The accessor must + // surface that as a missing registry (None) rather than + // silently upgrading. use crate::key_value_store::{KvHandle, NoopKvStore}; use std::sync::Arc; @@ -398,36 +489,118 @@ mod tests { .insert(KvHandle::new(Arc::new(NoopKvStore))); let ctx = RequestContext::new(request, PathParams::default()); - assert!(ctx.kv_handle().is_some()); + assert!( + ctx.kv_store("anything").is_none(), + "registry-aware accessor must not auto-upgrade a bare handle" + ); + assert!( + ctx.kv_store_default().is_none(), + "registry-aware default accessor must not auto-upgrade a bare handle" + ); } #[test] - fn kv_handle_returns_none_when_absent() { - let ctx = ctx("/test", Body::empty(), PathParams::default()); - assert!(ctx.kv_handle().is_none()); + fn config_store_resolves_named_handle_from_registry() { + use crate::config_store::{ConfigStore, ConfigStoreError, ConfigStoreHandle}; + use crate::store_registry::{ConfigRegistry, StoreRegistry}; + use std::collections::BTreeMap; + use std::sync::Arc; + + struct FixedStore(&'static str); + #[async_trait(?Send)] + impl ConfigStore for FixedStore { + async fn get(&self, _key: &str) -> Result, ConfigStoreError> { + Ok(Some(self.0.to_owned())) + } + } + + let primary_handle = ConfigStoreHandle::new(Arc::new(FixedStore("primary"))); + let analytics_handle = ConfigStoreHandle::new(Arc::new(FixedStore("analytics"))); + let by_id: BTreeMap = [ + ("primary".to_owned(), primary_handle), + ("analytics".to_owned(), analytics_handle), + ] + .into_iter() + .collect(); + let registry: ConfigRegistry = StoreRegistry::new(by_id, "primary".to_owned()); + + let mut request = request_builder() + .method(Method::GET) + .uri("/config") + .body(Body::empty()) + .expect("request"); + request.extensions_mut().insert(registry); + + let ctx = RequestContext::new(request, PathParams::default()); + let resolved = ctx.config_store("analytics").expect("analytics handle"); + assert_eq!( + block_on(resolved.get("key")).expect("config value"), + Some("analytics".to_owned()) + ); + assert!(ctx.config_store("unknown").is_none()); + let default = ctx.config_store_default().expect("default handle"); + assert_eq!( + block_on(default.get("key")).expect("default config value"), + Some("primary".to_owned()) + ); } #[test] - fn secret_handle_is_retrieved_when_present() { + fn secret_store_resolves_named_handle_from_registry() { use crate::secret_store::{NoopSecretStore, SecretHandle}; + use crate::store_registry::{BoundSecretStore, SecretRegistry, StoreRegistry}; + use std::collections::BTreeMap; use std::sync::Arc; + let handle = SecretHandle::new(Arc::new(NoopSecretStore)); + let by_id: BTreeMap = [( + "default".to_owned(), + // The registry binds the logical id to the platform store name — + // in production that's `EDGEZERO__STORES__SECRETS__DEFAULT__NAME` + // resolved against the env (falling back to the logical id). + BoundSecretStore::new(handle, "platform-secret-store".to_owned()), + )] + .into_iter() + .collect(); + let registry: SecretRegistry = StoreRegistry::new(by_id, "default".to_owned()); + let mut request = request_builder() .method(Method::GET) .uri("/secrets") .body(Body::empty()) .expect("request"); - request - .extensions_mut() - .insert(SecretHandle::new(Arc::new(NoopSecretStore))); + request.extensions_mut().insert(registry); let ctx = RequestContext::new(request, PathParams::default()); - assert!(ctx.secret_handle().is_some()); + let bound = ctx.secret_store("default").expect("default bound store"); + assert_eq!(bound.store_name(), "platform-secret-store"); + assert!(ctx.secret_store("unknown").is_none()); + assert!(ctx.secret_store_default().is_some()); } #[test] - fn secret_handle_returns_none_when_absent() { - let ctx = ctx("/test", Body::empty(), PathParams::default()); - assert!(ctx.secret_handle().is_none()); + fn secret_store_default_returns_none_when_only_legacy_handle_wired() { + // Hard-cutoff: same semantics as + // `kv_store_returns_none_when_only_legacy_handle_wired` — + // a bare `SecretHandle` in extensions (a state that + // only arises if a test bypasses the dispatcher) must + // not auto-upgrade into a synthetic registry. + use crate::secret_store::{NoopSecretStore, SecretHandle}; + use std::sync::Arc; + + let mut request = request_builder() + .method(Method::GET) + .uri("/secrets") + .body(Body::empty()) + .expect("request"); + request + .extensions_mut() + .insert(SecretHandle::new(Arc::new(NoopSecretStore))); + + let ctx = RequestContext::new(request, PathParams::default()); + assert!( + ctx.secret_store_default().is_none(), + "registry-aware default accessor must not auto-upgrade a bare handle" + ); } } diff --git a/crates/edgezero-core/src/env_config.rs b/crates/edgezero-core/src/env_config.rs new file mode 100644 index 00000000..aa27546a --- /dev/null +++ b/crates/edgezero-core/src/env_config.rs @@ -0,0 +1,297 @@ +//! `EDGEZERO__*` environment-config layer. +//! +//! Adapter-specific runtime config — platform store names, per-store tuning, +//! bind host/port, and logging level — is supplied at runtime through +//! `EDGEZERO__`-prefixed environment variables. `__` (double underscore) +//! separates key-path segments, so `EDGEZERO__STORES__KV__SESSIONS__NAME` +//! parses to the segment path `["stores", "kv", "sessions", "name"]`. +//! +//! Every segment is lower-cased on parse, and lookup arguments are lower-cased +//! before matching — callers pass lower-case logical ids and get a +//! case-insensitive match against the upper-case env-var convention. + +use std::collections::BTreeMap; +use std::env; + +/// The prefix every recognised variable must start with. +const PREFIX: &str = "EDGEZERO__"; +/// The key-path segment separator. +const SEPARATOR: &str = "__"; + +/// Adapter runtime config resolved from `EDGEZERO__*` environment variables. +/// +/// Keys are lower-cased segment paths; values are the raw environment-variable +/// strings. Build one with [`EnvConfig::from_env`] (native targets) or +/// [`EnvConfig::from_vars`] (e.g. Cloudflare Workers, which have no +/// `std::env`). +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct EnvConfig { + entries: BTreeMap, String>, +} + +impl EnvConfig { + /// `EDGEZERO__ADAPTER__HOST`. + #[must_use] + #[inline] + pub fn adapter_host(&self) -> Option<&str> { + self.get(&["adapter", "host"]) + } + + /// `EDGEZERO__ADAPTER__PORT` (raw string — callers parse it). + #[must_use] + #[inline] + pub fn adapter_port(&self) -> Option<&str> { + self.get(&["adapter", "port"]) + } + + /// Read all `EDGEZERO__`-prefixed variables from the process environment + /// (`std::env::vars()`). On targets without a process environment (e.g. + /// `wasm32-unknown-unknown`) this yields an empty config. + #[must_use] + #[inline] + pub fn from_env() -> Self { + Self::from_vars(env::vars()) + } + + /// Build from an explicit `(key, value)` iterator. Cloudflare Workers have + /// no `std::env`; that adapter enumerates its `Env` binding object and + /// calls this instead of [`EnvConfig::from_env`]. + #[must_use] + #[inline] + pub fn from_vars(vars: I) -> Self + where + I: IntoIterator, + K: AsRef, + V: Into, + { + let mut entries = BTreeMap::new(); + for (key, value) in vars { + let Some(rest) = key.as_ref().strip_prefix(PREFIX) else { + continue; + }; + let segments: Vec = + rest.split(SEPARATOR).map(str::to_ascii_lowercase).collect(); + if segments.is_empty() || segments.iter().any(String::is_empty) { + continue; + } + entries.insert(segments, value.into()); + } + Self { entries } + } + + /// Generic lookup by segment path. Segments are matched case-insensitively + /// — they are lower-cased before comparison, matching the lower-cased + /// parsed keys. + #[must_use] + #[inline] + pub fn get(&self, segments: &[&str]) -> Option<&str> { + let path: Vec = segments + .iter() + .map(|seg| seg.to_ascii_lowercase()) + .collect(); + self.entries.get(&path).map(String::as_str) + } + + /// `EDGEZERO__LOGGING__ENDPOINT`. Adapters that wire a platform-specific + /// logger (e.g. Fastly's named log endpoints) read this to know which + /// endpoint to attach to; a `None` value means "don't init a platform + /// logger" — useful under local emulators (Viceroy) that reject reserved + /// names like `stdout`. + #[must_use] + #[inline] + pub fn logging_endpoint(&self) -> Option<&str> { + self.get(&["logging", "endpoint"]) + } + + /// `EDGEZERO__LOGGING__LEVEL`. + #[must_use] + #[inline] + pub fn logging_level(&self) -> Option<&str> { + self.get(&["logging", "level"]) + } + + /// Platform name for a logical store — `EDGEZERO__STORES______NAME` + /// — falling back to `id` itself when the variable is unset OR when + /// the value is empty / whitespace-only. `kind` is `"kv"` / + /// `"config"` / `"secrets"`. + /// + /// The empty/whitespace skip is deliberate: an env var like + /// `EDGEZERO__STORES__CONFIG__APP_CONFIG__NAME=` (set but blank) + /// would otherwise flow into `wrangler kv namespace create ""` + /// or `fastly config-store create --name=` or be written as + /// the binding name in wrangler.toml -- all of which fail at + /// the platform with confusing errors rather than the clear + /// "did you forget to set the env var" message you'd expect. + /// Falling back to the logical id is consistent with the + /// "unset" path and gives the operator a working default. + /// + /// Control characters are similarly rejected because no + /// platform (cloudflare bindings, fastly store names, spin + /// labels) accepts them as resource identifiers. + #[must_use] + #[inline] + pub fn store_name(&self, kind: &str, id: &str) -> String { + self.get(&["stores", kind, id, "name"]) + .filter(|value| !is_blank_or_control(value)) + .map_or_else(|| id.to_owned(), str::to_owned) + } + + /// Free-form per-store tuning — `EDGEZERO__STORES______`. + #[must_use] + #[inline] + pub fn store_setting(&self, kind: &str, id: &str, key: &str) -> Option<&str> { + self.get(&["stores", kind, id, key]) + } +} + +/// `true` if `value` is empty, made entirely of whitespace, or +/// contains any ASCII / Unicode control character. Used to reject +/// platform-name overrides that would otherwise flow as empty +/// strings (or control chars) into platform-side resource names. +fn is_blank_or_control(value: &str) -> bool { + value.is_empty() + || value.chars().all(char::is_whitespace) + || value.chars().any(char::is_control) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn sample() -> EnvConfig { + EnvConfig::from_vars([ + ("EDGEZERO__STORES__KV__SESSIONS__NAME", "prod-sessions"), + ("EDGEZERO__STORES__KV__SESSIONS__MAX_LIST_KEYS", "500"), + ("EDGEZERO__ADAPTER__HOST", "0.0.0.0"), + ("EDGEZERO__ADAPTER__PORT", "9000"), + ("EDGEZERO__LOGGING__LEVEL", "debug"), + ("PATH", "/usr/bin"), + ]) + } + + #[test] + fn parses_and_lower_cases_segments() { + let cfg = sample(); + assert_eq!( + cfg.get(&["stores", "kv", "sessions", "name"]), + Some("prod-sessions") + ); + } + + #[test] + fn get_is_case_insensitive() { + let cfg = sample(); + assert_eq!( + cfg.get(&["STORES", "KV", "Sessions", "NAME"]), + Some("prod-sessions") + ); + } + + #[test] + fn store_name_hit() { + let cfg = sample(); + assert_eq!(cfg.store_name("kv", "sessions"), "prod-sessions"); + } + + #[test] + fn store_name_falls_back_to_id() { + let cfg = sample(); + assert_eq!(cfg.store_name("kv", "cache"), "cache"); + } + + #[test] + fn store_name_falls_back_to_id_when_env_value_is_empty() { + // An exported but empty `EDGEZERO__STORES______NAME=` + // would otherwise flow into a platform `create` call with + // an empty name and a binding written as `binding = ""` in + // wrangler.toml. Treat it the same as unset. + let cfg = EnvConfig::from_vars([("EDGEZERO__STORES__KV__SESSIONS__NAME", "")]); + assert_eq!(cfg.store_name("kv", "sessions"), "sessions"); + } + + #[test] + fn store_name_falls_back_to_id_when_env_value_is_whitespace_only() { + let cfg = EnvConfig::from_vars([("EDGEZERO__STORES__KV__SESSIONS__NAME", " \t ")]); + assert_eq!(cfg.store_name("kv", "sessions"), "sessions"); + } + + #[test] + fn store_name_falls_back_to_id_when_env_value_has_control_chars() { + // A literal newline or NUL embedded in the override would + // be passed through to `wrangler kv namespace create + // ` and similar. Reject and fall back to the id. + let with_newline = + EnvConfig::from_vars([("EDGEZERO__STORES__KV__SESSIONS__NAME", "prod\nname")]); + assert_eq!(with_newline.store_name("kv", "sessions"), "sessions"); + let with_nul = + EnvConfig::from_vars([("EDGEZERO__STORES__KV__SESSIONS__NAME", "prod\x00name")]); + assert_eq!(with_nul.store_name("kv", "sessions"), "sessions"); + } + + #[test] + fn store_name_accepts_real_world_punctuation() { + // Underscores, dashes, and dots are valid in every platform + // store-name we target. Don't false-reject them. + let cfg = EnvConfig::from_vars([( + "EDGEZERO__STORES__KV__SESSIONS__NAME", + "prod-app_v2.sessions", + )]); + assert_eq!(cfg.store_name("kv", "sessions"), "prod-app_v2.sessions"); + } + + #[test] + fn store_setting_lookup() { + let cfg = sample(); + assert_eq!( + cfg.store_setting("kv", "sessions", "max_list_keys"), + Some("500") + ); + assert_eq!(cfg.store_setting("kv", "sessions", "ttl"), None); + } + + #[test] + fn adapter_and_logging_accessors() { + let cfg = sample(); + assert_eq!(cfg.adapter_host(), Some("0.0.0.0")); + assert_eq!(cfg.adapter_port(), Some("9000")); + assert_eq!(cfg.logging_level(), Some("debug")); + } + + #[test] + fn empty_config_returns_none_and_fallbacks() { + let empty: [(&str, &str); 0] = []; + let cfg = EnvConfig::from_vars(empty); + assert_eq!(cfg.adapter_host(), None); + assert_eq!(cfg.adapter_port(), None); + assert_eq!(cfg.logging_level(), None); + assert_eq!(cfg.store_setting("kv", "sessions", "name"), None); + assert_eq!(cfg.get(&["stores", "kv", "sessions", "name"]), None); + assert_eq!(cfg.store_name("kv", "sessions"), "sessions"); + } + + #[test] + fn non_prefixed_variable_is_ignored() { + let cfg = EnvConfig::from_vars([ + ("PATH", "/usr/bin"), + ("EDGEZERO_HOST", "ignored-no-double-underscore"), + ("EDGEZERO__ADAPTER__HOST", "kept"), + ]); + assert_eq!(cfg.adapter_host(), Some("kept")); + assert_eq!(cfg.get(&["host"]), None); + } + + #[test] + fn malformed_variables_are_skipped() { + // `EDGEZERO__` alone, a trailing `__`, and an interior empty segment + // must all be skipped without panicking. + let cfg = EnvConfig::from_vars([ + ("EDGEZERO__", "empty"), + ("EDGEZERO__ADAPTER__", "trailing"), + ("EDGEZERO__ADAPTER____PORT", "interior-empty"), + ("EDGEZERO__ADAPTER__HOST", "good"), + ]); + assert_eq!(cfg.adapter_host(), Some("good")); + assert_eq!(cfg.adapter_port(), None); + assert_eq!(cfg.get(&["adapter"]), None); + } +} diff --git a/crates/edgezero-core/src/error.rs b/crates/edgezero-core/src/error.rs index f1ed7653..04e8c072 100644 --- a/crates/edgezero-core/src/error.rs +++ b/crates/edgezero-core/src/error.rs @@ -10,49 +10,71 @@ use crate::response::{response_with_body, IntoResponse}; /// Application-level error that carries an HTTP status code. #[derive(Debug, Error)] +#[non_exhaustive] pub enum EdgeError { #[error("{message}")] BadRequest { message: String }, - #[error("no route matched path: {path}")] - NotFound { path: String }, - #[error("method {method} not allowed; allowed: {allowed}")] - MethodNotAllowed { method: Method, allowed: String }, - #[error("validation error: {message}")] - Validation { message: String }, - #[error("service unavailable: {message}")] - ServiceUnavailable { message: String }, #[error("internal error: {source}")] Internal { #[from] source: AnyError, }, + #[error("method {method} not allowed; allowed: {allowed}")] + MethodNotAllowed { method: Method, allowed: String }, + #[error("no route matched path: {path}")] + NotFound { path: String }, + #[error("not implemented: {message}")] + NotImplemented { message: String }, + #[error("service unavailable: {message}")] + ServiceUnavailable { message: String }, + #[error("validation error: {message}")] + Validation { message: String }, } impl EdgeError { - pub fn bad_request(message: impl Into) -> Self { + #[inline] + pub fn bad_request>(message: S) -> Self { EdgeError::BadRequest { message: message.into(), } } - pub fn validation(message: impl Into) -> Self { - EdgeError::Validation { - message: message.into(), + #[inline] + pub fn internal(error: E) -> Self + where + E: Into, + { + EdgeError::Internal { + source: error.into(), } } - pub fn not_found(path: impl Into) -> Self { - EdgeError::NotFound { path: path.into() } + #[must_use] + #[inline] + pub fn message(&self) -> String { + match self { + EdgeError::BadRequest { message } + | EdgeError::Validation { message } + | EdgeError::NotImplemented { message } + | EdgeError::ServiceUnavailable { message } => message.clone(), + EdgeError::NotFound { path } => format!("no route matched path: {path}"), + EdgeError::MethodNotAllowed { method, allowed } => { + format!("method {method} not allowed; allowed: {allowed}") + } + EdgeError::Internal { source } => format!("internal error: {source}"), + } } + #[must_use] + #[inline] pub fn method_not_allowed(method: &Method, allowed: &[Method]) -> Self { let mut names = allowed .iter() - .map(|m| m.as_str().to_string()) + .map(|name| name.as_str().to_owned()) .collect::>(); names.sort(); let allowed_list = if names.is_empty() { - "(none)".to_string() + "(none)".to_owned() } else { names.join(", ") }; @@ -62,54 +84,71 @@ impl EdgeError { } } - pub fn internal(error: E) -> Self - where - E: Into, - { - EdgeError::Internal { - source: error.into(), + #[inline] + pub fn not_found>(path: S) -> Self { + EdgeError::NotFound { path: path.into() } + } + + #[inline] + pub fn not_implemented>(message: S) -> Self { + EdgeError::NotImplemented { + message: message.into(), } } - pub fn service_unavailable(message: impl Into) -> Self { + #[inline] + pub fn service_unavailable>(message: S) -> Self { EdgeError::ServiceUnavailable { message: message.into(), } } + /// Typed access to the wrapped [`AnyError`] for `EdgeError::Internal`. + /// Shadows [`std::error::Error::source`] (auto-derived by `thiserror`) + /// intentionally — the trait method returns a `&dyn Error`, this one + /// returns the concrete `&anyhow::Error` so callers can downcast. + #[expect( + clippy::same_name_method, + reason = "intentional: typed alternative to the trait-object Error::source" + )] + #[must_use] + #[inline] + pub fn source(&self) -> Option<&AnyError> { + match self { + EdgeError::Internal { source } => Some(source), + EdgeError::BadRequest { .. } + | EdgeError::NotFound { .. } + | EdgeError::NotImplemented { .. } + | EdgeError::MethodNotAllowed { .. } + | EdgeError::Validation { .. } + | EdgeError::ServiceUnavailable { .. } => None, + } + } + + #[must_use] + #[inline] pub fn status(&self) -> StatusCode { match self { EdgeError::BadRequest { .. } => StatusCode::BAD_REQUEST, EdgeError::Validation { .. } => StatusCode::UNPROCESSABLE_ENTITY, EdgeError::NotFound { .. } => StatusCode::NOT_FOUND, EdgeError::MethodNotAllowed { .. } => StatusCode::METHOD_NOT_ALLOWED, + EdgeError::NotImplemented { .. } => StatusCode::NOT_IMPLEMENTED, EdgeError::ServiceUnavailable { .. } => StatusCode::SERVICE_UNAVAILABLE, EdgeError::Internal { .. } => StatusCode::INTERNAL_SERVER_ERROR, } } - pub fn message(&self) -> String { - match self { - EdgeError::BadRequest { message } => message.clone(), - EdgeError::Validation { message } => message.clone(), - EdgeError::NotFound { path } => format!("no route matched path: {path}"), - EdgeError::MethodNotAllowed { method, allowed } => { - format!("method {} not allowed; allowed: {}", method, allowed) - } - EdgeError::ServiceUnavailable { message } => message.clone(), - EdgeError::Internal { source } => format!("internal error: {}", source), - } - } - - pub fn source(&self) -> Option<&AnyError> { - match self { - EdgeError::Internal { source } => Some(source), - _ => None, + #[inline] + pub fn validation>(message: S) -> Self { + EdgeError::Validation { + message: message.into(), } } } impl From for EdgeError { + #[inline] fn from(err: ConfigStoreError) -> Self { match err { ConfigStoreError::InvalidKey { message } => EdgeError::bad_request(message), @@ -119,12 +158,9 @@ impl From for EdgeError { } } -fn json_or_text(payload: &T) -> Body { - Body::json(payload).unwrap_or_else(|_| Body::text("internal error")) -} - impl IntoResponse for EdgeError { - fn into_response(self) -> Response { + #[inline] + fn into_response(self) -> Result { let payload = json!({ "error": { "status": self.status().as_u16(), @@ -133,19 +169,24 @@ impl IntoResponse for EdgeError { }); let body = json_or_text(&payload); - let mut response = response_with_body(self.status(), body); + let mut response = response_with_body(self.status(), body)?; response .headers_mut() .insert(CONTENT_TYPE, HeaderValue::from_static("application/json")); - response + Ok(response) } } +fn json_or_text(payload: &T) -> Body { + Body::json(payload).unwrap_or_else(|_| Body::text("internal error")) +} + #[cfg(test)] mod tests { use super::*; use crate::http::Method; use serde::ser; + use std::str; #[test] fn bad_request_sets_status_and_message() { @@ -155,47 +196,17 @@ mod tests { } #[test] - fn method_not_allowed_lists_methods_sorted() { - let err = EdgeError::method_not_allowed(&Method::POST, &[Method::GET, Method::DELETE]); - assert_eq!(err.status(), StatusCode::METHOD_NOT_ALLOWED); - assert!(err.message().contains("allowed: DELETE, GET")); - } - - #[test] - fn internal_wraps_source_error() { - let err = EdgeError::internal(anyhow::anyhow!("boom")); + fn config_store_error_internal_maps_to_internal_server_error() { + let err = EdgeError::from(ConfigStoreError::internal(anyhow::anyhow!("boom"))); assert_eq!(err.status(), StatusCode::INTERNAL_SERVER_ERROR); - assert!(err.message().contains("internal error: boom")); - assert!(err.source().is_some()); - } - - #[test] - fn not_found_sets_status_and_message() { - let err = EdgeError::not_found("/missing"); - assert_eq!(err.status(), StatusCode::NOT_FOUND); - assert!(err.message().contains("/missing")); - } - - #[test] - fn validation_sets_status_and_message() { - let err = EdgeError::validation("invalid input"); - assert_eq!(err.status(), StatusCode::UNPROCESSABLE_ENTITY); - assert_eq!(err.message(), "invalid input"); - assert!(err.source().is_none()); - } - - #[test] - fn method_not_allowed_handles_empty_allowed_list() { - let err = EdgeError::method_not_allowed(&Method::GET, &[]); - assert_eq!(err.status(), StatusCode::METHOD_NOT_ALLOWED); - assert!(err.message().contains("(none)")); + assert!(err.message().contains("boom")); } #[test] - fn service_unavailable_sets_status_and_message() { - let err = EdgeError::service_unavailable("config store unavailable"); - assert_eq!(err.status(), StatusCode::SERVICE_UNAVAILABLE); - assert_eq!(err.message(), "config store unavailable"); + fn config_store_error_invalid_key_maps_to_bad_request() { + let err = EdgeError::from(ConfigStoreError::invalid_key("invalid config key")); + assert_eq!(err.status(), StatusCode::BAD_REQUEST); + assert_eq!(err.message(), "invalid config key"); } #[test] @@ -206,17 +217,27 @@ mod tests { } #[test] - fn config_store_error_invalid_key_maps_to_bad_request() { - let err = EdgeError::from(ConfigStoreError::invalid_key("invalid config key")); - assert_eq!(err.status(), StatusCode::BAD_REQUEST); - assert_eq!(err.message(), "invalid config key"); + fn internal_wraps_source_error() { + let err = EdgeError::internal(anyhow::anyhow!("boom")); + assert_eq!(err.status(), StatusCode::INTERNAL_SERVER_ERROR); + assert!(err.message().contains("internal error: boom")); + assert!(err.source().is_some()); } #[test] - fn config_store_error_internal_maps_to_internal_server_error() { - let err = EdgeError::from(ConfigStoreError::internal(anyhow::anyhow!("boom"))); - assert_eq!(err.status(), StatusCode::INTERNAL_SERVER_ERROR); - assert!(err.message().contains("boom")); + fn into_response_sets_json_payload() { + let response = EdgeError::bad_request("invalid") + .into_response() + .expect("response"); + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + let content_type = response + .headers() + .get(CONTENT_TYPE) + .expect("content-type header"); + assert_eq!(content_type, HeaderValue::from_static("application/json")); + + let body = response.into_body().into_bytes().expect("buffered"); + assert!(str::from_utf8(body.as_ref()).unwrap().contains("invalid")); } #[test] @@ -233,22 +254,42 @@ mod tests { } let body = json_or_text(&FailingSerialize); - assert_eq!(body.as_bytes(), b"internal error"); + assert_eq!(body.as_bytes().expect("buffered"), b"internal error"); } #[test] - fn into_response_sets_json_payload() { - let response = EdgeError::bad_request("invalid").into_response(); - assert_eq!(response.status(), StatusCode::BAD_REQUEST); - let content_type = response - .headers() - .get(CONTENT_TYPE) - .expect("content-type header"); - assert_eq!(content_type, HeaderValue::from_static("application/json")); + fn method_not_allowed_handles_empty_allowed_list() { + let err = EdgeError::method_not_allowed(&Method::GET, &[]); + assert_eq!(err.status(), StatusCode::METHOD_NOT_ALLOWED); + assert!(err.message().contains("(none)")); + } - let body = response.into_body().into_bytes(); - assert!(std::str::from_utf8(body.as_ref()) - .unwrap() - .contains("invalid")); + #[test] + fn method_not_allowed_lists_methods_sorted() { + let err = EdgeError::method_not_allowed(&Method::POST, &[Method::GET, Method::DELETE]); + assert_eq!(err.status(), StatusCode::METHOD_NOT_ALLOWED); + assert!(err.message().contains("allowed: DELETE, GET")); + } + + #[test] + fn not_found_sets_status_and_message() { + let err = EdgeError::not_found("/missing"); + assert_eq!(err.status(), StatusCode::NOT_FOUND); + assert!(err.message().contains("/missing")); + } + + #[test] + fn service_unavailable_sets_status_and_message() { + let err = EdgeError::service_unavailable("config store unavailable"); + assert_eq!(err.status(), StatusCode::SERVICE_UNAVAILABLE); + assert_eq!(err.message(), "config store unavailable"); + } + + #[test] + fn validation_sets_status_and_message() { + let err = EdgeError::validation("invalid input"); + assert_eq!(err.status(), StatusCode::UNPROCESSABLE_ENTITY); + assert_eq!(err.message(), "invalid input"); + assert!(err.source().is_none()); } } diff --git a/crates/edgezero-core/src/extractor.rs b/crates/edgezero-core/src/extractor.rs index 0d9e1563..c96d8207 100644 --- a/crates/edgezero-core/src/extractor.rs +++ b/crates/edgezero-core/src/extractor.rs @@ -8,6 +8,9 @@ use validator::Validate; use crate::context::RequestContext; use crate::error::EdgeError; use crate::http::HeaderMap; +use crate::store_registry::{ + BoundConfigStore, BoundKvStore, BoundSecretStore, ConfigRegistry, KvRegistry, SecretRegistry, +}; #[async_trait(?Send)] pub trait FromRequest: Sized { @@ -21,6 +24,7 @@ impl FromRequest for Json where T: DeserializeOwned + Send + 'static, { + #[inline] async fn from_request(ctx: &RequestContext) -> Result { ctx.json().map(Json) } @@ -29,18 +33,21 @@ where impl Deref for Json { type Target = T; + #[inline] fn deref(&self) -> &Self::Target { &self.0 } } impl DerefMut for Json { + #[inline] fn deref_mut(&mut self) -> &mut Self::Target { &mut self.0 } } impl Json { + #[inline] pub fn into_inner(self) -> T { self.0 } @@ -53,6 +60,7 @@ impl FromRequest for ValidatedJson where T: DeserializeOwned + Validate + Send + 'static, { + #[inline] async fn from_request(ctx: &RequestContext) -> Result { let Json(value) = Json::::from_request(ctx).await?; value @@ -65,18 +73,21 @@ where impl Deref for ValidatedJson { type Target = T; + #[inline] fn deref(&self) -> &Self::Target { &self.0 } } impl DerefMut for ValidatedJson { + #[inline] fn deref_mut(&mut self) -> &mut Self::Target { &mut self.0 } } impl ValidatedJson { + #[inline] pub fn into_inner(self) -> T { self.0 } @@ -86,6 +97,7 @@ pub struct Headers(pub HeaderMap); #[async_trait(?Send)] impl FromRequest for Headers { + #[inline] async fn from_request(ctx: &RequestContext) -> Result { Ok(Headers(ctx.request().headers().clone())) } @@ -94,18 +106,22 @@ impl FromRequest for Headers { impl Deref for Headers { type Target = HeaderMap; + #[inline] fn deref(&self) -> &Self::Target { &self.0 } } impl DerefMut for Headers { + #[inline] fn deref_mut(&mut self) -> &mut Self::Target { &mut self.0 } } impl Headers { + #[must_use] + #[inline] pub fn into_inner(self) -> HeaderMap { self.0 } @@ -126,13 +142,14 @@ pub struct Host(pub String); #[async_trait(?Send)] impl FromRequest for Host { + #[inline] async fn from_request(ctx: &RequestContext) -> Result { let headers = ctx.request().headers(); let host = headers .get(header::HOST) - .and_then(|v| v.to_str().ok()) + .and_then(|value| value.to_str().ok()) .unwrap_or("localhost") - .to_string(); + .to_owned(); Ok(Host(host)) } } @@ -140,12 +157,15 @@ impl FromRequest for Host { impl Deref for Host { type Target = String; + #[inline] fn deref(&self) -> &Self::Target { &self.0 } } impl Host { + #[must_use] + #[inline] pub fn into_inner(self) -> String { self.0 } @@ -171,14 +191,15 @@ pub struct ForwardedHost(pub String); #[async_trait(?Send)] impl FromRequest for ForwardedHost { + #[inline] async fn from_request(ctx: &RequestContext) -> Result { let headers = ctx.request().headers(); let host = headers .get("x-forwarded-host") .or_else(|| headers.get(header::HOST)) - .and_then(|v| v.to_str().ok()) + .and_then(|value| value.to_str().ok()) .unwrap_or("localhost") - .to_string(); + .to_owned(); Ok(ForwardedHost(host)) } } @@ -186,12 +207,15 @@ impl FromRequest for ForwardedHost { impl Deref for ForwardedHost { type Target = String; + #[inline] fn deref(&self) -> &Self::Target { &self.0 } } impl ForwardedHost { + #[must_use] + #[inline] pub fn into_inner(self) -> String { self.0 } @@ -204,6 +228,7 @@ impl FromRequest for Query where T: DeserializeOwned + Send + 'static, { + #[inline] async fn from_request(ctx: &RequestContext) -> Result { ctx.query().map(Query) } @@ -212,18 +237,21 @@ where impl Deref for Query { type Target = T; + #[inline] fn deref(&self) -> &Self::Target { &self.0 } } impl DerefMut for Query { + #[inline] fn deref_mut(&mut self) -> &mut Self::Target { &mut self.0 } } impl Query { + #[inline] pub fn into_inner(self) -> T { self.0 } @@ -236,6 +264,7 @@ impl FromRequest for ValidatedQuery where T: DeserializeOwned + Validate + Send + 'static, { + #[inline] async fn from_request(ctx: &RequestContext) -> Result { let Query(value) = Query::::from_request(ctx).await?; value @@ -248,18 +277,21 @@ where impl Deref for ValidatedQuery { type Target = T; + #[inline] fn deref(&self) -> &Self::Target { &self.0 } } impl DerefMut for ValidatedQuery { + #[inline] fn deref_mut(&mut self) -> &mut Self::Target { &mut self.0 } } impl ValidatedQuery { + #[inline] pub fn into_inner(self) -> T { self.0 } @@ -272,6 +304,7 @@ impl FromRequest for Path where T: DeserializeOwned + Send + 'static, { + #[inline] async fn from_request(ctx: &RequestContext) -> Result { ctx.path().map(Path) } @@ -280,18 +313,21 @@ where impl Deref for Path { type Target = T; + #[inline] fn deref(&self) -> &Self::Target { &self.0 } } impl DerefMut for Path { + #[inline] fn deref_mut(&mut self) -> &mut Self::Target { &mut self.0 } } impl Path { + #[inline] pub fn into_inner(self) -> T { self.0 } @@ -304,6 +340,7 @@ impl FromRequest for ValidatedPath where T: DeserializeOwned + Validate + Send + 'static, { + #[inline] async fn from_request(ctx: &RequestContext) -> Result { let Path(value) = Path::::from_request(ctx).await?; value @@ -316,18 +353,21 @@ where impl Deref for ValidatedPath { type Target = T; + #[inline] fn deref(&self) -> &Self::Target { &self.0 } } impl DerefMut for ValidatedPath { + #[inline] fn deref_mut(&mut self) -> &mut Self::Target { &mut self.0 } } impl ValidatedPath { + #[inline] pub fn into_inner(self) -> T { self.0 } @@ -340,6 +380,7 @@ impl FromRequest for Form where T: DeserializeOwned + Send + 'static, { + #[inline] async fn from_request(ctx: &RequestContext) -> Result { ctx.form().map(Form) } @@ -348,18 +389,21 @@ where impl Deref for Form { type Target = T; + #[inline] fn deref(&self) -> &Self::Target { &self.0 } } impl DerefMut for Form { + #[inline] fn deref_mut(&mut self) -> &mut Self::Target { &mut self.0 } } impl Form { + #[inline] pub fn into_inner(self) -> T { self.0 } @@ -372,6 +416,7 @@ impl FromRequest for ValidatedForm where T: DeserializeOwned + Validate + Send + 'static, { + #[inline] async fn from_request(ctx: &RequestContext) -> Result { let Form(value) = Form::::from_request(ctx).await?; value @@ -384,119 +429,224 @@ where impl Deref for ValidatedForm { type Target = T; + #[inline] fn deref(&self) -> &Self::Target { &self.0 } } impl DerefMut for ValidatedForm { + #[inline] fn deref_mut(&mut self) -> &mut Self::Target { &mut self.0 } } impl ValidatedForm { + #[inline] pub fn into_inner(self) -> T { self.0 } } -/// Extracts the [`KvHandle`] from the request context. +/// Extractor that yields the per-request [`KvRegistry`]. /// -/// Returns `EdgeError::Internal` if no KV store was configured for this request. +/// Handlers pick a bound store by id at the call site: /// -/// # Example /// ```ignore /// #[action] -/// pub async fn handler(Kv(store): Kv) -> Result { +/// pub async fn handler(kv: Kv) -> Result { +/// let store = kv.default().ok_or_else(|| EdgeError::internal(anyhow::anyhow!("no default kv")))?; /// let count: i32 = store.get_or("visits", 0).await?; /// store.put("visits", &(count + 1)).await?; /// Ok(format!("visits: {}", count + 1)) /// } /// ``` -#[derive(Debug)] -pub struct Kv(pub crate::key_value_store::KvHandle); +/// +/// Or, for a non-default id: +/// +/// ```ignore +/// let cache = kv.named("cache").ok_or_else(|| EdgeError::internal(anyhow::anyhow!("no `cache` kv")))?; +/// ``` +#[derive(Clone, Debug)] +pub struct Kv(KvRegistry); #[async_trait(?Send)] impl FromRequest for Kv { + #[inline] async fn from_request(ctx: &RequestContext) -> Result { - ctx.kv_handle().map(Kv).ok_or_else(|| { - EdgeError::internal(anyhow::anyhow!( - "no kv store configured -- check [stores.kv] in edgezero.toml and platform bindings" - )) - }) - } -} - -impl std::ops::Deref for Kv { - type Target = crate::key_value_store::KvHandle; - - fn deref(&self) -> &Self::Target { - &self.0 - } -} - -impl std::ops::DerefMut for Kv { - fn deref_mut(&mut self) -> &mut Self::Target { - &mut self.0 + // Spec hard-cutoff (§ intro): no backward compatibility for + // the pre-rewrite runtime store API. Pre-Stage-9.3 this + // extractor silently synthesised a one-id registry from a + // lone `ctx.kv_handle()` when no `KvRegistry` was wired, + // which masked missing registry wiring. Adapter dispatchers + // (axum / cloudflare / fastly / spin) now normalise + // legacy bare-handle inputs to single-id registries at the + // dispatch boundary, so this path no longer needs a + // fallback — a missing registry is a real bug. + ctx.request() + .extensions() + .get::() + .cloned() + .map(Kv) + .ok_or_else(|| { + EdgeError::internal(anyhow::anyhow!( + "no kv store configured -- check [stores.kv] in edgezero.toml and platform bindings" + )) + }) } } impl Kv { - pub fn into_inner(self) -> crate::key_value_store::KvHandle { - self.0 + /// Resolve the default [`BoundKvStore`]. + #[must_use] + #[inline] + pub fn default(&self) -> Option { + self.0.default() + } + + /// Resolve the [`BoundKvStore`] for `id`. Strict lookup — unknown ids + /// yield `None`. + #[must_use] + #[inline] + pub fn named(&self, id: &str) -> Option { + self.0.named(id) + } + + /// Access the underlying registry directly (rarely needed; most handlers + /// should use [`Self::default`] / [`Self::named`]). + #[must_use] + #[inline] + pub fn registry(&self) -> &KvRegistry { + &self.0 } } -/// Extracts the [`SecretHandle`] from the request context. +/// Extractor that yields the per-request [`SecretRegistry`]. /// -/// Returns `EdgeError::Internal` if no secret store was configured for this request. +/// The returned [`BoundSecretStore`] is pre-bound to a platform store name +/// (resolved per id from `EDGEZERO__STORES__SECRETS____NAME`), so +/// handler code passes only the key: /// -/// # Example /// ```ignore /// #[action] -/// pub async fn handler(Secrets(secrets): Secrets) -> Result { -/// let key = secrets.require_str("api-keys", "API_KEY").await.map_err(EdgeError::from)?; -/// // use key ... +/// pub async fn handler(secrets: Secrets) -> Result { +/// let bound = secrets.default().ok_or_else(|| EdgeError::internal(anyhow::anyhow!("no secrets")))?; +/// let key = bound.require_str("API_KEY").await.map_err(EdgeError::from)?; +/// // ... /// } /// ``` -#[derive(Debug)] -pub struct Secrets(pub crate::secret_store::SecretHandle); +#[derive(Clone, Debug)] +pub struct Secrets(SecretRegistry); #[async_trait(?Send)] impl FromRequest for Secrets { + #[inline] async fn from_request(ctx: &RequestContext) -> Result { - // ctx.secret_handle() returns a handle object, not secret bytes. - // The error message below contains only store configuration info — no secret values - // are included, so this is safe from a cleartext-logging perspective. - ctx.secret_handle().map(Secrets).ok_or_else(|| { - EdgeError::internal(anyhow::anyhow!( - "no secret store configured -- check [stores.secrets] in edgezero.toml and platform bindings" - )) - }) + // Hard-cutoff: see `impl FromRequest for Kv`. Adapter + // dispatchers normalise legacy bare-handle inputs to + // single-id `SecretRegistry`s at the dispatch boundary. + ctx.request() + .extensions() + .get::() + .cloned() + .map(Secrets) + .ok_or_else(|| { + EdgeError::internal(anyhow::anyhow!( + "no secret store configured -- check [stores.secrets] in edgezero.toml and platform bindings" + )) + }) } } -impl std::ops::Deref for Secrets { - type Target = crate::secret_store::SecretHandle; +impl Secrets { + /// Resolve the default [`BoundSecretStore`]. + #[must_use] + #[inline] + pub fn default(&self) -> Option { + self.0.default() + } - fn deref(&self) -> &Self::Target { + /// Resolve the [`BoundSecretStore`] for `id`. Strict lookup — unknown ids + /// yield `None`. + #[must_use] + #[inline] + pub fn named(&self, id: &str) -> Option { + self.0.named(id) + } + + /// Access the underlying registry directly. + #[must_use] + #[inline] + pub fn registry(&self) -> &SecretRegistry { &self.0 } } -impl std::ops::DerefMut for Secrets { - fn deref_mut(&mut self) -> &mut Self::Target { - &mut self.0 +/// Extractor that yields the per-request [`ConfigRegistry`]. +/// +/// ```ignore +/// #[action] +/// pub async fn handler(config: Config) -> Result { +/// let bound = config.default().ok_or_else(|| EdgeError::internal(anyhow::anyhow!("no config")))?; +/// let greeting = bound.get("greeting").await?.unwrap_or_default(); +/// // ... +/// } +/// ``` +#[derive(Clone, Debug)] +pub struct Config(ConfigRegistry); + +#[async_trait(?Send)] +impl FromRequest for Config { + #[inline] + async fn from_request(ctx: &RequestContext) -> Result { + // Hard-cutoff: see `impl FromRequest for Kv`. Adapter + // dispatchers normalise legacy bare-handle inputs to + // single-id `ConfigRegistry`s at the dispatch boundary. + ctx.request() + .extensions() + .get::() + .cloned() + .map(Config) + .ok_or_else(|| { + EdgeError::internal(anyhow::anyhow!( + "no config store configured -- check [stores.config] in edgezero.toml and platform bindings" + )) + }) } } -impl Secrets { - pub fn into_inner(self) -> crate::secret_store::SecretHandle { - self.0 +impl Config { + /// Resolve the default [`BoundConfigStore`]. + #[must_use] + #[inline] + pub fn default(&self) -> Option { + self.0.default() + } + + /// Resolve the [`BoundConfigStore`] for `id`. Strict lookup — unknown ids + /// yield `None`. + #[must_use] + #[inline] + pub fn named(&self, id: &str) -> Option { + self.0.named(id) + } + + /// Access the underlying registry directly. + #[must_use] + #[inline] + pub fn registry(&self) -> &ConfigRegistry { + &self.0 } } +// removed the private `single_id_registry` helper that +// the Kv/Config/Secrets extractors used to synthesise a one-id +// registry from a legacy bare handle. The equivalent normalisation +// now happens at each adapter's dispatch boundary via +// `StoreRegistry::single_id`, so this fallback is no longer +// reachable from the extractor path. + #[cfg(test)] mod tests { use super::*; @@ -504,26 +654,21 @@ mod tests { use crate::context::RequestContext; use crate::http::{request_builder, HeaderValue, Method, StatusCode}; use crate::params::PathParams; + use crate::store_registry::StoreRegistry; use futures::executor::block_on; use serde::{Deserialize, Serialize}; use std::collections::HashMap; use validator::Validate; - fn ctx(body: Body, params: PathParams) -> RequestContext { - let request = request_builder() - .method(Method::POST) - .uri("/test") - .body(body) - .expect("request"); - RequestContext::new(request, params) + #[derive(Debug, Deserialize, PartialEq)] + struct FormData { + age: Option, + username: String, } - fn params(values: &[(&str, &str)]) -> PathParams { - let map = values - .iter() - .map(|(k, v)| (k.to_string(), v.to_string())) - .collect::>(); - PathParams::new(map) + #[derive(Debug, Deserialize, PartialEq)] + struct PathPayload { + id: String, } #[derive(Debug, Deserialize, Serialize, PartialEq)] @@ -531,17 +676,74 @@ mod tests { name: String, } + #[derive(Debug, Deserialize, PartialEq)] + struct QueryParams { + page: Option, + #[serde(rename = "q")] + query_term: Option, + } + + #[derive(Debug, Deserialize, Validate)] + struct ValidatedFormData { + #[validate(length(min = 3_u64))] + username: String, + } + #[derive(Debug, Deserialize, Serialize, Validate)] struct ValidatedPayload { - #[validate(length(min = 1))] + #[validate(length(min = 1_u64))] name: String, } - #[derive(Debug, Deserialize, PartialEq)] - struct PathPayload { + #[derive(Debug, Deserialize, Validate)] + struct ValidatedPathParams { + #[validate(length(min = 1_u64, max = 10_u64))] id: String, } + #[derive(Debug, Deserialize, Validate)] + struct ValidatedQueryParams { + #[validate(range(min = 1_u32, max = 100_u32))] + page: u32, + } + + fn ctx(body: Body, params: PathParams) -> RequestContext { + let request = request_builder() + .method(Method::POST) + .uri("/test") + .body(body) + .expect("request"); + RequestContext::new(request, params) + } + + fn ctx_with_form(body: &str) -> RequestContext { + let request = request_builder() + .method(Method::POST) + .uri("/test") + .header("content-type", "application/x-www-form-urlencoded") + .body(Body::from(body.to_owned())) + .expect("request"); + RequestContext::new(request, PathParams::default()) + } + + fn ctx_with_query(query: &str) -> RequestContext { + let uri = format!("/test?{query}"); + let request = request_builder() + .method(Method::GET) + .uri(uri) + .body(Body::empty()) + .expect("request"); + RequestContext::new(request, PathParams::default()) + } + + fn params(values: &[(&str, &str)]) -> PathParams { + let map = values + .iter() + .map(|(key, value)| ((*key).to_owned(), (*value).to_owned())) + .collect::>(); + PathParams::new(map) + } + #[test] fn json_extractor_parses_payload() { let body = Body::json(&Payload { @@ -564,7 +766,10 @@ mod tests { #[test] fn validated_json_rejects_invalid_payloads() { - let body = Body::json(&ValidatedPayload { name: "".into() }).expect("json"); + let body = Body::json(&ValidatedPayload { + name: String::new(), + }) + .expect("json"); let ctx = ctx(body, PathParams::default()); let err = block_on(ValidatedJson::::from_request(&ctx)) .err() @@ -587,34 +792,20 @@ mod tests { .insert("x-test", HeaderValue::from_static("value")); let headers = block_on(Headers::from_request(&ctx)).expect("headers"); assert_eq!( - headers.get("x-test").and_then(|v| v.to_str().ok()).unwrap(), + headers + .get("x-test") + .and_then(|value| value.to_str().ok()) + .unwrap(), "value" ); } - // Query extractor tests - #[derive(Debug, Deserialize, PartialEq)] - struct QueryParams { - page: Option, - q: Option, - } - - fn ctx_with_query(query: &str) -> RequestContext { - let uri = format!("/test?{}", query); - let request = request_builder() - .method(Method::GET) - .uri(uri) - .body(Body::empty()) - .expect("request"); - RequestContext::new(request, PathParams::default()) - } - #[test] fn query_extractor_parses_params() { let ctx = ctx_with_query("page=5&q=hello"); let query = block_on(Query::::from_request(&ctx)).expect("query"); assert_eq!(query.page, Some(5)); - assert_eq!(query.q.as_deref(), Some("hello")); + assert_eq!(query.query_term.as_deref(), Some("hello")); } #[test] @@ -622,7 +813,7 @@ mod tests { let ctx = ctx_with_query("page=1"); let query = block_on(Query::::from_request(&ctx)).expect("query"); assert_eq!(query.page, Some(1)); - assert_eq!(query.q, None); + assert_eq!(query.query_term, None); } #[test] @@ -635,13 +826,7 @@ mod tests { let ctx = RequestContext::new(request, PathParams::default()); let query = block_on(Query::::from_request(&ctx)).expect("query"); assert_eq!(query.page, None); - assert_eq!(query.q, None); - } - - #[derive(Debug, Deserialize, Validate)] - struct ValidatedQueryParams { - #[validate(range(min = 1, max = 100))] - page: u32, + assert_eq!(query.query_term, None); } #[test] @@ -661,23 +846,6 @@ mod tests { assert_eq!(err.status(), StatusCode::UNPROCESSABLE_ENTITY); } - // Form extractor tests - fn ctx_with_form(body: &str) -> RequestContext { - let request = request_builder() - .method(Method::POST) - .uri("/test") - .header("content-type", "application/x-www-form-urlencoded") - .body(Body::from(body.to_string())) - .expect("request"); - RequestContext::new(request, PathParams::default()) - } - - #[derive(Debug, Deserialize, PartialEq)] - struct FormData { - username: String, - age: Option, - } - #[test] fn form_extractor_parses_urlencoded_body() { let ctx = ctx_with_form("username=alice&age=30"); @@ -694,12 +862,6 @@ mod tests { assert_eq!(form.age, None); } - #[derive(Debug, Deserialize, Validate)] - struct ValidatedFormData { - #[validate(length(min = 3))] - username: String, - } - #[test] fn validated_form_accepts_valid_data() { let ctx = ctx_with_form("username=alice"); @@ -716,13 +878,6 @@ mod tests { assert_eq!(err.status(), StatusCode::UNPROCESSABLE_ENTITY); } - // ValidatedPath tests - #[derive(Debug, Deserialize, Validate)] - struct ValidatedPathParams { - #[validate(length(min = 1, max = 10))] - id: String, - } - #[test] fn validated_path_accepts_valid_params() { let ctx = ctx(Body::empty(), params(&[("id", "abc123")])); @@ -762,7 +917,7 @@ mod tests { fn query_deref_and_into_inner() { let query = Query(QueryParams { page: Some(1), - q: None, + query_term: None, }); assert_eq!(query.page, Some(1)); // Deref let inner = query.into_inner(); @@ -773,7 +928,7 @@ mod tests { fn query_deref_mut() { let mut query = Query(QueryParams { page: Some(1), - q: None, + query_term: None, }); query.page = Some(2); // DerefMut assert_eq!(query.page, Some(2)); @@ -946,7 +1101,7 @@ mod tests { #[test] fn host_deref_and_into_inner() { - let host = Host("example.com".to_string()); + let host = Host("example.com".to_owned()); assert_eq!(&*host, "example.com"); // Deref let inner = host.into_inner(); assert_eq!(inner, "example.com"); @@ -1000,16 +1155,27 @@ mod tests { #[test] fn forwarded_host_deref_and_into_inner() { - let host = ForwardedHost("example.com".to_string()); + let host = ForwardedHost("example.com".to_owned()); assert_eq!(&*host, "example.com"); // Deref let inner = host.into_inner(); assert_eq!(inner, "example.com"); } - // -- Kv extractor ------------------------------------------------------- + // -- Kv / Secrets / Config extractors (registry-aware) ----------------- #[test] - fn kv_extractor_returns_handle_when_configured() { + fn kv_extractor_errors_when_only_legacy_handle_wired() { + // Hard-cutoff: the extractor used to synthesise + // a one-id registry from a lone `ctx.kv_handle()` when no + // `KvRegistry` was in extensions. That path silently + // masked missing registry wiring, which violates the + // spec's "no backward compatibility" promise for the + // runtime store API. Adapter dispatchers (axum / + // cloudflare / fastly / spin) now normalise legacy bare- + // handle inputs to a single-id `KvRegistry` at the + // dispatch boundary, so this code path only fires when a + // test or callsite bypasses a dispatcher. In that case + // the extractor must surface the wiring bug. use crate::key_value_store::{KvHandle, NoopKvStore}; use std::sync::Arc; @@ -1023,8 +1189,43 @@ mod tests { .insert(KvHandle::new(Arc::new(NoopKvStore))); let ctx = RequestContext::new(request, PathParams::default()); - let kv = block_on(Kv::from_request(&ctx)); - assert!(kv.is_ok()); + let err = block_on(Kv::from_request(&ctx)) + .expect_err("extractor must surface missing-registry as an error, not auto-upgrade"); + assert!( + err.message().contains("no kv store configured"), + "error names the wiring gap: {err:?}" + ); + } + + #[test] + fn kv_extractor_prefers_registry_over_legacy_handle() { + use crate::key_value_store::{KvHandle, NoopKvStore}; + use std::collections::BTreeMap; + use std::sync::Arc; + + let registry: KvRegistry = StoreRegistry::new( + [ + ("sessions".to_owned(), KvHandle::new(Arc::new(NoopKvStore))), + ("cache".to_owned(), KvHandle::new(Arc::new(NoopKvStore))), + ] + .into_iter() + .collect::>(), + "sessions".to_owned(), + ); + + let mut request = request_builder() + .method(Method::GET) + .uri("/kv") + .body(Body::empty()) + .expect("request"); + request.extensions_mut().insert(registry); + + let ctx = RequestContext::new(request, PathParams::default()); + let kv = block_on(Kv::from_request(&ctx)).expect("Kv extractor when registry present"); + assert!(kv.named("sessions").is_some()); + assert!(kv.named("cache").is_some()); + assert!(kv.named("unknown").is_none()); + assert_eq!(kv.registry().default_id(), "sessions"); } #[test] @@ -1041,53 +1242,180 @@ mod tests { } #[test] - fn kv_deref_and_into_inner() { - use crate::key_value_store::{KvHandle, NoopKvStore}; + fn secrets_extractor_errors_when_only_legacy_handle_wired() { + // Hard-cutoff — same semantics as + // `kv_extractor_errors_when_only_legacy_handle_wired`. + use crate::secret_store::{NoopSecretStore, SecretHandle}; + use std::sync::Arc; + + let mut request = request_builder() + .method(Method::GET) + .uri("/secrets") + .body(Body::empty()) + .expect("request"); + request + .extensions_mut() + .insert(SecretHandle::new(Arc::new(NoopSecretStore))); + let ctx = RequestContext::new(request, PathParams::default()); + let err = block_on(Secrets::from_request(&ctx)) + .expect_err("extractor must surface missing-registry as an error"); + assert!( + err.message().contains("no secret store configured"), + "error names the wiring gap: {err:?}" + ); + } + + #[test] + fn secrets_extractor_preserves_registry_per_id_platform_name() { + use crate::secret_store::{NoopSecretStore, SecretHandle}; + use std::collections::BTreeMap; use std::sync::Arc; - let handle = KvHandle::new(Arc::new(NoopKvStore)); - let kv = Kv(handle); + let handle = SecretHandle::new(Arc::new(NoopSecretStore)); + let by_id: BTreeMap = [ + ( + "primary".to_owned(), + BoundSecretStore::new(handle.clone(), "primary-vault".to_owned()), + ), + ( + "analytics".to_owned(), + BoundSecretStore::new(handle, "analytics-vault".to_owned()), + ), + ] + .into_iter() + .collect(); + let registry: SecretRegistry = StoreRegistry::new(by_id, "primary".to_owned()); - // Debug works - let debug = format!("{:?}", kv); - assert!(debug.contains("Kv")); + let mut request = request_builder() + .method(Method::GET) + .uri("/secrets") + .body(Body::empty()) + .expect("request"); + request.extensions_mut().insert(registry); + let ctx = RequestContext::new(request, PathParams::default()); - // Deref works - let _: &KvHandle = &kv; + let secrets = + block_on(Secrets::from_request(&ctx)).expect("Secrets extractor when registry present"); + // The per-id binding survives the extractor — each named store + // resolves to its own platform name. + assert_eq!( + secrets.named("primary").expect("primary").store_name(), + "primary-vault" + ); + assert_eq!( + secrets.named("analytics").expect("analytics").store_name(), + "analytics-vault" + ); + assert_eq!( + secrets.default().expect("default").store_name(), + "primary-vault" + ); + assert!(secrets.named("missing").is_none()); + } - // into_inner works - let _inner: KvHandle = kv.into_inner(); + #[test] + fn secrets_extractor_errors_when_absent() { + let request = request_builder() + .method(Method::GET) + .uri("/secrets") + .body(Body::empty()) + .expect("request"); + let ctx = RequestContext::new(request, PathParams::default()); + let err = block_on(Secrets::from_request(&ctx)).unwrap_err(); + assert_eq!(err.status(), StatusCode::INTERNAL_SERVER_ERROR); } - // -- Secrets extractor -------------------------------------------------- + #[test] + fn config_extractor_resolves_from_registry() { + use crate::config_store::{ConfigStore, ConfigStoreError, ConfigStoreHandle}; + use std::collections::BTreeMap; + use std::sync::Arc; + + struct FixedStore(&'static str); + #[async_trait(?Send)] + impl ConfigStore for FixedStore { + async fn get(&self, _key: &str) -> Result, ConfigStoreError> { + Ok(Some(self.0.to_owned())) + } + } + + let registry: ConfigRegistry = StoreRegistry::new( + [ + ( + "primary".to_owned(), + ConfigStoreHandle::new(Arc::new(FixedStore("primary"))), + ), + ( + "analytics".to_owned(), + ConfigStoreHandle::new(Arc::new(FixedStore("analytics"))), + ), + ] + .into_iter() + .collect::>(), + "primary".to_owned(), + ); + + let mut request = request_builder() + .method(Method::GET) + .uri("/config") + .body(Body::empty()) + .expect("request"); + request.extensions_mut().insert(registry); + + let ctx = RequestContext::new(request, PathParams::default()); + let config = + block_on(Config::from_request(&ctx)).expect("Config extractor when registry present"); + let analytics = config.named("analytics").expect("analytics handle"); + assert_eq!( + block_on(analytics.get("any")).expect("config value"), + Some("analytics".to_owned()) + ); + assert!(config.named("missing").is_none()); + assert!(config.default().is_some()); + } #[test] - fn secrets_extractor_returns_handle_when_present() { - use crate::secret_store::{NoopSecretStore, SecretHandle}; + fn config_extractor_errors_when_only_legacy_handle_wired() { + // Hard-cutoff — same semantics as + // `kv_extractor_errors_when_only_legacy_handle_wired`. + use crate::config_store::{ConfigStore, ConfigStoreError, ConfigStoreHandle}; use std::sync::Arc; + struct AnyStore; + #[async_trait(?Send)] + impl ConfigStore for AnyStore { + async fn get(&self, _key: &str) -> Result, ConfigStoreError> { + Ok(Some("legacy".to_owned())) + } + } + let mut request = request_builder() .method(Method::GET) - .uri("/secrets") + .uri("/config") .body(Body::empty()) .expect("request"); request .extensions_mut() - .insert(SecretHandle::new(Arc::new(NoopSecretStore))); + .insert(ConfigStoreHandle::new(Arc::new(AnyStore))); let ctx = RequestContext::new(request, PathParams::default()); - let result = block_on(Secrets::from_request(&ctx)); - assert!(result.is_ok()); + let err = block_on(Config::from_request(&ctx)) + .expect_err("extractor must surface missing-registry as an error"); + assert!( + err.message().contains("no config store configured"), + "error names the wiring gap: {err:?}" + ); } #[test] - fn secrets_extractor_errors_when_absent() { + fn config_extractor_errors_when_absent() { let request = request_builder() .method(Method::GET) - .uri("/secrets") + .uri("/config") .body(Body::empty()) .expect("request"); let ctx = RequestContext::new(request, PathParams::default()); - let err = block_on(Secrets::from_request(&ctx)).unwrap_err(); + let err = block_on(Config::from_request(&ctx)).expect_err("expected error"); assert_eq!(err.status(), StatusCode::INTERNAL_SERVER_ERROR); + assert!(err.message().contains("check [stores.config]")); } } diff --git a/crates/edgezero-core/src/handler.rs b/crates/edgezero-core/src/handler.rs index 0696c91a..17fd4831 100644 --- a/crates/edgezero-core/src/handler.rs +++ b/crates/edgezero-core/src/handler.rs @@ -16,12 +16,10 @@ where Fut: Future> + 'static, Res: IntoResponse, { + #[inline] fn call(&self, ctx: RequestContext) -> HandlerFuture { let fut = (self)(ctx); - Box::pin(async move { - let response = fut.await?.into_response(); - Ok(response) - }) + Box::pin(async move { fut.await?.into_response() }) } } @@ -35,6 +33,7 @@ impl IntoHandler for H where H: DynHandler + Sized + 'static, { + #[inline] fn into_handler(self) -> BoxHandler { Arc::new(self) } diff --git a/crates/edgezero-core/src/http.rs b/crates/edgezero-core/src/http.rs index 871d9a32..60ead491 100644 --- a/crates/edgezero-core/src/http.rs +++ b/crates/edgezero-core/src/http.rs @@ -1,31 +1,49 @@ +/// Re-exports of [`http::header`] used by adapters and handlers. +pub mod header { + #![expect( + clippy::pub_use, + reason = "header constants/types must be re-exported through this module to satisfy the \ + CLAUDE.md `edgezero_core::http` facade rule; downstream code must not depend on \ + the `http` crate directly" + )] + pub use http::header::*; +} + use std::future::Future; use std::pin::Pin; +use http::request::Builder as HttpRequestBuilder; +use http::response::Builder as HttpResponseBuilder; + use crate::body::Body; use crate::error::EdgeError; -pub use http::header; -pub use http::request::Builder as RequestBuilder; -pub use http::response::Builder as ResponseBuilder; - -pub type Method = http::Method; -pub type StatusCode = http::StatusCode; +// CLAUDE.md mandates that application code never imports from the `http` +// crate directly — every HTTP type must come through `edgezero_core::http`. +// `Builder` types are exposed via `pub type` aliases (not `pub use`) so +// only the `header` re-export remains, scoped to its own child module. +pub type Extensions = http::Extensions; +pub type HandlerFuture = Pin> + 'static>>; pub type HeaderMap = http::HeaderMap; +pub type HeaderName = header::HeaderName; pub type HeaderValue = http::HeaderValue; -pub type HeaderName = http::header::HeaderName; +pub type Method = http::Method; +pub type Request = http::Request; +pub type RequestBuilder = HttpRequestBuilder; +pub type Response = http::Response; +pub type ResponseBuilder = HttpResponseBuilder; +pub type StatusCode = http::StatusCode; pub type Uri = http::Uri; pub type Version = http::Version; -pub type Extensions = http::Extensions; +#[must_use] +#[inline] pub fn request_builder() -> RequestBuilder { http::Request::builder() } +#[must_use] +#[inline] pub fn response_builder() -> ResponseBuilder { http::Response::builder() } - -pub type Request = http::Request; -pub type Response = http::Response; - -pub type HandlerFuture = Pin> + 'static>>; diff --git a/crates/edgezero-core/src/key_value_store.rs b/crates/edgezero-core/src/key_value_store.rs index 1e7b535f..268b8004 100644 --- a/crates/edgezero-core/src/key_value_store.rs +++ b/crates/edgezero-core/src/key_value_store.rs @@ -23,22 +23,28 @@ //! //! # Usage //! -//! Access the KV store in a handler via [`crate::context::RequestContext::kv_handle`]: +//! Use the [`crate::extractor::Kv`] extractor with the `#[action]` +//! macro and pick a store by id at the call site (portable +//! store API): //! //! ```rust,ignore -//! async fn visit_counter(ctx: RequestContext) -> Result { -//! let kv = ctx.kv_handle().expect("kv store configured"); -//! let count: i32 = kv.read_modify_write("visits", 0, |n| n + 1).await?; +//! #[action] +//! async fn visit_counter(kv: Kv) -> Result { +//! let store = kv +//! .default() +//! .ok_or_else(|| EdgeError::service_unavailable("no default kv"))?; +//! let count: i32 = store.read_modify_write("visits", 0, |n| n + 1).await?; //! Ok(format!("Visit #{count}")) //! } //! ``` //! -//! Or use the [`crate::extractor::Kv`] extractor with the `#[action]` macro: +//! Or reach the store through [`crate::context::RequestContext`] +//! when you have a context instead of an extractor: //! //! ```rust,ignore -//! #[action] -//! async fn visit_counter(Kv(store): Kv) -> Result { -//! let count: i32 = store.read_modify_write("visits", 0, |n| n + 1).await?; +//! async fn visit_counter(ctx: RequestContext) -> Result { +//! let kv = ctx.kv_store_default().expect("default kv configured"); +//! let count: i32 = kv.read_modify_write("visits", 0, |n| n + 1).await?; //! Ok(format!("Visit #{count}")) //! } //! ``` @@ -54,191 +60,279 @@ use serde::{Deserialize, Serialize}; use crate::error::EdgeError; +// --------------------------------------------------------------------------- +// Contract test macro +// --------------------------------------------------------------------------- + +/// Generate a suite of contract tests for any [`KvStore`] implementation. +/// +/// The macro takes the module name and a factory expression that produces a +/// fresh store instance (implementing `KvStore`). It generates a module +/// containing tests that verify the fundamental behaviours every backend +/// must satisfy. +/// +/// # Example +/// +/// ```rust,ignore +/// edgezero_core::key_value_store_contract_tests!(persistent_kv_contract, { +/// let db_path = std::env::temp_dir().join(format!( +/// "edgezero-contract-{}-{:?}.redb", +/// std::process::id(), +/// std::thread::current().id() +/// )); +/// PersistentKvStore::new(db_path).unwrap() +/// }); +/// ``` +#[macro_export] +macro_rules! key_value_store_contract_tests { + ($mod_name:ident, $factory:expr) => { + mod $mod_name { + use super::*; + use bytes::Bytes; + use $crate::key_value_store::KvStore; + + fn run(future: Fut) -> Fut::Output { + ::futures::executor::block_on(future) + } + + #[test] + fn contract_put_and_get() { + let store = $factory; + run(async { + store.put_bytes("k", Bytes::from("v")).await.unwrap(); + assert_eq!(store.get_bytes("k").await.unwrap(), Some(Bytes::from("v"))); + }); + } + + #[test] + fn contract_get_missing_returns_none() { + let store = $factory; + run(async { + assert_eq!(store.get_bytes("missing").await.unwrap(), None); + }); + } + + #[test] + fn contract_put_overwrites() { + let store = $factory; + run(async { + store.put_bytes("k", Bytes::from("first")).await.unwrap(); + store.put_bytes("k", Bytes::from("second")).await.unwrap(); + assert_eq!( + store.get_bytes("k").await.unwrap(), + Some(Bytes::from("second")) + ); + }); + } + + #[test] + fn contract_delete_removes_key() { + let store = $factory; + run(async { + store.put_bytes("k", Bytes::from("v")).await.unwrap(); + store.delete("k").await.unwrap(); + assert_eq!(store.get_bytes("k").await.unwrap(), None); + }); + } + + #[test] + fn contract_delete_nonexistent_ok() { + let store = $factory; + run(async { + store.delete("nope").await.unwrap(); + }); + } + + #[test] + fn contract_exists() { + let store = $factory; + run(async { + assert!(!store.exists("k").await.unwrap()); + store.put_bytes("k", Bytes::from("v")).await.unwrap(); + assert!(store.exists("k").await.unwrap()); + store.delete("k").await.unwrap(); + assert!(!store.exists("k").await.unwrap()); + }); + } + + #[test] + fn contract_put_with_ttl_stores_value() { + let store = $factory; + run(async { + store + .put_bytes_with_ttl( + "ttl_key", + Bytes::from("ttl_val"), + std::time::Duration::from_mins(5), + ) + .await + .unwrap(); + assert_eq!( + store.get_bytes("ttl_key").await.unwrap(), + Some(Bytes::from("ttl_val")) + ); + }); + } + + // `std::thread::sleep` is not available on `wasm32` targets (no + // thread support). The TTL eviction contract is verified on native + // targets only; WASM adapters are expected to delegate eviction to + // the platform runtime (Cloudflare/Fastly), which does not expose a + // synchronous sleep primitive in test environments. + #[cfg(not(target_arch = "wasm32"))] + #[test] + fn contract_ttl_expires() { + let store = $factory; + run(async { + // Uses a sub-second TTL intentionally. Contract tests call + // `KvStore` directly (not `KvHandle`), so the 60-second + // minimum TTL validation is bypassed. This lets us verify + // that the backend actually evicts expired entries. + store + .put_bytes_with_ttl( + "ephemeral", + Bytes::from("gone_soon"), + std::time::Duration::from_millis(1), + ) + .await + .unwrap(); + // Allow the TTL to elapse. 200ms gives the OS scheduler + // enough headroom on busy CI runners. + std::thread::sleep(std::time::Duration::from_millis(200)); + assert_eq!(store.get_bytes("ephemeral").await.unwrap(), None); + }); + } + + #[test] + fn contract_list_keys_page_is_paginated() { + let store = $factory; + run(async { + let expected = vec![ + "app/one".to_owned(), + "app/two".to_owned(), + "other/three".to_owned(), + ]; + for key in &expected { + store + .put_bytes(key, Bytes::from(key.clone())) + .await + .unwrap(); + } + + let mut cursor = None; + let mut seen = std::collections::HashSet::new(); + let mut collected = Vec::new(); + + for _ in 0..expected.len() { + let page = store + .list_keys_page("", cursor.as_deref(), 1) + .await + .unwrap(); + assert!(page.keys.len() <= 1); + for key in &page.keys { + assert!( + seen.insert(key.clone()), + "duplicate key in pagination: {key}" + ); + collected.push(key.clone()); + } + + cursor = page.cursor; + if cursor.is_none() { + break; + } + } + + collected.sort(); + let mut expected_sorted = expected.clone(); + expected_sorted.sort(); + assert_eq!(collected, expected_sorted); + }); + } + + #[test] + fn contract_list_keys_page_respects_prefix() { + let store = $factory; + run(async { + store + .put_bytes("prefix/a", Bytes::from_static(b"a")) + .await + .unwrap(); + store + .put_bytes("prefix/b", Bytes::from_static(b"b")) + .await + .unwrap(); + store + .put_bytes("other/c", Bytes::from_static(b"c")) + .await + .unwrap(); + + let first = store.list_keys_page("prefix/", None, 1).await.unwrap(); + assert_eq!(first.keys.len(), 1); + assert!(first.keys[0].starts_with("prefix/")); + + let second = store + .list_keys_page("prefix/", first.cursor.as_deref(), 1) + .await + .unwrap(); + assert!(second.keys.iter().all(|key| key.starts_with("prefix/"))); + assert!(first + .keys + .iter() + .chain(second.keys.iter()) + .all(|key| key.starts_with("prefix/"))); + }); + } + } + }; +} + // --------------------------------------------------------------------------- // Error // --------------------------------------------------------------------------- +#[derive(Debug, Serialize, Deserialize)] +struct KvCursorEnvelope { + cursor: String, + prefix: String, +} + /// Errors returned by KV store operations. #[derive(Debug, thiserror::Error)] +#[non_exhaustive] pub enum KvError { + /// A general internal error. + #[error("kv store error: {0}")] + Internal(#[from] anyhow::Error), + + /// A backend listing or paging limit was exceeded (e.g. Spin's + /// `max_list_keys` cap on `get_keys`). + #[error("kv backend limit exceeded: {message}")] + LimitExceeded { message: String }, + /// The requested key was not found (used by `delete` when strict). #[error("key not found: {key}")] NotFound { key: String }, + /// A serialization or deserialization error. + #[error("serialization error: {0}")] + Serialization(#[from] serde_json::Error), + /// The KV store backend is temporarily unavailable. #[error("kv store unavailable")] Unavailable, + /// The operation is not supported by the active backend (e.g. TTL writes + /// on Spin, where `key_value::Store::set` accepts no expiry). + #[error("kv operation not supported by backend: {operation}")] + Unsupported { operation: String }, + /// A validation error (e.g., invalid key or value). #[error("validation error: {0}")] Validation(String), - - /// A serialization or deserialization error. - #[error("serialization error: {0}")] - Serialization(#[from] serde_json::Error), - - /// A general internal error. - #[error("kv store error: {0}")] - Internal(#[from] anyhow::Error), } -/// A single page of keys from a KV listing operation. -/// -/// The `cursor` is opaque. Pass it back to `list_keys_page` to continue -/// listing from the next page. `None` means the current page is the last page. -#[derive(Debug, Clone, Default, PartialEq, Eq)] -pub struct KvPage { - pub keys: Vec, - pub cursor: Option, -} - -#[derive(Debug, Serialize, Deserialize)] -struct KvCursorEnvelope { - prefix: String, - cursor: String, -} - -impl From for EdgeError { - fn from(err: KvError) -> Self { - match err { - KvError::NotFound { key } => EdgeError::not_found(format!("kv key: {key}")), - KvError::Unavailable => EdgeError::service_unavailable("kv store unavailable"), - KvError::Validation(e) => EdgeError::bad_request(format!("kv validation error: {e}")), - KvError::Serialization(e) => { - EdgeError::internal(anyhow::anyhow!("kv serialization error: {e}")) - } - KvError::Internal(e) => EdgeError::internal(e), - } - } -} - -// --------------------------------------------------------------------------- -// Trait -// --------------------------------------------------------------------------- - -/// Object-safe interface for KV store backends. -/// -/// All methods take `&self` — backends handle concurrency internally -/// (e.g., platform APIs, or `Mutex` for in-memory stores). -/// -/// # Pre-validation contract -/// -/// This trait is always called through [`KvHandle`], which validates all -/// inputs (key length/format, value size, TTL bounds, list limits) before -/// delegating here. Implementations may therefore assume that: -/// - Keys are non-empty and within [`KvHandle::MAX_KEY_SIZE`] -/// - Values are within [`KvHandle::MAX_VALUE_SIZE`] -/// - TTLs are within `[MIN_TTL, MAX_TTL]` -/// - List limits are within `[1, MAX_LIST_PAGE_SIZE]` -/// -/// Do **not** call trait methods directly in production code; always go -/// through [`KvHandle`] to ensure validation is applied. -/// -/// Implementations exist per adapter: -/// - `PersistentKvStore` (axum adapter) — local dev / tests with persistent storage -/// - `FastlyKvStore` (fastly adapter) — Fastly KV Store -/// - `CloudflareKvStore` (cloudflare adapter) — Cloudflare Workers KV -#[async_trait(?Send)] -pub trait KvStore: Send + Sync { - /// Retrieve raw bytes for a key. Returns `Ok(None)` if the key does not exist. - async fn get_bytes(&self, key: &str) -> Result, KvError>; - - /// Store raw bytes for a key, overwriting any existing value. - async fn put_bytes(&self, key: &str, value: Bytes) -> Result<(), KvError>; - - /// Store raw bytes with a time-to-live. After `ttl` has elapsed the key - /// should be treated as expired. Eviction timing is backend-specific: - /// - **Axum (`PersistentKvStore`)**: lazy eviction — expired keys are removed - /// on the next `get_bytes` call for that key. Keys never accessed after - /// expiration remain in the database until deleted, so `.edgezero/kv.redb` - /// grows without bound on long-running dev servers. - /// - **Fastly/Cloudflare**: eviction is managed by the platform and is not - /// guaranteed to be immediate. - async fn put_bytes_with_ttl( - &self, - key: &str, - value: Bytes, - ttl: Duration, - ) -> Result<(), KvError>; - - /// Delete a key. Returns `Ok(())` even if the key did not exist. - async fn delete(&self, key: &str) -> Result<(), KvError>; - - /// List keys in lexicographic order, returning at most `limit` keys. - /// - /// The `cursor` is opaque. Pass the cursor from a previous page back to - /// continue listing. Implementations should keep memory usage bounded to a - /// single page worth of keys. - async fn list_keys_page( - &self, - prefix: &str, - cursor: Option<&str>, - limit: usize, - ) -> Result; - - /// Check whether a key exists. - /// - /// The default implementation delegates to `get_bytes`. Backends that - /// support a cheaper existence check should override this. - async fn exists(&self, key: &str) -> Result { - Ok(self.get_bytes(key).await?.is_some()) - } -} - -// --------------------------------------------------------------------------- -// Test-only no-op store -// --------------------------------------------------------------------------- - -/// A no-op [`KvStore`] for tests that only need a [`KvHandle`] to exist -/// without storing real data. -/// -/// All reads return `None` / empty; all writes succeed silently. -/// -/// Available in `#[cfg(test)]` builds within this crate, and in any downstream -/// crate that enables the `test-utils` feature on `edgezero-core`: -/// -/// ```toml -/// [dev-dependencies] -/// edgezero-core = { path = "...", features = ["test-utils"] } -/// ``` -#[cfg(any(test, feature = "test-utils"))] -pub struct NoopKvStore; - -#[cfg(any(test, feature = "test-utils"))] -#[async_trait(?Send)] -impl KvStore for NoopKvStore { - async fn get_bytes(&self, _key: &str) -> Result, KvError> { - Ok(None) - } - async fn put_bytes(&self, _key: &str, _value: Bytes) -> Result<(), KvError> { - Ok(()) - } - async fn put_bytes_with_ttl( - &self, - _key: &str, - _value: Bytes, - _ttl: Duration, - ) -> Result<(), KvError> { - Ok(()) - } - async fn delete(&self, _key: &str) -> Result<(), KvError> { - Ok(()) - } - async fn list_keys_page( - &self, - _prefix: &str, - _cursor: Option<&str>, - _limit: usize, - ) -> Result { - Ok(KvPage::default()) - } -} - -// --------------------------------------------------------------------------- -// Handle -// --------------------------------------------------------------------------- - -/// A cloneable, ergonomic handle to a KV store. +/// A cloneable, ergonomic handle to a KV store. /// /// Provides generic `get` / `put` helpers that serialize via JSON, /// while delegating to the object-safe `KvStore` trait underneath. @@ -247,7 +341,10 @@ impl KvStore for NoopKvStore { /// /// ```ignore /// #[action] -/// async fn handler(Kv(store): Kv) -> Result { +/// async fn handler(kv: Kv) -> Result { +/// let store = kv +/// .default() +/// .ok_or_else(|| EdgeError::service_unavailable("no default kv"))?; /// let count: i32 = store.get_or("visits", 0).await?; /// store.put("visits", &(count + 1)).await?; /// Ok(format!("visits: {}", count + 1)) @@ -259,6 +356,7 @@ pub struct KvHandle { } impl fmt::Debug for KvHandle { + #[inline] fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { f.debug_struct("KvHandle").finish_non_exhaustive() } @@ -268,147 +366,87 @@ impl KvHandle { /// Maximum key size in bytes (Cloudflare limit). pub const MAX_KEY_SIZE: usize = 512; - /// Maximum value size in bytes (Standard limit). - pub const MAX_VALUE_SIZE: usize = 25 * 1024 * 1024; - - /// Minimum TTL in seconds (Cloudflare limit). - pub const MIN_TTL: Duration = Duration::from_secs(60); - - /// Maximum TTL (1 year). Prevents overflow when adding to `SystemTime::now()`. - pub const MAX_TTL: Duration = Duration::from_secs(365 * 24 * 60 * 60); - /// Maximum number of keys returned from a single page. pub const MAX_LIST_PAGE_SIZE: usize = 1_000; - /// Create a new handle wrapping a KV store implementation. - pub fn new(store: Arc) -> Self { - Self { store } - } - - // -- Validation --------------------------------------------------------- - - fn validate_key(key: &str) -> Result<(), KvError> { - if key.is_empty() { - return Err(KvError::Validation("key cannot be empty".to_string())); - } - if key.len() > Self::MAX_KEY_SIZE { - return Err(KvError::Validation(format!( - "key length {} exceeds limit of {} bytes", - key.len(), - Self::MAX_KEY_SIZE - ))); - } - if key == "." || key == ".." { - return Err(KvError::Validation( - "key cannot be exactly '.' or '..'".to_string(), - )); - } - if key.chars().any(|c| c.is_control()) { - return Err(KvError::Validation( - "key contains invalid control characters".to_string(), - )); - } - Ok(()) - } - - fn validate_value(value: &[u8]) -> Result<(), KvError> { - if value.len() > Self::MAX_VALUE_SIZE { - return Err(KvError::Validation(format!( - "value size {} exceeds limit of {} bytes", - value.len(), - Self::MAX_VALUE_SIZE - ))); - } - Ok(()) - } - - fn validate_ttl(ttl: Duration) -> Result<(), KvError> { - if ttl < Self::MIN_TTL { - return Err(KvError::Validation(format!( - "TTL {:?} is less than minimum of at least 60 seconds", - ttl - ))); - } - if ttl > Self::MAX_TTL { - return Err(KvError::Validation(format!( - "TTL {:?} exceeds maximum of 1 year", - ttl - ))); - } - Ok(()) - } + /// Maximum TTL (1 year). Prevents overflow when adding to `SystemTime::now()`. + #[expect( + clippy::duration_suboptimal_units, + reason = "`Duration::from_days` is not stable in const context (1.95)" + )] + pub const MAX_TTL: Duration = Duration::from_secs(365 * 24 * 60 * 60); - fn validate_prefix(prefix: &str) -> Result<(), KvError> { - if prefix.len() > Self::MAX_KEY_SIZE { - return Err(KvError::Validation(format!( - "prefix length {} exceeds limit of {} bytes", - prefix.len(), - Self::MAX_KEY_SIZE - ))); - } - if prefix.chars().any(|c| c.is_control()) { - return Err(KvError::Validation( - "prefix contains invalid control characters".to_string(), - )); - } - Ok(()) - } + /// Maximum value size in bytes (Standard limit). + pub const MAX_VALUE_SIZE: usize = 25 * 1024 * 1024; - fn validate_list_limit(limit: usize) -> Result<(), KvError> { - if limit == 0 { - return Err(KvError::Validation( - "list limit must be greater than zero".to_string(), - )); - } - if limit > Self::MAX_LIST_PAGE_SIZE { - return Err(KvError::Validation(format!( - "list limit {} exceeds maximum of {}", - limit, - Self::MAX_LIST_PAGE_SIZE - ))); - } - Ok(()) - } + /// Minimum TTL (Cloudflare limit). + #[expect( + clippy::duration_suboptimal_units, + reason = "`Duration::from_mins` is not stable in const context (1.95)" + )] + pub const MIN_TTL: Duration = Duration::from_secs(60); fn decode_list_cursor(prefix: &str, cursor: Option<&str>) -> Result, KvError> { - let Some(cursor) = cursor else { + let Some(encoded) = cursor else { return Ok(None); }; - let envelope: KvCursorEnvelope = serde_json::from_str(cursor) - .map_err(|_| KvError::Validation("list cursor is invalid or corrupted".to_string()))?; + let envelope: KvCursorEnvelope = serde_json::from_str(encoded) + .map_err(|_e| KvError::Validation("list cursor is invalid or corrupted".to_owned()))?; if envelope.prefix != prefix { return Err(KvError::Validation( - "list cursor does not match the requested prefix".to_string(), + "list cursor does not match the requested prefix".to_owned(), )); } if envelope.cursor.is_empty() { return Err(KvError::Validation( - "list cursor payload cannot be empty".to_string(), + "list cursor payload cannot be empty".to_owned(), )); } Ok(Some(envelope.cursor)) } + /// Delete a key. + /// + /// # Errors + /// Returns [`KvError`] if the backend rejects the delete. + #[inline] + pub async fn delete(&self, key: &str) -> Result<(), KvError> { + Self::validate_key(key)?; + self.store.delete(key).await + } + fn encode_list_cursor(prefix: &str, cursor: Option) -> Result, KvError> { cursor - .map(|cursor| { + .map(|inner| { serde_json::to_string(&KvCursorEnvelope { - prefix: prefix.to_string(), - cursor, + cursor: inner, + prefix: prefix.to_owned(), }) .map_err(KvError::from) }) .transpose() } - // -- Typed helpers (JSON) ----------------------------------------------- + /// Check whether a key exists without deserializing its value. + /// + /// # Errors + /// Returns [`KvError`] if the backend lookup fails. + #[inline] + pub async fn exists(&self, key: &str) -> Result { + Self::validate_key(key)?; + self.store.exists(key).await + } /// Get a value by key, deserializing from JSON. /// /// Returns `Ok(None)` if the key does not exist. + /// + /// # Errors + /// Returns [`KvError`] if the lookup fails or the stored bytes cannot be deserialized into `T`. + #[inline] pub async fn get(&self, key: &str) -> Result, KvError> { Self::validate_key(key)?; match self.store.get_bytes(key).await? { @@ -420,12 +458,66 @@ impl KvHandle { } } - /// Get a value by key, returning `default` if the key does not exist. - pub async fn get_or(&self, key: &str, default: T) -> Result { - Ok(self.get(key).await?.unwrap_or(default)) - } - + /// Get raw bytes for a key. + /// + /// # Errors + /// Returns [`KvError`] if the backend lookup fails. + #[inline] + pub async fn get_bytes(&self, key: &str) -> Result, KvError> { + Self::validate_key(key)?; + self.store.get_bytes(key).await + } + + /// Get a value by key, returning `default` if the key does not exist. + /// + /// # Errors + /// Returns [`KvError`] if the lookup fails or the stored bytes cannot be deserialized into `T`. + #[inline] + pub async fn get_or(&self, key: &str, default: T) -> Result { + Ok(self.get(key).await?.unwrap_or(default)) + } + + /// List keys in a bounded, paginated fashion. + /// + /// The cursor is opaque, prefix-bound, and should be passed back unchanged + /// with the same prefix to retrieve the next page. Listings are not atomic + /// snapshots and may reflect concurrent writes or provider-level eventual + /// consistency. + /// + /// # Errors + /// Returns [`KvError::Validation`] if `cursor` is malformed or `prefix` exceeds backend limits; [`KvError::Internal`] on backend failure. + #[inline] + pub async fn list_keys_page( + &self, + prefix: &str, + cursor: Option<&str>, + limit: usize, + ) -> Result { + Self::validate_prefix(prefix)?; + Self::validate_list_limit(limit)?; + let decoded_cursor = Self::decode_list_cursor(prefix, cursor)?; + let page = self + .store + .list_keys_page(prefix, decoded_cursor.as_deref(), limit) + .await?; + + Ok(KvPage { + cursor: Self::encode_list_cursor(prefix, page.cursor)?, + keys: page.keys, + }) + } + + /// Create a new handle wrapping a KV store implementation. + #[inline] + pub fn new(store: Arc) -> Self { + Self { store } + } + /// Put a value, serializing it to JSON. + /// + /// # Errors + /// Returns [`KvError`] if the value cannot be serialized or the backend rejects the write. + #[inline] pub async fn put(&self, key: &str, value: &T) -> Result<(), KvError> { Self::validate_key(key)?; let bytes = serde_json::to_vec(value)?; @@ -433,7 +525,39 @@ impl KvHandle { self.store.put_bytes(key, Bytes::from(bytes)).await } + /// Put raw bytes for a key. + /// + /// # Errors + /// Returns [`KvError::Validation`] for invalid keys or oversized values; [`KvError::Internal`] on backend failure. + #[inline] + pub async fn put_bytes(&self, key: &str, value: Bytes) -> Result<(), KvError> { + Self::validate_key(key)?; + Self::validate_value(&value)?; + self.store.put_bytes(key, value).await + } + + /// Put raw bytes with a TTL. + /// + /// # Errors + /// Returns [`KvError::Validation`] for invalid input; [`KvError::Internal`] on backend failure. + #[inline] + pub async fn put_bytes_with_ttl( + &self, + key: &str, + value: Bytes, + ttl: Duration, + ) -> Result<(), KvError> { + Self::validate_key(key)?; + Self::validate_ttl(ttl)?; + Self::validate_value(&value)?; + self.store.put_bytes_with_ttl(key, value, ttl).await + } + /// Put a value with a TTL, serializing it to JSON. + /// + /// # Errors + /// Returns [`KvError`] if the value cannot be serialized or the backend rejects the write. + #[inline] pub async fn put_with_ttl( &self, key: &str, @@ -461,312 +585,268 @@ impl KvHandle { /// calls to the backend. Concurrent calls on the same key may cause /// lost writes. Use this only when eventual consistency is acceptable /// (e.g., approximate counters). - pub async fn read_modify_write(&self, key: &str, default: T, f: F) -> Result + /// + /// # Errors + /// Returns [`KvError`] if any of the read, mutate, or write steps fail. + #[inline] + pub async fn read_modify_write( + &self, + key: &str, + default: T, + mutator: Mutator, + ) -> Result where T: DeserializeOwned + Serialize, - F: FnOnce(T) -> T, + Mutator: FnOnce(T) -> T, { // Validation happens in get_or and put let current = self.get_or(key, default).await?; - let updated = f(current); + let updated = mutator(current); self.put(key, &updated).await?; Ok(updated) } - // -- Raw bytes ---------------------------------------------------------- + fn validate_key(key: &str) -> Result<(), KvError> { + if key.is_empty() { + return Err(KvError::Validation("key cannot be empty".to_owned())); + } + if key.len() > Self::MAX_KEY_SIZE { + return Err(KvError::Validation(format!( + "key length {} exceeds limit of {} bytes", + key.len(), + Self::MAX_KEY_SIZE + ))); + } + if key == "." || key == ".." { + return Err(KvError::Validation( + "key cannot be exactly '.' or '..'".to_owned(), + )); + } + if key.chars().any(char::is_control) { + return Err(KvError::Validation( + "key contains invalid control characters".to_owned(), + )); + } + Ok(()) + } - /// Get raw bytes for a key. - pub async fn get_bytes(&self, key: &str) -> Result, KvError> { - Self::validate_key(key)?; - self.store.get_bytes(key).await + fn validate_list_limit(limit: usize) -> Result<(), KvError> { + if limit == 0 { + return Err(KvError::Validation( + "list limit must be greater than zero".to_owned(), + )); + } + if limit > Self::MAX_LIST_PAGE_SIZE { + return Err(KvError::Validation(format!( + "list limit {} exceeds maximum of {}", + limit, + Self::MAX_LIST_PAGE_SIZE + ))); + } + Ok(()) } - /// Put raw bytes for a key. - pub async fn put_bytes(&self, key: &str, value: Bytes) -> Result<(), KvError> { - Self::validate_key(key)?; - Self::validate_value(&value)?; - self.store.put_bytes(key, value).await + fn validate_prefix(prefix: &str) -> Result<(), KvError> { + if prefix.len() > Self::MAX_KEY_SIZE { + return Err(KvError::Validation(format!( + "prefix length {} exceeds limit of {} bytes", + prefix.len(), + Self::MAX_KEY_SIZE + ))); + } + if prefix.chars().any(char::is_control) { + return Err(KvError::Validation( + "prefix contains invalid control characters".to_owned(), + )); + } + Ok(()) } - /// Put raw bytes with a TTL. - pub async fn put_bytes_with_ttl( - &self, - key: &str, - value: Bytes, - ttl: Duration, - ) -> Result<(), KvError> { - Self::validate_key(key)?; - Self::validate_ttl(ttl)?; - Self::validate_value(&value)?; - self.store.put_bytes_with_ttl(key, value, ttl).await + fn validate_ttl(ttl: Duration) -> Result<(), KvError> { + if ttl < Self::MIN_TTL { + return Err(KvError::Validation(format!( + "TTL {ttl:?} is less than minimum of at least 60 seconds" + ))); + } + if ttl > Self::MAX_TTL { + return Err(KvError::Validation(format!( + "TTL {ttl:?} exceeds maximum of 1 year" + ))); + } + Ok(()) } - // -- Other operations --------------------------------------------------- + fn validate_value(value: &[u8]) -> Result<(), KvError> { + if value.len() > Self::MAX_VALUE_SIZE { + return Err(KvError::Validation(format!( + "value size {} exceeds limit of {} bytes", + value.len(), + Self::MAX_VALUE_SIZE + ))); + } + Ok(()) + } +} - /// Check whether a key exists without deserializing its value. - pub async fn exists(&self, key: &str) -> Result { - Self::validate_key(key)?; - self.store.exists(key).await +impl From for EdgeError { + #[inline] + fn from(err: KvError) -> Self { + match err { + KvError::NotFound { key } => EdgeError::not_found(format!("kv key: {key}")), + KvError::Unavailable => EdgeError::service_unavailable("kv store unavailable"), + KvError::Validation(msg) => { + EdgeError::bad_request(format!("kv validation error: {msg}")) + } + KvError::Serialization(msg) => { + EdgeError::internal(anyhow::anyhow!("kv serialization error: {msg}")) + } + KvError::Internal(source) => EdgeError::internal(source), + KvError::Unsupported { operation } => EdgeError::not_implemented(format!( + "kv operation not supported by backend: {operation}" + )), + KvError::LimitExceeded { message } => { + EdgeError::service_unavailable(format!("kv backend limit exceeded: {message}")) + } + } } +} - /// Delete a key. - pub async fn delete(&self, key: &str) -> Result<(), KvError> { - Self::validate_key(key)?; - self.store.delete(key).await +/// A single page of keys from a KV listing operation. +/// +/// The `cursor` is opaque. Pass it back to `list_keys_page` to continue +/// listing from the next page. `None` means the current page is the last page. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct KvPage { + pub cursor: Option, + pub keys: Vec, +} + +/// Object-safe interface for KV store backends. +/// +/// All methods take `&self` — backends handle concurrency internally +/// (e.g., platform APIs, or `Mutex` for in-memory stores). +/// +/// # Pre-validation contract +/// +/// This trait is always called through [`KvHandle`], which validates all +/// inputs (key length/format, value size, TTL bounds, list limits) before +/// delegating here. Implementations may therefore assume that: +/// - Keys are non-empty and within [`KvHandle::MAX_KEY_SIZE`] +/// - Values are within [`KvHandle::MAX_VALUE_SIZE`] +/// - TTLs are within `[MIN_TTL, MAX_TTL]` +/// - List limits are within `[1, MAX_LIST_PAGE_SIZE]` +/// +/// Do **not** call trait methods directly in production code; always go +/// through [`KvHandle`] to ensure validation is applied. +/// +/// Implementations exist per adapter: +/// - `PersistentKvStore` (axum adapter) — local dev / tests with persistent storage +/// - `FastlyKvStore` (fastly adapter) — Fastly KV Store +/// - `CloudflareKvStore` (cloudflare adapter) — Cloudflare Workers KV +#[async_trait(?Send)] +pub trait KvStore: Send + Sync { + /// Delete a key. Returns `Ok(())` even if the key did not exist. + async fn delete(&self, key: &str) -> Result<(), KvError>; + + /// Check whether a key exists. + /// + /// The default implementation delegates to `get_bytes`. Backends that + /// support a cheaper existence check should override this. + #[inline] + async fn exists(&self, key: &str) -> Result { + Ok(self.get_bytes(key).await?.is_some()) } - /// List keys in a bounded, paginated fashion. + /// Retrieve raw bytes for a key. Returns `Ok(None)` if the key does not exist. + async fn get_bytes(&self, key: &str) -> Result, KvError>; + + /// List keys in lexicographic order, returning at most `limit` keys. /// - /// The cursor is opaque, prefix-bound, and should be passed back unchanged - /// with the same prefix to retrieve the next page. Listings are not atomic - /// snapshots and may reflect concurrent writes or provider-level eventual - /// consistency. - pub async fn list_keys_page( + /// The `cursor` is opaque. Pass the cursor from a previous page back to + /// continue listing. Implementations should keep memory usage bounded to a + /// single page worth of keys. + async fn list_keys_page( &self, prefix: &str, cursor: Option<&str>, limit: usize, - ) -> Result { - Self::validate_prefix(prefix)?; - Self::validate_list_limit(limit)?; - let decoded_cursor = Self::decode_list_cursor(prefix, cursor)?; - let page = self - .store - .list_keys_page(prefix, decoded_cursor.as_deref(), limit) - .await?; - - Ok(KvPage { - keys: page.keys, - cursor: Self::encode_list_cursor(prefix, page.cursor)?, - }) - } -} + ) -> Result; -// --------------------------------------------------------------------------- -// Contract test macro -// --------------------------------------------------------------------------- + /// Store raw bytes for a key, overwriting any existing value. + async fn put_bytes(&self, key: &str, value: Bytes) -> Result<(), KvError>; -/// Generate a suite of contract tests for any [`KvStore`] implementation. -/// -/// The macro takes the module name and a factory expression that produces a -/// fresh store instance (implementing `KvStore`). It generates a module -/// containing tests that verify the fundamental behaviours every backend -/// must satisfy. + /// Store raw bytes with a time-to-live. After `ttl` has elapsed the key + /// should be treated as expired. Eviction timing is backend-specific: + /// - **Axum (`PersistentKvStore`)**: lazy eviction — expired keys are removed + /// on the next `get_bytes` call for that key. Keys never accessed after + /// expiration remain in the database until deleted, so `.edgezero/kv.redb` + /// grows without bound on long-running dev servers. + /// - **Fastly/Cloudflare**: eviction is managed by the platform and is not + /// guaranteed to be immediate. + async fn put_bytes_with_ttl( + &self, + key: &str, + value: Bytes, + ttl: Duration, + ) -> Result<(), KvError>; +} + +// --------------------------------------------------------------------------- +// Test-only no-op store +// --------------------------------------------------------------------------- + +/// A no-op [`KvStore`] for tests that only need a [`KvHandle`] to exist +/// without storing real data. /// -/// # Example +/// All reads return `None` / empty; all writes succeed silently. /// -/// ```rust,ignore -/// edgezero_core::key_value_store_contract_tests!(persistent_kv_contract, { -/// let db_path = std::env::temp_dir().join(format!( -/// "edgezero-contract-{}-{:?}.redb", -/// std::process::id(), -/// std::thread::current().id() -/// )); -/// PersistentKvStore::new(db_path).unwrap() -/// }); +/// Available in `#[cfg(test)]` builds within this crate, and in any downstream +/// crate that enables the `test-utils` feature on `edgezero-core`: +/// +/// ```toml +/// [dev-dependencies] +/// edgezero-core = { path = "...", features = ["test-utils"] } /// ``` -#[macro_export] -macro_rules! key_value_store_contract_tests { - ($mod_name:ident, $factory:expr) => { - mod $mod_name { - use super::*; - use bytes::Bytes; - use $crate::key_value_store::KvStore; - - fn run(f: F) -> F::Output { - futures::executor::block_on(f) - } - - #[test] - fn contract_put_and_get() { - let store = $factory; - run(async { - store.put_bytes("k", Bytes::from("v")).await.unwrap(); - assert_eq!(store.get_bytes("k").await.unwrap(), Some(Bytes::from("v"))); - }); - } - - #[test] - fn contract_get_missing_returns_none() { - let store = $factory; - run(async { - assert_eq!(store.get_bytes("missing").await.unwrap(), None); - }); - } - - #[test] - fn contract_put_overwrites() { - let store = $factory; - run(async { - store.put_bytes("k", Bytes::from("first")).await.unwrap(); - store.put_bytes("k", Bytes::from("second")).await.unwrap(); - assert_eq!( - store.get_bytes("k").await.unwrap(), - Some(Bytes::from("second")) - ); - }); - } - - #[test] - fn contract_delete_removes_key() { - let store = $factory; - run(async { - store.put_bytes("k", Bytes::from("v")).await.unwrap(); - store.delete("k").await.unwrap(); - assert_eq!(store.get_bytes("k").await.unwrap(), None); - }); - } - - #[test] - fn contract_delete_nonexistent_ok() { - let store = $factory; - run(async { - store.delete("nope").await.unwrap(); - }); - } - - #[test] - fn contract_exists() { - let store = $factory; - run(async { - assert!(!store.exists("k").await.unwrap()); - store.put_bytes("k", Bytes::from("v")).await.unwrap(); - assert!(store.exists("k").await.unwrap()); - store.delete("k").await.unwrap(); - assert!(!store.exists("k").await.unwrap()); - }); - } - - #[test] - fn contract_put_with_ttl_stores_value() { - let store = $factory; - run(async { - store - .put_bytes_with_ttl( - "ttl_key", - Bytes::from("ttl_val"), - std::time::Duration::from_secs(300), - ) - .await - .unwrap(); - assert_eq!( - store.get_bytes("ttl_key").await.unwrap(), - Some(Bytes::from("ttl_val")) - ); - }); - } - - // `std::thread::sleep` is not available on `wasm32` targets (no - // thread support). The TTL eviction contract is verified on native - // targets only; WASM adapters are expected to delegate eviction to - // the platform runtime (Cloudflare/Fastly), which does not expose a - // synchronous sleep primitive in test environments. - #[cfg(not(target_arch = "wasm32"))] - #[test] - fn contract_ttl_expires() { - let store = $factory; - run(async { - // Uses a sub-second TTL intentionally. Contract tests call - // `KvStore` directly (not `KvHandle`), so the 60-second - // minimum TTL validation is bypassed. This lets us verify - // that the backend actually evicts expired entries. - store - .put_bytes_with_ttl( - "ephemeral", - Bytes::from("gone_soon"), - std::time::Duration::from_millis(1), - ) - .await - .unwrap(); - // Allow the TTL to elapse. 200ms gives the OS scheduler - // enough headroom on busy CI runners. - std::thread::sleep(std::time::Duration::from_millis(200)); - assert_eq!(store.get_bytes("ephemeral").await.unwrap(), None); - }); - } - - #[test] - fn contract_list_keys_page_is_paginated() { - let store = $factory; - run(async { - let expected = vec![ - "app/one".to_string(), - "app/two".to_string(), - "other/three".to_string(), - ]; - for key in &expected { - store - .put_bytes(key, Bytes::from(key.clone())) - .await - .unwrap(); - } - - let mut cursor = None; - let mut seen = std::collections::HashSet::new(); - let mut collected = Vec::new(); - - for _ in 0..expected.len() { - let page = store - .list_keys_page("", cursor.as_deref(), 1) - .await - .unwrap(); - assert!(page.keys.len() <= 1); - for key in &page.keys { - assert!( - seen.insert(key.clone()), - "duplicate key in pagination: {key}" - ); - collected.push(key.clone()); - } - - cursor = page.cursor; - if cursor.is_none() { - break; - } - } - - collected.sort(); - let mut expected_sorted = expected.clone(); - expected_sorted.sort(); - assert_eq!(collected, expected_sorted); - }); - } - - #[test] - fn contract_list_keys_page_respects_prefix() { - let store = $factory; - run(async { - store - .put_bytes("prefix/a", Bytes::from_static(b"a")) - .await - .unwrap(); - store - .put_bytes("prefix/b", Bytes::from_static(b"b")) - .await - .unwrap(); - store - .put_bytes("other/c", Bytes::from_static(b"c")) - .await - .unwrap(); - - let first = store.list_keys_page("prefix/", None, 1).await.unwrap(); - assert_eq!(first.keys.len(), 1); - assert!(first.keys[0].starts_with("prefix/")); +#[cfg(any(test, feature = "test-utils"))] +pub struct NoopKvStore; - let second = store - .list_keys_page("prefix/", first.cursor.as_deref(), 1) - .await - .unwrap(); - assert!(second.keys.iter().all(|key| key.starts_with("prefix/"))); - assert!(first - .keys - .iter() - .chain(second.keys.iter()) - .all(|key| key.starts_with("prefix/"))); - }); - } - } - }; +#[cfg(any(test, feature = "test-utils"))] +#[async_trait(?Send)] +impl KvStore for NoopKvStore { + #[inline] + async fn delete(&self, _key: &str) -> Result<(), KvError> { + Ok(()) + } + #[inline] + async fn exists(&self, _key: &str) -> Result { + Ok(false) + } + #[inline] + async fn get_bytes(&self, _key: &str) -> Result, KvError> { + Ok(None) + } + #[inline] + async fn list_keys_page( + &self, + _prefix: &str, + _cursor: Option<&str>, + _limit: usize, + ) -> Result { + Ok(KvPage::default()) + } + #[inline] + async fn put_bytes(&self, _key: &str, _value: Bytes) -> Result<(), KvError> { + Ok(()) + } + #[inline] + async fn put_bytes_with_ttl( + &self, + _key: &str, + _value: Bytes, + _ttl: Duration, + ) -> Result<(), KvError> { + Ok(()) + } } // --------------------------------------------------------------------------- @@ -775,62 +855,50 @@ macro_rules! key_value_store_contract_tests { #[cfg(test)] mod tests { + // Run the shared contract tests against MockStore. + crate::key_value_store_contract_tests!(mock_store_contract, MockStore::new()); + use super::*; use crate::http::StatusCode; + use futures::executor::block_on; use std::collections::HashMap; use std::sync::Mutex; use std::time::SystemTime; - // In-memory store with TTL support for contract testing. - // Uses `SystemTime` instead of `Instant` for WASM compatibility. - struct MockStore { - data: Mutex)>>, - } - - impl MockStore { - fn new() -> Self { - Self { - data: Mutex::new(HashMap::new()), - } - } + #[derive(serde::Serialize, serde::Deserialize, PartialEq, Debug)] + struct Counter { + count: i32, } - #[async_trait(?Send)] - impl KvStore for MockStore { - async fn get_bytes(&self, key: &str) -> Result, KvError> { - let mut data = self.data.lock().unwrap(); - if let Some((_, Some(exp))) = data.get(key) { - if SystemTime::now() >= *exp { - data.remove(key); - return Ok(None); - } - } - Ok(data.get(key).map(|(v, _)| v.clone())) - } - - async fn put_bytes(&self, key: &str, value: Bytes) -> Result<(), KvError> { - let mut data = self.data.lock().unwrap(); - data.insert(key.to_string(), (value, None)); - Ok(()) - } - - async fn put_bytes_with_ttl( - &self, - key: &str, - value: Bytes, - ttl: Duration, - ) -> Result<(), KvError> { - let mut data = self.data.lock().unwrap(); - data.insert(key.to_string(), (value, Some(SystemTime::now() + ttl))); - Ok(()) - } + // In-memory store with TTL support for contract testing. + // Uses `SystemTime` instead of `Instant` for WASM compatibility. + struct MockStore { + data: Mutex)>>, + } + #[async_trait(?Send)] + impl KvStore for MockStore { async fn delete(&self, key: &str) -> Result<(), KvError> { let mut data = self.data.lock().unwrap(); data.remove(key); Ok(()) } + async fn exists(&self, key: &str) -> Result { + Ok(self.get_bytes(key).await?.is_some()) + } + + async fn get_bytes(&self, key: &str) -> Result, KvError> { + let mut data = self.data.lock().unwrap(); + if let Some((_, Some(exp))) = data.get(key) { + if SystemTime::now() >= *exp { + data.remove(key); + return Ok(None); + } + } + Ok(data.get(key).map(|(value, _)| value.clone())) + } + async fn list_keys_page( &self, prefix: &str, @@ -844,7 +912,7 @@ mod tests { let mut keys = data .keys() .filter(|key| { - key.starts_with(prefix) && cursor.is_none_or(|cursor| key.as_str() > cursor) + key.starts_with(prefix) && cursor.is_none_or(|cur| key.as_str() > cur) }) .cloned() .collect::>(); @@ -858,507 +926,539 @@ mod tests { keys, }) } + + async fn put_bytes(&self, key: &str, value: Bytes) -> Result<(), KvError> { + let mut data = self.data.lock().unwrap(); + data.insert(key.to_owned(), (value, None)); + Ok(()) + } + + async fn put_bytes_with_ttl( + &self, + key: &str, + value: Bytes, + ttl: Duration, + ) -> Result<(), KvError> { + let mut data = self.data.lock().unwrap(); + let expires_at = SystemTime::now() + .checked_add(ttl) + .ok_or_else(|| KvError::Internal(anyhow::anyhow!("ttl overflows system time")))?; + data.insert(key.to_owned(), (value, Some(expires_at))); + Ok(()) + } + } + + impl MockStore { + fn new() -> Self { + Self { + data: Mutex::new(HashMap::new()), + } + } } fn handle() -> KvHandle { KvHandle::new(Arc::new(MockStore::new())) } - // -- Raw bytes ---------------------------------------------------------- - #[test] - fn raw_bytes_roundtrip() { - let h = handle(); - futures::executor::block_on(async { - h.put_bytes("k", Bytes::from("hello")).await.unwrap(); - assert_eq!(h.get_bytes("k").await.unwrap(), Some(Bytes::from("hello"))); + fn delete_missing_key_is_ok() { + let kv = handle(); + block_on(async { + kv.delete("nope").await.unwrap(); }); } #[test] - fn raw_bytes_missing_key_returns_none() { - let h = handle(); - futures::executor::block_on(async { - assert_eq!(h.get_bytes("missing").await.unwrap(), None); + fn delete_removes_key() { + let kv = handle(); + block_on(async { + kv.put_bytes("k", Bytes::from("v")).await.unwrap(); + kv.delete("k").await.unwrap(); + assert_eq!(kv.get_bytes("k").await.unwrap(), None); }); } #[test] - fn raw_bytes_overwrite() { - let h = handle(); - futures::executor::block_on(async { - h.put_bytes("k", Bytes::from("a")).await.unwrap(); - h.put_bytes("k", Bytes::from("b")).await.unwrap(); - assert_eq!(h.get_bytes("k").await.unwrap(), Some(Bytes::from("b"))); + fn empty_key_rejected() { + let kv = handle(); + block_on(async { + let err = kv.put("", &"empty key").await.unwrap_err(); + assert!(matches!(err, KvError::Validation(_))); + assert!(format!("{err}").contains("cannot be empty")); }); } - // -- Typed JSON --------------------------------------------------------- - - #[derive(serde::Serialize, serde::Deserialize, PartialEq, Debug)] - struct Counter { - count: i32, - } - #[test] - fn typed_get_put_roundtrip() { - let h = handle(); - futures::executor::block_on(async { - let data = Counter { count: 42 }; - h.put("counter", &data).await.unwrap(); - let out: Option = h.get("counter").await.unwrap(); - assert_eq!(out, Some(data)); + fn exists_returns_false_after_delete() { + let kv = handle(); + block_on(async { + kv.put_bytes("ephemeral", Bytes::from("v")).await.unwrap(); + assert!(kv.exists("ephemeral").await.unwrap()); + kv.delete("ephemeral").await.unwrap(); + assert!(!kv.exists("ephemeral").await.unwrap()); }); } #[test] - fn typed_get_missing_returns_none() { - let h = handle(); - futures::executor::block_on(async { - let out: Option = h.get("nope").await.unwrap(); - assert_eq!(out, None); + fn exists_returns_false_for_missing() { + let kv = handle(); + block_on(async { + assert!(!kv.exists("nope").await.unwrap()); }); } #[test] - fn typed_get_or_returns_default() { - let h = handle(); - futures::executor::block_on(async { - let count: i32 = h.get_or("visits", 0).await.unwrap(); - assert_eq!(count, 0); + fn exists_returns_true_for_present() { + let kv = handle(); + block_on(async { + kv.put_bytes("k", Bytes::from("v")).await.unwrap(); + assert!(kv.exists("k").await.unwrap()); }); } #[test] - fn typed_get_or_returns_existing() { - let h = handle(); - futures::executor::block_on(async { - h.put("visits", &99).await.unwrap(); - let count: i32 = h.get_or("visits", 0).await.unwrap(); - assert_eq!(count, 99); + fn get_or_with_complex_default() { + let kv = handle(); + block_on(async { + let default = Counter { count: 100_i32 }; + let val: Counter = kv.get_or("missing_struct", default).await.unwrap(); + assert_eq!(val.count, 100_i32); }); } #[test] - fn typed_get_bad_json_returns_serialization_error() { - let h = handle(); - futures::executor::block_on(async { - h.put_bytes("bad", Bytes::from("not json")).await.unwrap(); - let err = h.get::("bad").await.unwrap_err(); - assert!(matches!(err, KvError::Serialization(_))); + fn handle_is_cloneable_and_shares_state() { + let h1 = handle(); + let h2 = h1.clone(); + block_on(async { + h1.put("shared", &42_i32).await.unwrap(); + let val: i32 = h2.get_or("shared", 0_i32).await.unwrap(); + assert_eq!(val, 42_i32); }); } - // -- Update ------------------------------------------------------------- - #[test] - fn update_increments_counter() { - let h = handle(); - futures::executor::block_on(async { - h.put("c", &0i32).await.unwrap(); - let val = h.read_modify_write("c", 0i32, |n| n + 1).await.unwrap(); - assert_eq!(val, 1); - let val = h.read_modify_write("c", 0i32, |n| n + 1).await.unwrap(); - assert_eq!(val, 2); - }); + fn kv_error_internal_converts_to_internal() { + let kv_err = KvError::Internal(anyhow::anyhow!("boom")); + let edge_err: EdgeError = kv_err.into(); + assert_eq!(edge_err.status(), StatusCode::INTERNAL_SERVER_ERROR); + assert!(edge_err.message().contains("boom")); } #[test] - fn update_uses_default_when_missing() { - let h = handle(); - futures::executor::block_on(async { - let val = h.read_modify_write("new", 10i32, |n| n * 2).await.unwrap(); - assert_eq!(val, 20); - }); + fn kv_error_not_found_converts_to_not_found() { + let kv_err = KvError::NotFound { key: "test".into() }; + let edge_err: EdgeError = kv_err.into(); + assert_eq!(edge_err.status(), StatusCode::NOT_FOUND); + assert!(edge_err.message().contains("kv key")); } - // -- Exists ------------------------------------------------------------- + #[test] + fn kv_error_serialization_converts_to_internal() { + let json_err: serde_json::Error = serde_json::from_str::("not json").unwrap_err(); + let kv_err = KvError::Serialization(json_err); + let edge_err: EdgeError = kv_err.into(); + assert_eq!(edge_err.status(), StatusCode::INTERNAL_SERVER_ERROR); + assert!(edge_err.message().contains("serialization")); + } #[test] - fn exists_returns_false_for_missing() { - let h = handle(); - futures::executor::block_on(async { - assert!(!h.exists("nope").await.unwrap()); - }); + fn kv_error_unavailable_converts_to_service_unavailable() { + let kv_err = KvError::Unavailable; + let edge_err: EdgeError = kv_err.into(); + assert_eq!(edge_err.status(), StatusCode::SERVICE_UNAVAILABLE); } #[test] - fn exists_returns_true_for_present() { - let h = handle(); - futures::executor::block_on(async { - h.put_bytes("k", Bytes::from("v")).await.unwrap(); - assert!(h.exists("k").await.unwrap()); - }); + fn kv_error_unsupported_converts_to_not_implemented() { + let kv_err = KvError::Unsupported { + operation: "put_bytes_with_ttl".to_owned(), + }; + let edge_err: EdgeError = kv_err.into(); + assert_eq!(edge_err.status(), StatusCode::NOT_IMPLEMENTED); + assert!(edge_err.message().contains("put_bytes_with_ttl")); } - // -- Delete ------------------------------------------------------------- + #[test] + fn kv_error_limit_exceeded_converts_to_service_unavailable() { + let kv_err = KvError::LimitExceeded { + message: "max_list_keys=1000 exceeded".to_owned(), + }; + let edge_err: EdgeError = kv_err.into(); + assert_eq!(edge_err.status(), StatusCode::SERVICE_UNAVAILABLE); + assert!(edge_err.message().contains("max_list_keys")); + } #[test] - fn delete_removes_key() { - let h = handle(); - futures::executor::block_on(async { - h.put_bytes("k", Bytes::from("v")).await.unwrap(); - h.delete("k").await.unwrap(); - assert_eq!(h.get_bytes("k").await.unwrap(), None); - }); + fn kv_handle_debug_output() { + let kv = handle(); + let debug = format!("{kv:?}"); + assert!(debug.contains("KvHandle")); } #[test] - fn delete_missing_key_is_ok() { - let h = handle(); - futures::executor::block_on(async { - h.delete("nope").await.unwrap(); + fn large_value_roundtrip() { + let kv = handle(); + block_on(async { + let large = "x".repeat(1_000_000); // 1MB string + kv.put("big", &large).await.unwrap(); + let val: Option = kv.get("big").await.unwrap(); + assert_eq!(val.as_deref(), Some(large.as_str())); }); } #[test] fn list_keys_page_roundtrip() { - let h = handle(); - futures::executor::block_on(async { - h.put("app/a", &1i32).await.unwrap(); - h.put("app/b", &2i32).await.unwrap(); - h.put("app/c", &3i32).await.unwrap(); - h.put("other/d", &4i32).await.unwrap(); - - let first = h.list_keys_page("app/", None, 2).await.unwrap(); - assert_eq!(first.keys, vec!["app/a".to_string(), "app/b".to_string()]); + let kv = handle(); + block_on(async { + kv.put("app/a", &1_i32).await.unwrap(); + kv.put("app/b", &2_i32).await.unwrap(); + kv.put("app/c", &3_i32).await.unwrap(); + kv.put("other/d", &4_i32).await.unwrap(); + + let first = kv.list_keys_page("app/", None, 2).await.unwrap(); + assert_eq!(first.keys, vec!["app/a".to_owned(), "app/b".to_owned()]); assert!(first.cursor.is_some()); assert_ne!(first.cursor.as_deref(), Some("app/b")); - let second = h + let second = kv .list_keys_page("app/", first.cursor.as_deref(), 2) .await .unwrap(); - assert_eq!(second.keys, vec!["app/c".to_string()]); + assert_eq!(second.keys, vec!["app/c".to_owned()]); assert_eq!(second.cursor, None); }); } - // -- TTL ---------------------------------------------------------------- + #[test] + fn put_overwrite_changes_type() { + let kv = handle(); + block_on(async { + kv.put("flex", &42_i32).await.unwrap(); + let int_val: i32 = kv.get_or("flex", 0_i32).await.unwrap(); + assert_eq!(int_val, 42_i32); + + // Overwrite with a different type + kv.put("flex", &"now a string").await.unwrap(); + let str_val: String = kv.get_or("flex", String::new()).await.unwrap(); + assert_eq!(str_val, "now a string"); + }); + } #[test] fn put_with_ttl_stores_value() { - let h = handle(); - futures::executor::block_on(async { - h.put_with_ttl("session", &"token123", Duration::from_secs(60)) + let kv = handle(); + block_on(async { + kv.put_with_ttl("session", &"token123", Duration::from_mins(1)) .await .unwrap(); - let val: Option = h.get("session").await.unwrap(); - assert_eq!(val, Some("token123".to_string())); + let val: Option = kv.get("session").await.unwrap(); + assert_eq!(val, Some("token123".to_owned())); }); } - // -- KvError -> EdgeError ----------------------------------------------- + #[test] + fn put_with_ttl_typed_helper() { + let kv = handle(); + block_on(async { + let data = Counter { count: 7_i32 }; + kv.put_with_ttl("ttl_key", &data, Duration::from_mins(10)) + .await + .unwrap(); + let val: Option = kv.get("ttl_key").await.unwrap(); + assert_eq!(val, Some(Counter { count: 7_i32 })); + }); + } #[test] - fn kv_error_not_found_converts_to_not_found() { - let kv_err = KvError::NotFound { key: "test".into() }; - let edge_err: EdgeError = kv_err.into(); - assert_eq!(edge_err.status(), StatusCode::NOT_FOUND); - assert!(edge_err.message().contains("kv key")); + fn raw_bytes_missing_key_returns_none() { + let kv = handle(); + block_on(async { + assert_eq!(kv.get_bytes("missing").await.unwrap(), None); + }); } #[test] - fn kv_error_unavailable_converts_to_service_unavailable() { - let kv_err = KvError::Unavailable; - let edge_err: EdgeError = kv_err.into(); - assert_eq!(edge_err.status(), StatusCode::SERVICE_UNAVAILABLE); + fn raw_bytes_overwrite() { + let kv = handle(); + block_on(async { + kv.put_bytes("k", Bytes::from("a")).await.unwrap(); + kv.put_bytes("k", Bytes::from("b")).await.unwrap(); + assert_eq!(kv.get_bytes("k").await.unwrap(), Some(Bytes::from("b"))); + }); } #[test] - fn kv_error_internal_converts_to_internal() { - let kv_err = KvError::Internal(anyhow::anyhow!("boom")); - let edge_err: EdgeError = kv_err.into(); - assert_eq!(edge_err.status(), StatusCode::INTERNAL_SERVER_ERROR); - assert!(edge_err.message().contains("boom")); + fn raw_bytes_roundtrip() { + let kv = handle(); + block_on(async { + kv.put_bytes("k", Bytes::from("hello")).await.unwrap(); + assert_eq!(kv.get_bytes("k").await.unwrap(), Some(Bytes::from("hello"))); + }); } - // -- Clone handle ------------------------------------------------------- + #[test] + fn typed_get_bad_json_returns_serialization_error() { + let kv = handle(); + block_on(async { + kv.put_bytes("bad", Bytes::from("not json")).await.unwrap(); + let err = kv.get::("bad").await.unwrap_err(); + assert!(matches!(err, KvError::Serialization(_))); + }); + } #[test] - fn handle_is_cloneable_and_shares_state() { - let h1 = handle(); - let h2 = h1.clone(); - futures::executor::block_on(async { - h1.put("shared", &42i32).await.unwrap(); - let val: i32 = h2.get_or("shared", 0).await.unwrap(); - assert_eq!(val, 42); + fn typed_get_missing_returns_none() { + let kv = handle(); + block_on(async { + let out: Option = kv.get("nope").await.unwrap(); + assert_eq!(out, None); }); } - // -- Edge cases --------------------------------------------------------- + #[test] + fn typed_get_or_returns_default() { + let kv = handle(); + block_on(async { + let count: i32 = kv.get_or("visits", 0_i32).await.unwrap(); + assert_eq!(count, 0_i32); + }); + } #[test] - fn empty_key_rejected() { - let h = handle(); - futures::executor::block_on(async { - let err = h.put("", &"empty key").await.unwrap_err(); - assert!(matches!(err, KvError::Validation(_))); - assert!(format!("{}", err).contains("cannot be empty")); + fn typed_get_or_returns_existing() { + let kv = handle(); + block_on(async { + kv.put("visits", &99_i32).await.unwrap(); + let count: i32 = kv.get_or("visits", 0_i32).await.unwrap(); + assert_eq!(count, 99_i32); }); } #[test] - fn unicode_key_roundtrip() { - let h = handle(); - futures::executor::block_on(async { - h.put("日本語キー", &"value").await.unwrap(); - let val: Option = h.get("日本語キー").await.unwrap(); - assert_eq!(val, Some("value".to_string())); + fn typed_get_put_roundtrip() { + let kv = handle(); + block_on(async { + let data = Counter { count: 42 }; + kv.put("counter", &data).await.unwrap(); + let out: Option = kv.get("counter").await.unwrap(); + assert_eq!(out, Some(data)); }); } #[test] - fn large_value_roundtrip() { - let h = handle(); - futures::executor::block_on(async { - let large = "x".repeat(1_000_000); // 1MB string - h.put("big", &large).await.unwrap(); - let val: Option = h.get("big").await.unwrap(); - assert_eq!(val.as_deref(), Some(large.as_str())); + fn unicode_key_roundtrip() { + // "日本語キー" — the literal is written as Unicode escapes so the source + // file stays ASCII-only. The runtime bytes are identical. + const JAPANESE_KEY: &str = "\u{65E5}\u{672C}\u{8A9E}\u{30AD}\u{30FC}"; + let kv = handle(); + block_on(async { + kv.put(JAPANESE_KEY, &"value").await.unwrap(); + let val: Option = kv.get(JAPANESE_KEY).await.unwrap(); + assert_eq!(val, Some("value".to_owned())); }); } #[test] - fn put_with_ttl_typed_helper() { - let h = handle(); - futures::executor::block_on(async { - let data = Counter { count: 7 }; - h.put_with_ttl("ttl_key", &data, Duration::from_secs(600)) + fn update_increments_counter() { + let kv = handle(); + block_on(async { + kv.put("c", &0_i32).await.unwrap(); + let after_first = kv + .read_modify_write("c", 0_i32, |num| num + 1_i32) + .await + .unwrap(); + assert_eq!(after_first, 1_i32); + let after_second = kv + .read_modify_write("c", 0_i32, |num| num + 1_i32) .await .unwrap(); - let val: Option = h.get("ttl_key").await.unwrap(); - assert_eq!(val, Some(Counter { count: 7 })); + assert_eq!(after_second, 2_i32); }); } #[test] - fn get_or_with_complex_default() { - let h = handle(); - futures::executor::block_on(async { - let default = Counter { count: 100 }; - let val: Counter = h.get_or("missing_struct", default).await.unwrap(); - assert_eq!(val.count, 100); + fn update_uses_default_when_missing() { + let kv = handle(); + block_on(async { + let val = kv + .read_modify_write("new", 10_i32, |num| num * 2_i32) + .await + .unwrap(); + assert_eq!(val, 20_i32); }); } #[test] fn update_with_struct() { - let h = handle(); - futures::executor::block_on(async { - let val = h - .read_modify_write("counter_struct", Counter { count: 0 }, |mut c| { - c.count += 10; - c + let kv = handle(); + block_on(async { + let after_first = kv + .read_modify_write("counter_struct", Counter { count: 0_i32 }, |mut counter| { + counter.count += 10_i32; + counter }) .await .unwrap(); - assert_eq!(val.count, 10); + assert_eq!(after_first.count, 10_i32); - let val = h - .read_modify_write("counter_struct", Counter { count: 0 }, |mut c| { - c.count += 5; - c + let after_second = kv + .read_modify_write("counter_struct", Counter { count: 0_i32 }, |mut counter| { + counter.count += 5_i32; + counter }) .await .unwrap(); - assert_eq!(val.count, 15); + assert_eq!(after_second.count, 15_i32); }); } #[test] - fn kv_error_serialization_converts_to_internal() { - let json_err: serde_json::Error = serde_json::from_str::("not json").unwrap_err(); - let kv_err = KvError::Serialization(json_err); - let edge_err: EdgeError = kv_err.into(); - assert_eq!(edge_err.status(), StatusCode::INTERNAL_SERVER_ERROR); - assert!(edge_err.message().contains("serialization")); - } - - #[test] - fn kv_handle_debug_output() { - let h = handle(); - let debug = format!("{:?}", h); - assert!(debug.contains("KvHandle")); - } - - // -- Validation Tests --------------------------------------------------- - - #[test] - fn validation_rejects_long_keys() { - let h = handle(); - futures::executor::block_on(async { - let long_key = "a".repeat(KvHandle::MAX_KEY_SIZE + 1); - let err = h.get::(&long_key).await.unwrap_err(); + fn validation_rejects_control_chars() { + let kv = handle(); + block_on(async { + let err = kv.get::("key\nwith\nnewline").await.unwrap_err(); assert!(matches!(err, KvError::Validation(_))); - assert!(format!("{}", err).contains("key length")); + assert!(format!("{err}").contains("control characters")); }); } #[test] - fn validation_rejects_dot_keys() { - let h = handle(); - futures::executor::block_on(async { - let err = h.get::(".").await.unwrap_err(); - assert!(matches!(err, KvError::Validation(_))); - assert!(format!("{}", err).contains("cannot be exactly")); - - let err = h.get::("..").await.unwrap_err(); + fn validation_rejects_control_chars_in_prefix() { + let kv = handle(); + block_on(async { + let err = kv.list_keys_page("bad\nprefix", None, 1).await.unwrap_err(); assert!(matches!(err, KvError::Validation(_))); - assert!(format!("{}", err).contains("cannot be exactly")); + assert!(format!("{err}").contains("control characters")); }); } #[test] - fn validation_rejects_control_chars() { - let h = handle(); - futures::executor::block_on(async { - let err = h.get::("key\nwith\nnewline").await.unwrap_err(); - assert!(matches!(err, KvError::Validation(_))); - assert!(format!("{}", err).contains("control characters")); - }); - } + fn validation_rejects_cursor_for_different_prefix() { + let kv = handle(); + block_on(async { + kv.put("app/a", &1_i32).await.unwrap(); + kv.put("app/b", &2_i32).await.unwrap(); - #[test] - fn validation_rejects_large_values() { - let h = handle(); - futures::executor::block_on(async { - let large_val = vec![0u8; KvHandle::MAX_VALUE_SIZE + 1]; - let err = h - .put_bytes("large", Bytes::from(large_val)) + let page = kv.list_keys_page("app/", None, 1).await.unwrap(); + let err = kv + .list_keys_page("other/", page.cursor.as_deref(), 1) .await .unwrap_err(); assert!(matches!(err, KvError::Validation(_))); - assert!(format!("{}", err).contains("value size")); + assert!(format!("{err}").contains("requested prefix")); }); } #[test] - fn validation_rejects_short_ttl() { - let h = handle(); - futures::executor::block_on(async { - let err = h - .put_with_ttl("short", &"val", Duration::from_secs(10)) - .await - .unwrap_err(); - assert!(matches!(err, KvError::Validation(_))); - assert!(format!("{}", err).contains("at least 60 seconds")); + fn validation_rejects_dot_keys() { + let kv = handle(); + block_on(async { + let single_dot_err = kv.get::(".").await.unwrap_err(); + assert!(matches!(single_dot_err, KvError::Validation(_))); + assert!(format!("{single_dot_err}").contains("cannot be exactly")); + + let double_dot_err = kv.get::("..").await.unwrap_err(); + assert!(matches!(double_dot_err, KvError::Validation(_))); + assert!(format!("{double_dot_err}").contains("cannot be exactly")); }); } #[test] - fn validation_rejects_long_ttl() { - let h = handle(); - futures::executor::block_on(async { - let err = h - .put_with_ttl("long", &"val", KvHandle::MAX_TTL + Duration::from_secs(1)) + fn validation_rejects_large_list_limit() { + let kv = handle(); + block_on(async { + let err = kv + .list_keys_page("", None, KvHandle::MAX_LIST_PAGE_SIZE + 1) .await .unwrap_err(); assert!(matches!(err, KvError::Validation(_))); - assert!(format!("{}", err).contains("exceeds maximum")); + assert!(format!("{err}").contains("list limit")); }); } #[test] - fn validation_rejects_zero_list_limit() { - let h = handle(); - futures::executor::block_on(async { - let err = h.list_keys_page("", None, 0).await.unwrap_err(); + fn validation_rejects_large_values() { + let kv = handle(); + block_on(async { + let large_val = vec![0_u8; KvHandle::MAX_VALUE_SIZE + 1]; + let err = kv + .put_bytes("large", Bytes::from(large_val)) + .await + .unwrap_err(); assert!(matches!(err, KvError::Validation(_))); - assert!(format!("{}", err).contains("greater than zero")); + assert!(format!("{err}").contains("value size")); }); } #[test] - fn validation_rejects_large_list_limit() { - let h = handle(); - futures::executor::block_on(async { - let err = h - .list_keys_page("", None, KvHandle::MAX_LIST_PAGE_SIZE + 1) - .await - .unwrap_err(); + fn validation_rejects_long_keys() { + let kv = handle(); + block_on(async { + let long_key = "a".repeat(KvHandle::MAX_KEY_SIZE + 1); + let err = kv.get::(&long_key).await.unwrap_err(); assert!(matches!(err, KvError::Validation(_))); - assert!(format!("{}", err).contains("list limit")); + assert!(format!("{err}").contains("key length")); }); } #[test] fn validation_rejects_long_prefix() { - let h = handle(); - futures::executor::block_on(async { + let kv = handle(); + block_on(async { let prefix = "a".repeat(KvHandle::MAX_KEY_SIZE + 1); - let err = h.list_keys_page(&prefix, None, 1).await.unwrap_err(); + let err = kv.list_keys_page(&prefix, None, 1).await.unwrap_err(); assert!(matches!(err, KvError::Validation(_))); - assert!(format!("{}", err).contains("prefix length")); + assert!(format!("{err}").contains("prefix length")); }); } #[test] - fn validation_rejects_control_chars_in_prefix() { - let h = handle(); - futures::executor::block_on(async { - let err = h.list_keys_page("bad\nprefix", None, 1).await.unwrap_err(); + fn validation_rejects_long_ttl() { + let kv = handle(); + block_on(async { + let err = kv + .put_with_ttl("long", &"val", KvHandle::MAX_TTL + Duration::from_secs(1)) + .await + .unwrap_err(); assert!(matches!(err, KvError::Validation(_))); - assert!(format!("{}", err).contains("control characters")); + assert!(format!("{err}").contains("exceeds maximum")); }); } #[test] fn validation_rejects_malformed_list_cursor() { - let h = handle(); - futures::executor::block_on(async { - let err = h + let kv = handle(); + block_on(async { + let err = kv .list_keys_page("app/", Some("not-json"), 1) .await .unwrap_err(); assert!(matches!(err, KvError::Validation(_))); - assert!(format!("{}", err).contains("cursor")); + assert!(format!("{err}").contains("cursor")); }); } #[test] - fn validation_rejects_cursor_for_different_prefix() { - let h = handle(); - futures::executor::block_on(async { - h.put("app/a", &1i32).await.unwrap(); - h.put("app/b", &2i32).await.unwrap(); - - let page = h.list_keys_page("app/", None, 1).await.unwrap(); - let err = h - .list_keys_page("other/", page.cursor.as_deref(), 1) + fn validation_rejects_short_ttl() { + let kv = handle(); + block_on(async { + let err = kv + .put_with_ttl("short", &"val", Duration::from_secs(10)) .await .unwrap_err(); assert!(matches!(err, KvError::Validation(_))); - assert!(format!("{}", err).contains("requested prefix")); - }); - } - - #[test] - fn exists_returns_false_after_delete() { - let h = handle(); - futures::executor::block_on(async { - h.put_bytes("ephemeral", Bytes::from("v")).await.unwrap(); - assert!(h.exists("ephemeral").await.unwrap()); - h.delete("ephemeral").await.unwrap(); - assert!(!h.exists("ephemeral").await.unwrap()); + assert!(format!("{err}").contains("at least 60 seconds")); }); } #[test] - fn put_overwrite_changes_type() { - let h = handle(); - futures::executor::block_on(async { - h.put("flex", &42i32).await.unwrap(); - let val: i32 = h.get_or("flex", 0).await.unwrap(); - assert_eq!(val, 42); - - // Overwrite with a different type - h.put("flex", &"now a string").await.unwrap(); - let val: String = h.get_or("flex", String::new()).await.unwrap(); - assert_eq!(val, "now a string"); + fn validation_rejects_zero_list_limit() { + let kv = handle(); + block_on(async { + let err = kv.list_keys_page("", None, 0).await.unwrap_err(); + assert!(matches!(err, KvError::Validation(_))); + assert!(format!("{err}").contains("greater than zero")); }); } - - // Run the shared contract tests against MockStore. - crate::key_value_store_contract_tests!(mock_store_contract, MockStore::new()); } diff --git a/crates/edgezero-core/src/lib.rs b/crates/edgezero-core/src/lib.rs index f5970d9b..03197e73 100644 --- a/crates/edgezero-core/src/lib.rs +++ b/crates/edgezero-core/src/lib.rs @@ -1,11 +1,22 @@ //! Core primitives for building portable edge workloads across edge adapters. +// Targets a single line — the proc-macro re-export at the bottom of this +// file. The `pub_use` lint is module-scoped (cannot be `#[expect]`-ed +// per-item), and proc-macros must be re-exported here so downstream users +// depend only on `edgezero-core` (not `edgezero-macros`). +#![expect( + clippy::pub_use, + reason = "proc-macros must be re-exported through the parent crate" +)] + pub mod addr; pub mod app; +pub mod app_config; pub mod body; pub mod compression; pub mod config_store; pub mod context; +pub mod env_config; pub mod error; pub mod extractor; pub mod handler; @@ -19,12 +30,6 @@ pub mod responder; pub mod response; pub mod router; pub mod secret_store; +pub mod store_registry; -pub use config_store::{ConfigStore, ConfigStoreError, ConfigStoreHandle}; -pub use edgezero_macros::{action, app}; -#[cfg(any(test, feature = "test-utils"))] -pub use key_value_store::NoopKvStore; -pub use key_value_store::{KvError, KvHandle, KvPage, KvStore}; -#[cfg(any(test, feature = "test-utils"))] -pub use secret_store::{InMemorySecretStore, NoopSecretStore}; -pub use secret_store::{SecretError, SecretHandle, SecretStore}; +pub use edgezero_macros::{action, app, AppConfig}; diff --git a/crates/edgezero-core/src/manifest.rs b/crates/edgezero-core/src/manifest.rs index 9e29d17e..f8d8b4b9 100644 --- a/crates/edgezero-core/src/manifest.rs +++ b/crates/edgezero-core/src/manifest.rs @@ -1,9 +1,10 @@ use log::LevelFilter; -use serde::{Deserialize, Serialize}; -use std::collections::BTreeMap; -use std::io; +use serde::de::Error as DeError; +use serde::Deserialize; +use std::collections::{BTreeMap, BTreeSet}; use std::path::{Path, PathBuf}; use std::sync::Arc; +use std::{env, fs, io}; use validator::{Validate, ValidationError}; pub struct ManifestLoader { @@ -11,23 +12,14 @@ pub struct ManifestLoader { } impl ManifestLoader { - pub fn load_from_str(contents: &str) -> Self { - let mut manifest: Manifest = - toml::from_str(contents).expect("edgezero manifest should be valid"); - manifest - .validate() - .expect("edgezero manifest failed validation"); - manifest.finalize(); - Self { - manifest: Arc::new(manifest), - } - } - + /// # Errors + /// Returns an [`io::Error`] if `path` cannot be read, or the file content cannot be parsed/validated as an `EdgeZero` manifest. + #[inline] pub fn from_path(path: &Path) -> Result { - let contents = std::fs::read_to_string(path)?; + let contents = fs::read_to_string(path)?; let mut manifest: Manifest = toml::from_str(&contents) .map_err(|err| io::Error::new(io::ErrorKind::InvalidData, err))?; - let cwd = std::env::current_dir()?; + let cwd = env::current_dir()?; let root_path = resolve_root_path(path, &cwd); manifest.root = Some(root_path); manifest @@ -39,66 +31,96 @@ impl ManifestLoader { }) } + /// Loads a manifest from a static, in-process TOML string — + /// fixture data in tests, build-time compile-checks, and the + /// `app!` macro's compile-time consumption are the in-tree callers. + /// The portable store-registry rewrite removed the per-adapter + /// `run_app(include_str!("edgezero.toml"), …)` shape, so an adapter + /// binary no longer carries the manifest at runtime; the portable + /// store registry it would have extracted is baked into + /// `Hooks::stores()` by the macro instead. + /// + /// # Panics + /// Panics if `contents` is not valid TOML or fails validation. + /// Because `contents` is statically known to the caller (a + /// compile-time literal in the macro / tests), a parse failure + /// indicates corruption that can't be recovered at runtime, and + /// surfacing it as a clear panic is the right behaviour. Callers + /// with a fallible input source (file paths, network, user input) + /// should use [`ManifestLoader::try_load_from_str`] or + /// [`ManifestLoader::from_path`]. + #[expect( + clippy::panic, + reason = "load_from_str only consumes statically-known manifest \ + literals (macro/tests); a parse error means the caller's \ + static input is corrupt and cannot recover" + )] + #[must_use] + #[inline] + pub fn load_from_str(contents: &str) -> Self { + Self::try_load_from_str(contents).unwrap_or_else(|err| panic!("invalid manifest: {err}")) + } + + #[must_use] + #[inline] pub fn manifest(&self) -> &Manifest { &self.manifest } -} -fn resolve_root_path(path: &Path, cwd: &Path) -> PathBuf { - match path.parent() { - Some(parent) if parent.as_os_str().is_empty() => cwd.to_path_buf(), - Some(parent) if parent.is_relative() => cwd.join(parent), - Some(parent) => parent.to_path_buf(), - None => cwd.to_path_buf(), + /// # Errors + /// Returns an [`io::Error`] if `contents` is not valid TOML or fails manifest validation. + #[inline] + pub fn try_load_from_str(contents: &str) -> Result { + let mut manifest: Manifest = toml::from_str(contents) + .map_err(|err| io::Error::new(io::ErrorKind::InvalidData, err))?; + manifest + .validate() + .map_err(|err| io::Error::new(io::ErrorKind::InvalidData, err))?; + manifest.finalize(); + Ok(Self { + manifest: Arc::new(manifest), + }) } } -pub const DEFAULT_CONFIG_STORE_NAME: &str = "EDGEZERO_CONFIG"; -// Spin config values come from Spin component variables (flat namespace); -// there is no runtime store-name concept, so adapter-name overrides for spin -// would be silently ignored. Keep spin out of the allowed set to surface -// misconfiguration at validation time rather than at runtime. -const SUPPORTED_CONFIG_STORE_ADAPTERS: &[&str] = &["axum", "cloudflare", "fastly"]; - #[derive(Debug, Deserialize, Validate)] +#[expect( + clippy::partial_pub_fields, + reason = "deserialized fields are pub for the public API; internal state is private" +)] pub struct Manifest { #[serde(default)] #[validate(nested)] - pub app: ManifestApp, + pub adapters: BTreeMap, #[serde(default)] #[validate(nested)] - pub triggers: ManifestTriggers, + pub app: ManifestApp, #[serde(default)] #[validate(nested)] pub environment: ManifestEnvironment, #[serde(default)] #[validate(nested)] - pub stores: ManifestStores, + pub logging: ManifestLogging, + #[serde(skip)] + logging_resolved: BTreeMap, + #[serde(skip)] + root: Option, #[serde(default)] #[validate(nested)] - pub adapters: BTreeMap, + pub stores: ManifestStores, #[serde(default)] #[validate(nested)] - pub logging: ManifestLogging, - #[serde(skip)] - pub(crate) root: Option, - #[serde(skip)] - pub(crate) logging_resolved: BTreeMap, + pub triggers: ManifestTriggers, } impl Manifest { - pub fn root(&self) -> Option<&Path> { - self.root.as_deref() - } - - pub fn logging_for(&self, adapter: &str) -> Option<&ResolvedLoggingConfig> { - self.logging_resolved.get(adapter) - } - - pub fn logging_or_default(&self, adapter: &str) -> ResolvedLoggingConfig { - self.logging_for(adapter).cloned().unwrap_or_default() + #[must_use] + #[inline] + pub fn environment(&self) -> &ManifestEnvironment { + &self.environment } + #[inline] pub fn environment_for(&self, adapter: &str) -> ResolvedEnvironment { let adapter_lower = adapter.to_ascii_lowercase(); @@ -118,77 +140,7 @@ impl Manifest { .map(ResolvedEnvironmentBinding::from_manifest) .collect(); - ResolvedEnvironment { variables, secrets } - } - - pub fn environment(&self) -> &ManifestEnvironment { - &self.environment - } - - /// Returns the KV store name for a given adapter. - /// - /// Resolution order: - /// 1. Per-adapter override (`[stores.kv.adapters.]`) - /// 2. Global name (`[stores.kv] name = "..."`) - /// 3. Default: `"EDGEZERO_KV"` - pub fn kv_store_name(&self, adapter: &str) -> &str { - match &self.stores.kv { - Some(kv) => { - let adapter_lower = adapter.to_ascii_lowercase(); - if let Some(adapter_cfg) = kv - .adapters - .iter() - .find(|(k, _)| k.eq_ignore_ascii_case(&adapter_lower)) - { - return &adapter_cfg.1.name; - } - &kv.name - } - None => DEFAULT_KV_STORE_NAME, - } - } - - /// Returns the secret store name for a given adapter. - /// - /// Resolution order: - /// 1. Per-adapter override (`[stores.secrets.adapters.]`) - /// 2. Global name (`[stores.secrets] name = "..."`) - /// 3. Default: `"EDGEZERO_SECRETS"` - pub fn secret_store_name(&self, adapter: &str) -> &str { - match &self.stores.secrets { - Some(secrets) => { - let adapter_lower = adapter.to_ascii_lowercase(); - if let Some(adapter_cfg) = secrets - .adapters - .iter() - .find(|(k, _)| k.eq_ignore_ascii_case(&adapter_lower)) - { - if let Some(name) = adapter_cfg.1.name.as_deref() { - return name; - } - } - &secrets.name - } - None => DEFAULT_SECRET_STORE_NAME, - } - } - - /// Returns whether the secret store should be attached for a given adapter. - pub fn secret_store_enabled(&self, adapter: &str) -> bool { - match &self.stores.secrets { - Some(secrets) => { - let adapter_lower = adapter.to_ascii_lowercase(); - if let Some(adapter_cfg) = secrets - .adapters - .iter() - .find(|(k, _)| k.eq_ignore_ascii_case(&adapter_lower)) - { - return adapter_cfg.1.enabled; - } - secrets.enabled - } - None => false, - } + ResolvedEnvironment { secrets, variables } } pub(crate) fn finalize(&mut self) { @@ -211,21 +163,50 @@ impl Manifest { self.logging_resolved = resolved; } + + #[must_use] + #[inline] + pub fn logging_for(&self, adapter: &str) -> Option<&ResolvedLoggingConfig> { + self.logging_resolved.get(adapter) + } + + #[must_use] + #[inline] + pub fn logging_or_default(&self, adapter: &str) -> ResolvedLoggingConfig { + self.logging_for(adapter).cloned().unwrap_or_default() + } + + #[must_use] + #[inline] + pub fn root(&self) -> Option<&Path> { + self.root.as_deref() + } + + /// Returns whether the secret store should be attached for a given adapter. + /// + /// True whenever a `[stores.secrets]` section is declared. + #[must_use] + #[inline] + pub fn secret_store_enabled(&self, _adapter: &str) -> bool { + self.stores.secrets.is_some() + } } #[derive(Debug, Default, Deserialize, Validate)] +#[non_exhaustive] pub struct ManifestApp { #[serde(default)] - #[validate(length(min = 1))] - pub name: Option, - #[serde(default)] - #[validate(length(min = 1))] + #[validate(length(min = 1_u64))] pub entry: Option, #[serde(default)] pub middleware: Vec, + #[serde(default)] + #[validate(length(min = 1_u64))] + pub name: Option, } #[derive(Debug, Default, Deserialize, Validate)] +#[non_exhaustive] pub struct ManifestTriggers { #[serde(default)] #[validate(nested)] @@ -233,59 +214,67 @@ pub struct ManifestTriggers { } #[derive(Clone, Debug, Deserialize, Validate)] +#[non_exhaustive] pub struct ManifestHttpTrigger { #[serde(default)] - #[validate(length(min = 1))] - pub id: Option, - #[validate(length(min = 1))] - pub path: String, + pub adapters: Vec, + #[serde(rename = "body-mode")] #[serde(default)] - #[validate(length(min = 1))] - pub handler: Option, + pub body_mode: Option, #[serde(default)] - pub methods: Vec, + #[validate(length(min = 1_u64))] + pub description: Option, #[serde(default)] - pub adapters: Vec, + #[validate(length(min = 1_u64))] + pub handler: Option, #[serde(default)] - #[validate(length(min = 1))] - pub description: Option, - #[serde(rename = "body-mode")] + #[validate(length(min = 1_u64))] + pub id: Option, #[serde(default)] - pub body_mode: Option, + pub methods: Vec, + #[validate(length(min = 1_u64))] + pub path: String, } impl ManifestHttpTrigger { + #[inline] pub fn methods(&self) -> Vec<&str> { if self.methods.is_empty() { vec!["GET"] } else { - self.methods.iter().map(|m| m.as_str()).collect() + self.methods + .iter() + .copied() + .map(HttpMethod::as_str) + .collect() } } } #[derive(Debug, Default, Deserialize, Validate)] +#[non_exhaustive] pub struct ManifestEnvironment { #[serde(default)] #[validate(nested)] - pub variables: Vec, + pub secrets: Vec, #[serde(default)] #[validate(nested)] - pub secrets: Vec, + pub variables: Vec, } #[derive(Debug, Deserialize, Validate)] +#[non_exhaustive] pub struct ManifestBinding { - #[validate(length(min = 1))] - pub name: String, - #[serde(default)] - #[validate(length(min = 1))] - pub description: Option, #[serde(default)] pub adapters: Vec, #[serde(default)] - #[validate(length(min = 1))] + #[validate(length(min = 1_u64))] + pub description: Option, + #[serde(default)] + #[validate(length(min = 1_u64))] pub env: Option, + #[validate(length(min = 1_u64))] + pub name: String, #[serde(default)] pub value: Option, } @@ -318,19 +307,21 @@ impl ResolvedEnvironmentBinding { #[derive(Clone, Debug)] pub struct ResolvedEnvironmentBinding { - pub name: String, pub description: Option, pub env: String, + pub name: String, pub value: Option, } #[derive(Clone, Debug, Default)] pub struct ResolvedEnvironment { - pub variables: Vec, pub secrets: Vec, + pub variables: Vec, } #[derive(Debug, Default, Deserialize, Validate)] +#[non_exhaustive] +#[validate(schema(function = "validate_manifest_adapter"))] pub struct ManifestAdapter { #[serde(default)] #[validate(nested)] @@ -341,56 +332,93 @@ pub struct ManifestAdapter { #[serde(default)] #[validate(nested)] pub commands: ManifestAdapterCommands, + /// Catch-all for any sub-table other than the four canonical ones + /// (`adapter`, `build`, `commands`, `logging`). The pre-rewrite + /// `[adapters..stores.*]` tables land here and are rejected by + /// [`validate_manifest_adapter`] with the migration-guide message. + #[serde(flatten)] + pub legacy: BTreeMap, #[serde(default)] #[validate(nested)] pub logging: ManifestLoggingConfig, } #[derive(Debug, Default, Deserialize, Validate)] +#[non_exhaustive] +#[validate(schema(function = "validate_manifest_adapter_definition"))] pub struct ManifestAdapterDefinition { + /// Spin component id, when the adapter's `manifest` (`spin.toml`) declares + /// more than one `[component.*]`. Read by `provision` and + /// `config push`; ignored at runtime. `config validate --strict` + /// requires it when `spin.toml` declares multiple components. + #[serde(default)] + #[validate(length(min = 1_u64))] + pub component: Option, #[serde(rename = "crate")] #[serde(default)] - #[validate(length(min = 1))] + #[validate(length(min = 1_u64))] pub crate_path: Option, - #[serde(default)] - #[validate(length(min = 1))] - pub manifest: Option, /// Bind address for the adapter server (e.g. `"0.0.0.0"` or `"127.0.0.1"`). /// /// Stored as a raw string so validation can be deferred until bind-address /// resolution, where environment-variable overrides and fallback behavior /// are applied consistently (see [`crate::addr::resolve_bind_addr`]). #[serde(default)] - #[validate(length(min = 1))] + #[validate(length(min = 1_u64))] pub host: Option, + /// Catch-all for any field other than the declared ones above. The + /// portable manifest has no per-adapter runtime tuning surface, so an + /// unknown key under `[adapters..adapter]` is rejected at load + /// time rather than silently ignored. + #[serde(flatten)] + pub legacy: BTreeMap, + #[serde(default)] + #[validate(length(min = 1_u64))] + pub manifest: Option, /// Port for the adapter server. #[serde(default)] pub port: Option, } #[derive(Debug, Default, Deserialize, Validate)] +#[non_exhaustive] pub struct ManifestAdapterBuild { #[serde(default)] - #[validate(length(min = 1))] - pub target: Option, + pub features: Vec, #[serde(default)] - #[validate(length(min = 1))] + #[validate(length(min = 1_u64))] pub profile: Option, #[serde(default)] - pub features: Vec, + #[validate(length(min = 1_u64))] + pub target: Option, } #[derive(Debug, Default, Deserialize, Validate)] +#[non_exhaustive] pub struct ManifestAdapterCommands { + /// Per-project override for `edgezero auth login --adapter `. + /// `None` (the default) means "use the adapter's built-in + /// command" — `wrangler login`, `fastly profile create`, etc. + #[serde(default, rename = "auth-login")] + #[validate(length(min = 1_u64))] + pub auth_login: Option, + /// Per-project override for `edgezero auth logout --adapter `. + #[serde(default, rename = "auth-logout")] + #[validate(length(min = 1_u64))] + pub auth_logout: Option, + /// Per-project override for `edgezero auth status --adapter `. + #[serde(default, rename = "auth-status")] + #[validate(length(min = 1_u64))] + pub auth_status: Option, #[serde(default)] - #[validate(length(min = 1))] + #[validate(length(min = 1_u64))] pub build: Option, #[serde(default)] - #[validate(length(min = 1))] - pub serve: Option, - #[serde(default)] - #[validate(length(min = 1))] + #[validate(length(min = 1_u64))] pub deploy: Option, + #[serde(default)] + #[validate(length(min = 1_u64))] + pub serve: Option, } // --------------------------------------------------------------------------- @@ -399,104 +427,56 @@ pub struct ManifestAdapterCommands { /// Top-level `[stores]` section. #[derive(Debug, Default, Deserialize, Validate)] +#[non_exhaustive] pub struct ManifestStores { #[serde(default)] #[validate(nested)] - pub config: Option, + pub config: Option, #[serde(default)] #[validate(nested)] - pub kv: Option, + pub kv: Option, #[serde(default)] #[validate(nested)] - pub secrets: Option, + pub secrets: Option, } -/// `[stores.config]` section — provider-neutral config store. +/// Portable `[stores.]` declaration. +/// +/// Declares logical store ids only — the portable fact that "this app uses a +/// KV/config/secrets store called ``". No platform names, no per-adapter +/// tuning. Platform-specific runtime config (store names, tuning) is supplied +/// out of band; in this interim model a store's name resolves to its logical +/// [`StoreDeclaration::default_id`]. #[derive(Debug, Deserialize, Validate)] -pub struct ManifestConfigStoreConfig { - /// Global store/binding name used when no adapter-specific override is set. - #[serde(default)] - #[validate(length(min = 1))] - pub name: Option, - /// Per-adapter name overrides, keyed by supported lowercase adapter name - /// (`axum`, `cloudflare`, or `fastly`). Spin config uses component - /// variables in a flat namespace, so `stores.config.adapters.spin` is - /// rejected during validation. +#[non_exhaustive] +#[validate(schema(function = "validate_store_declaration"))] +pub struct StoreDeclaration { + /// Logical default store id. Required when `ids.len() > 1`; when there is + /// exactly one id it resolves to `ids[0]`. #[serde(default)] - #[validate(nested)] - #[validate(custom(function = "validate_config_store_adapter_keys"))] - pub adapters: BTreeMap, - /// Optional default values used for local dev (Axum adapter). + pub default: Option, + /// Logical store ids — non-empty (enforced in validation, not by serde, so + /// a legacy manifest is rejected with the migration-guide message rather + /// than a bare "missing field `ids`" parse error). #[serde(default)] - pub defaults: BTreeMap, -} - -/// `[stores.config.adapters.]` override. -#[derive(Debug, Deserialize, Serialize, Validate)] -pub struct ManifestConfigAdapterConfig { - #[validate(length(min = 1))] - pub name: String, + pub ids: Vec, + /// Any field other than `ids` / `default` — the pre-rewrite store schema + /// (`name`, `enabled`, `adapters`, `defaults`) lands here and is rejected + /// with a migration-guide message during validation. + #[serde(flatten)] + pub legacy: BTreeMap, } -fn validate_config_store_adapter_keys( - adapters: &BTreeMap, -) -> Result<(), ValidationError> { - let mixed_case_keys = adapters - .keys() - .filter(|key| key.as_str() != key.to_ascii_lowercase()) - .cloned() - .collect::>(); - if !mixed_case_keys.is_empty() { - let mut error = ValidationError::new("config_store_adapter_keys_lowercase"); - error.message = Some( - format!( - "config store adapter override keys must be lowercase: {}", - mixed_case_keys.join(", ") - ) - .into(), - ); - return Err(error); - } - - let unknown_keys = adapters - .keys() - .filter(|key| !SUPPORTED_CONFIG_STORE_ADAPTERS.contains(&key.as_str())) - .cloned() - .collect::>(); - if unknown_keys.is_empty() { - return Ok(()); - } - - let mut error = ValidationError::new("config_store_adapter_keys_known"); - error.message = Some( - format!( - "config store adapter override keys must match supported adapters ({}): {}", - SUPPORTED_CONFIG_STORE_ADAPTERS.join(", "), - unknown_keys.join(", ") - ) - .into(), - ); - Err(error) -} - -impl ManifestConfigStoreConfig { - /// Resolve the config store name for a given adapter. - /// - /// Priority: adapter override → global name → `DEFAULT_CONFIG_STORE_NAME`. - pub fn config_store_name(&self, adapter: &str) -> &str { - let adapter_lower = adapter.to_ascii_lowercase(); - if let Some(override_cfg) = self.adapters.get(&adapter_lower) { - return &override_cfg.name; - } - if let Some(name) = &self.name { - return name.as_str(); - } - DEFAULT_CONFIG_STORE_NAME - } - - /// Access the default key-value pairs for local dev. - pub fn config_store_defaults(&self) -> &BTreeMap { - &self.defaults +impl StoreDeclaration { + /// Resolve the default logical store id (the explicit `default`, else the + /// first declared id). + #[must_use] + #[inline] + pub fn default_id(&self) -> &str { + self.default + .as_deref() + .or_else(|| self.ids.first().map(String::as_str)) + .unwrap_or("") } } @@ -505,6 +485,7 @@ impl ManifestConfigStoreConfig { // --------------------------------------------------------------------------- #[derive(Debug, Default, Deserialize, Validate)] +#[non_exhaustive] pub struct ManifestLogging { #[serde(flatten)] #[validate(nested)] @@ -512,24 +493,26 @@ pub struct ManifestLogging { } #[derive(Debug, Default, Deserialize, Clone, Validate)] +#[non_exhaustive] pub struct ManifestLoggingConfig { #[serde(default)] - pub level: Option, + pub echo_stdout: Option, #[serde(default)] - #[validate(length(min = 1))] + #[validate(length(min = 1_u64))] pub endpoint: Option, #[serde(default)] - pub echo_stdout: Option, + pub level: Option, } #[derive(Debug, Clone)] pub struct ResolvedLoggingConfig { - pub level: LogLevel, - pub endpoint: Option, pub echo_stdout: Option, + pub endpoint: Option, + pub level: LogLevel, } impl Default for ResolvedLoggingConfig { + #[inline] fn default() -> Self { Self { level: LogLevel::Info, @@ -545,7 +528,7 @@ impl ResolvedLoggingConfig { if let Some(level) = cfg.level { resolved.level = level; } - if let Some(endpoint) = &cfg.endpoint { + if let Some(endpoint) = cfg.endpoint.as_ref() { resolved.endpoint = Some(endpoint.clone()); } if let Some(echo_stdout) = cfg.echo_stdout { @@ -561,102 +544,44 @@ impl ManifestLoggingConfig { } } -/// Default KV store / binding name used when `[stores.kv]` is omitted. -pub const DEFAULT_KV_STORE_NAME: &str = "EDGEZERO_KV"; - -fn default_kv_name() -> String { - DEFAULT_KV_STORE_NAME.to_string() -} - -/// Default secret store / binding name used when `[stores.secrets]` is omitted. -pub const DEFAULT_SECRET_STORE_NAME: &str = "EDGEZERO_SECRETS"; - -fn default_secret_name() -> String { - DEFAULT_SECRET_STORE_NAME.to_string() -} - -fn default_enabled() -> bool { - true -} - -/// Global KV store configuration. -#[derive(Debug, Deserialize, Validate)] -pub struct ManifestKvConfig { - /// Store / binding name (default: `"EDGEZERO_KV"`). - #[serde(default = "default_kv_name")] - #[validate(length(min = 1))] - pub name: String, - - /// Per-adapter name overrides. - #[serde(default)] - #[validate(nested)] - pub adapters: BTreeMap, -} - -/// Per-adapter KV binding / store name override. -#[derive(Debug, Deserialize, Validate)] -pub struct ManifestKvAdapterConfig { - #[validate(length(min = 1))] - pub name: String, -} - -/// Global secret store configuration. -#[derive(Debug, Deserialize, Validate)] -pub struct ManifestSecretsConfig { - /// Whether the secret store is enabled for adapters without overrides. - #[serde(default = "default_enabled")] - pub enabled: bool, - - /// Store / binding name (default: `"EDGEZERO_SECRETS"`). - #[serde(default = "default_secret_name")] - #[validate(length(min = 1))] - pub name: String, - - /// Per-adapter name overrides. - #[serde(default)] - #[validate(nested)] - pub adapters: BTreeMap, -} - -/// Per-adapter secret store name override. -#[derive(Debug, Deserialize, Validate)] -pub struct ManifestSecretsAdapterConfig { - /// Whether the secret store is enabled for this adapter. - #[serde(default = "default_enabled")] - pub enabled: bool, - - /// Optional per-adapter secret store name override. - #[serde(default)] - #[validate(length(min = 1))] - pub name: Option, -} - -#[derive(Clone, Debug, Eq, PartialEq)] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[non_exhaustive] pub enum HttpMethod { + Delete, Get, + Head, + Options, + Patch, Post, Put, - Delete, - Patch, - Options, - Head, } impl HttpMethod { - pub fn as_str(&self) -> &'static str { + #[must_use] + #[inline] + pub fn as_str(self) -> &'static str { match self { + Self::Delete => "DELETE", Self::Get => "GET", + Self::Head => "HEAD", + Self::Options => "OPTIONS", + Self::Patch => "PATCH", Self::Post => "POST", Self::Put => "PUT", - Self::Delete => "DELETE", - Self::Patch => "PATCH", - Self::Options => "OPTIONS", - Self::Head => "HEAD", } } } +// Serde's `Deserialize` trait has an optional `deserialize_in_place` method +// that defaults to `*place = Self::deserialize(deserializer)?`. For these +// small Copy/clone enums there is nothing to gain from spelling out an +// override — the default already does exactly the right thing. +#[expect( + clippy::missing_trait_methods, + reason = "default deserialize_in_place is identical to what we would write manually" +)] impl<'de> Deserialize<'de> for HttpMethod { + #[inline] fn deserialize(deserializer: D) -> Result where D: serde::Deserializer<'de>, @@ -670,21 +595,30 @@ impl<'de> Deserialize<'de> for HttpMethod { "PATCH" => Ok(Self::Patch), "OPTIONS" => Ok(Self::Options), "HEAD" => Ok(Self::Head), - other => Err(serde::de::Error::custom(format!( - "unsupported HTTP method `{}`", - other + other => Err(DeError::custom(format!( + "unsupported HTTP method `{other}`" ))), } } } #[derive(Clone, Debug, Eq, PartialEq)] +#[non_exhaustive] pub enum BodyMode { Buffered, Stream, } +// Serde's `Deserialize` trait has an optional `deserialize_in_place` method +// that defaults to `*place = Self::deserialize(deserializer)?`. For these +// small Copy/clone enums there is nothing to gain from spelling out an +// override — the default already does exactly the right thing. +#[expect( + clippy::missing_trait_methods, + reason = "default deserialize_in_place is identical to what we would write manually" +)] impl<'de> Deserialize<'de> for BodyMode { + #[inline] fn deserialize(deserializer: D) -> Result where D: serde::Deserializer<'de>, @@ -693,27 +627,27 @@ impl<'de> Deserialize<'de> for BodyMode { match value.trim().to_ascii_lowercase().as_str() { "buffered" => Ok(Self::Buffered), "stream" => Ok(Self::Stream), - other => Err(serde::de::Error::custom(format!( - "unsupported body mode `{}`", - other - ))), + other => Err(DeError::custom(format!("unsupported body mode `{other}`"))), } } } #[derive(Clone, Copy, Debug, Eq, PartialEq, Default)] +#[non_exhaustive] pub enum LogLevel { - Trace, Debug, + Error, #[default] Info, - Warn, - Error, Off, + Trace, + Warn, } impl LogLevel { - pub fn as_str(&self) -> &'static str { + #[must_use] + #[inline] + pub fn as_str(self) -> &'static str { match self { Self::Trace => "trace", Self::Debug => "debug", @@ -726,6 +660,7 @@ impl LogLevel { } impl From for LevelFilter { + #[inline] fn from(level: LogLevel) -> Self { match level { LogLevel::Trace => LevelFilter::Trace, @@ -738,7 +673,16 @@ impl From for LevelFilter { } } +// Serde's `Deserialize` trait has an optional `deserialize_in_place` method +// that defaults to `*place = Self::deserialize(deserializer)?`. For these +// small Copy/clone enums there is nothing to gain from spelling out an +// override — the default already does exactly the right thing. +#[expect( + clippy::missing_trait_methods, + reason = "default deserialize_in_place is identical to what we would write manually" +)] impl<'de> Deserialize<'de> for LogLevel { + #[inline] fn deserialize(deserializer: D) -> Result where D: serde::Deserializer<'de>, @@ -751,19 +695,156 @@ impl<'de> Deserialize<'de> for LogLevel { "warn" => Ok(Self::Warn), "error" => Ok(Self::Error), "off" => Ok(Self::Off), - other => Err(serde::de::Error::custom(format!( - "logging level must be trace, debug, info, warn, error, or off (got `{}`)", - other + other => Err(DeError::custom(format!( + "logging level must be trace, debug, info, warn, error, or off (got `{other}`)" ))), } } } +fn resolve_root_path(path: &Path, cwd: &Path) -> PathBuf { + match path.parent() { + Some(parent) if parent.as_os_str().is_empty() => cwd.to_path_buf(), + Some(parent) if parent.is_relative() => cwd.join(parent), + Some(parent) => parent.to_path_buf(), + None => cwd.to_path_buf(), + } +} + +/// Validates a single `[adapters..adapter]` block. The portable +/// manifest model lists the declared fields explicitly; an unknown key +/// would otherwise be silently dropped by serde, so we surface it as a +/// hard load error with the migration-guide pointer (consistent with the +/// hard-cutoff on `[stores.]` and `[adapters..]`). +fn validate_manifest_adapter_definition( + definition: &ManifestAdapterDefinition, +) -> Result<(), ValidationError> { + if !definition.legacy.is_empty() { + let mut keys = definition.legacy.keys().cloned().collect::>(); + keys.sort(); + let mut error = ValidationError::new("legacy_adapter_definition_schema"); + error.message = Some( + format!( + "unknown field(s) under `[adapters..adapter]`: {}. The portable \ + manifest has no per-adapter runtime tuning surface beyond \ + `component`, `crate`, `host`, `manifest`, `port` -- see \ + docs/guide/manifest-store-migration.md", + keys.join(", ") + ) + .into(), + ); + return Err(error); + } + Ok(()) +} + +/// Validates a single `[adapters.]` block. The portable manifest model +/// has no per-adapter store / runtime tuning surface — all of that moved to +/// `EDGEZERO__*` env vars. The pre-rewrite +/// `[adapters..stores.]` tables and the legacy +/// `[adapters..adapter] runtime` block were silently ignored by the +/// deserializer before this hard-cutoff, so projects could carry over +/// stale entries without noticing. +fn validate_manifest_adapter(adapter: &ManifestAdapter) -> Result<(), ValidationError> { + if !adapter.legacy.is_empty() { + let mut keys = adapter.legacy.keys().cloned().collect::>(); + keys.sort(); + let mut error = ValidationError::new("legacy_adapter_schema"); + error.message = Some( + format!( + "the pre-rewrite `[adapters..]` subtables are no longer \ + supported (offending field(s): {}); per-adapter store / runtime \ + tuning moved to `EDGEZERO__*` env vars -- see \ + docs/guide/manifest-store-migration.md", + keys.join(", ") + ) + .into(), + ); + return Err(error); + } + Ok(()) +} + +/// Validates a single `[stores.]` declaration against the portable +/// schema. +/// +/// Rejects the pre-rewrite store fields (`name`, `enabled`, `adapters`, +/// `defaults`) with an error pointing at the migration guide, and enforces the +/// `ids` / `default` invariants. +fn validate_store_declaration(declaration: &StoreDeclaration) -> Result<(), ValidationError> { + if !declaration.legacy.is_empty() { + let mut keys = declaration.legacy.keys().cloned().collect::>(); + keys.sort(); + let mut error = ValidationError::new("legacy_store_schema"); + error.message = Some( + format!( + "the pre-rewrite `[stores.]` schema is no longer supported \ + (offending field(s): {}); migrate to the portable `ids` / `default` \ + form -- see docs/guide/manifest-store-migration.md", + keys.join(", ") + ) + .into(), + ); + return Err(error); + } + + if declaration.ids.is_empty() { + let mut error = ValidationError::new("store_ids_empty"); + error.message = + Some("`[stores.].ids` must declare at least one logical store id".into()); + return Err(error); + } + + if let Some(blank) = declaration + .ids + .iter() + .find(|id| id.trim().is_empty() || id.chars().any(char::is_control)) + { + let mut error = ValidationError::new("store_id_blank"); + error.message = Some( + format!( + "`[stores.].ids` entries must be non-empty and printable \ + (offending value: {blank:?})" + ) + .into(), + ); + return Err(error); + } + + let mut seen: BTreeSet<&str> = BTreeSet::new(); + if let Some(dup) = declaration.ids.iter().find(|id| !seen.insert(id.as_str())) { + let mut error = ValidationError::new("store_id_duplicate"); + error.message = Some(format!("`[stores.].ids` contains duplicate id `{dup}`").into()); + return Err(error); + } + + if declaration.ids.len() > 1 && declaration.default.is_none() { + let mut error = ValidationError::new("store_default_required"); + error.message = Some( + "`default` is required when `[stores.]` declares more than one id \ + -- see docs/guide/manifest-store-migration.md" + .into(), + ); + return Err(error); + } + + if let Some(default) = declaration.default.as_deref() { + if !declaration.ids.iter().any(|id| id == default) { + let mut error = ValidationError::new("store_default_unknown"); + error.message = + Some(format!("`default` (`{default}`) must be one of the declared `ids`").into()); + return Err(error); + } + } + + Ok(()) +} + #[cfg(test)] mod tests { use super::*; - use std::fs; use std::path::PathBuf; + use std::process; use tempfile::{tempdir, tempdir_in, NamedTempFile}; const SAMPLE: &str = r#" @@ -801,6 +882,37 @@ env = "APP_TOKEN" assert_eq!(manifest.app.name.as_deref(), Some("demo")); } + #[test] + fn try_load_from_str_rejects_invalid_toml() { + let err = ManifestLoader::try_load_from_str("not a [valid manifest\n") + .err() + .expect("expected err"); + assert_eq!(err.kind(), io::ErrorKind::InvalidData); + assert!( + err.to_string().to_lowercase().contains("toml") + || err.to_string().to_lowercase().contains("expected"), + "expected toml-parse error message, got: {err}" + ); + } + + #[test] + fn try_load_from_str_rejects_failed_validation() { + // `[stores.config]` requires a non-empty `ids` list; an empty list + // trips `validator` and surfaces as InvalidData. + let err = ManifestLoader::try_load_from_str( + r#" +[app] +name = "demo" + +[stores.config] +ids = [] +"#, + ) + .err() + .expect("expected err"); + assert_eq!(err.kind(), io::ErrorKind::InvalidData); + } + #[test] fn environment_resolves_for_adapters() { let loader = ManifestLoader::load_from_str(SAMPLE); @@ -837,7 +949,7 @@ env = "APP_TOKEN" #[test] fn manifest_from_path_handles_relative_parent() { - let cwd = std::env::current_dir().unwrap(); + let cwd = env::current_dir().unwrap(); let dir = tempdir_in(&cwd).unwrap(); let path = dir.path().join("edgezero.toml"); fs::write(&path, "").unwrap(); @@ -850,7 +962,7 @@ env = "APP_TOKEN" #[test] fn manifest_from_path_uses_cwd_for_empty_parent() { - let cwd = std::env::current_dir().unwrap(); + let cwd = env::current_dir().unwrap(); let file = NamedTempFile::new_in(&cwd).unwrap(); fs::write(file.path(), "").unwrap(); let file_name = file.path().file_name().unwrap(); @@ -862,8 +974,8 @@ env = "APP_TOKEN" #[test] fn manifest_from_path_uses_cwd_when_parent_is_none() { - let cwd = std::env::current_dir().unwrap(); - let file_name = format!("edgezero-test-manifest-{}.toml", std::process::id()); + let cwd = env::current_dir().unwrap(); + let file_name = format!("edgezero-test-manifest-{}.toml", process::id()); let path = cwd.join(&file_name); fs::write(&path, "").unwrap(); @@ -965,15 +1077,15 @@ path = "/head" methods = ["HEAD"] "#; let loader = ManifestLoader::load_from_str(manifest); - let m = loader.manifest(); - assert_eq!(m.triggers.http.len(), 7); - assert_eq!(m.triggers.http[0].methods(), vec!["GET"]); - assert_eq!(m.triggers.http[1].methods(), vec!["POST"]); - assert_eq!(m.triggers.http[2].methods(), vec!["PUT"]); - assert_eq!(m.triggers.http[3].methods(), vec!["DELETE"]); - assert_eq!(m.triggers.http[4].methods(), vec!["PATCH"]); - assert_eq!(m.triggers.http[5].methods(), vec!["OPTIONS"]); - assert_eq!(m.triggers.http[6].methods(), vec!["HEAD"]); + let mfest = loader.manifest(); + assert_eq!(mfest.triggers.http.len(), 7); + assert_eq!(mfest.triggers.http[0].methods(), vec!["GET"]); + assert_eq!(mfest.triggers.http[1].methods(), vec!["POST"]); + assert_eq!(mfest.triggers.http[2].methods(), vec!["PUT"]); + assert_eq!(mfest.triggers.http[3].methods(), vec!["DELETE"]); + assert_eq!(mfest.triggers.http[4].methods(), vec!["PATCH"]); + assert_eq!(mfest.triggers.http[5].methods(), vec!["OPTIONS"]); + assert_eq!(mfest.triggers.http[6].methods(), vec!["HEAD"]); } #[test] @@ -998,8 +1110,8 @@ path = "/test" methods = ["get", "Post", "PUT"] "#; let loader = ManifestLoader::load_from_str(manifest); - let m = loader.manifest(); - assert_eq!(m.triggers.http[0].methods(), vec!["GET", "POST", "PUT"]); + let mfest = loader.manifest(); + assert_eq!(mfest.triggers.http[0].methods(), vec!["GET", "POST", "PUT"]); } #[test] @@ -1009,8 +1121,8 @@ methods = ["get", "Post", "PUT"] path = "/test" "#; let loader = ManifestLoader::load_from_str(manifest); - let m = loader.manifest(); - assert_eq!(m.triggers.http[0].methods(), vec!["GET"]); + let mfest = loader.manifest(); + assert_eq!(mfest.triggers.http[0].methods(), vec!["GET"]); } // BodyMode parsing tests @@ -1022,8 +1134,8 @@ path = "/test" body-mode = "buffered" "#; let loader = ManifestLoader::load_from_str(manifest); - let m = loader.manifest(); - assert_eq!(m.triggers.http[0].body_mode, Some(BodyMode::Buffered)); + let mfest = loader.manifest(); + assert_eq!(mfest.triggers.http[0].body_mode, Some(BodyMode::Buffered)); } #[test] @@ -1034,8 +1146,8 @@ path = "/test" body-mode = "stream" "#; let loader = ManifestLoader::load_from_str(manifest); - let m = loader.manifest(); - assert_eq!(m.triggers.http[0].body_mode, Some(BodyMode::Stream)); + let mfest = loader.manifest(); + assert_eq!(mfest.triggers.http[0].body_mode, Some(BodyMode::Stream)); } #[test] @@ -1075,13 +1187,22 @@ level = "error" level = "off" "#; let loader = ManifestLoader::load_from_str(manifest); - let m = loader.manifest(); - assert_eq!(m.logging_for("adapter1").unwrap().level, LogLevel::Trace); - assert_eq!(m.logging_for("adapter2").unwrap().level, LogLevel::Debug); - assert_eq!(m.logging_for("adapter3").unwrap().level, LogLevel::Info); - assert_eq!(m.logging_for("adapter4").unwrap().level, LogLevel::Warn); - assert_eq!(m.logging_for("adapter5").unwrap().level, LogLevel::Error); - assert_eq!(m.logging_for("adapter6").unwrap().level, LogLevel::Off); + let mfest = loader.manifest(); + assert_eq!( + mfest.logging_for("adapter1").unwrap().level, + LogLevel::Trace + ); + assert_eq!( + mfest.logging_for("adapter2").unwrap().level, + LogLevel::Debug + ); + assert_eq!(mfest.logging_for("adapter3").unwrap().level, LogLevel::Info); + assert_eq!(mfest.logging_for("adapter4").unwrap().level, LogLevel::Warn); + assert_eq!( + mfest.logging_for("adapter5").unwrap().level, + LogLevel::Error + ); + assert_eq!(mfest.logging_for("adapter6").unwrap().level, LogLevel::Off); } #[test] @@ -1123,8 +1244,8 @@ level = "off" name = "test" "#; let loader = ManifestLoader::load_from_str(manifest); - let m = loader.manifest(); - let logging = m.logging_or_default("unknown"); + let mfest = loader.manifest(); + let logging = mfest.logging_or_default("unknown"); assert_eq!(logging.level, LogLevel::Info); assert!(logging.endpoint.is_none()); assert!(logging.echo_stdout.is_none()); @@ -1149,8 +1270,8 @@ endpoint = "https://logs.example.com" echo_stdout = true "#; let loader = ManifestLoader::load_from_str(manifest); - let m = loader.manifest(); - let logging = m.logging_for("axum").unwrap(); + let mfest = loader.manifest(); + let logging = mfest.logging_for("axum").unwrap(); assert_eq!(logging.level, LogLevel::Debug); assert_eq!( logging.endpoint.as_deref(), @@ -1167,8 +1288,8 @@ level = "error" endpoint = "https://fastly-logs.example.com" "#; let loader = ManifestLoader::load_from_str(manifest); - let m = loader.manifest(); - let logging = m.logging_for("fastly").unwrap(); + let mfest = loader.manifest(); + let logging = mfest.logging_for("fastly").unwrap(); assert_eq!(logging.level, LogLevel::Error); assert_eq!( logging.endpoint.as_deref(), @@ -1186,8 +1307,8 @@ env = "ACTUAL_ENV_KEY" value = "some-value" "#; let loader = ManifestLoader::load_from_str(manifest); - let m = loader.manifest(); - let env = m.environment_for("any-adapter"); + let mfest = loader.manifest(); + let env = mfest.environment_for("any-adapter"); assert_eq!(env.variables[0].name, "MY_VAR"); assert_eq!(env.variables[0].env, "ACTUAL_ENV_KEY"); assert_eq!(env.variables[0].value.as_deref(), Some("some-value")); @@ -1201,8 +1322,8 @@ name = "API_KEY" value = "secret" "#; let loader = ManifestLoader::load_from_str(manifest); - let m = loader.manifest(); - let env = m.environment_for("any-adapter"); + let mfest = loader.manifest(); + let env = mfest.environment_for("any-adapter"); assert_eq!(env.variables[0].name, "API_KEY"); assert_eq!(env.variables[0].env, "API_KEY"); } @@ -1225,17 +1346,17 @@ name = "VAR3" value = "v3" "#; let loader = ManifestLoader::load_from_str(manifest); - let m = loader.manifest(); + let mfest = loader.manifest(); - let fastly_env = m.environment_for("FASTLY"); + let fastly_env = mfest.environment_for("FASTLY"); assert_eq!(fastly_env.variables.len(), 2); // VAR1 and VAR3 - assert!(fastly_env.variables.iter().any(|v| v.name == "VAR1")); - assert!(fastly_env.variables.iter().any(|v| v.name == "VAR3")); + assert!(fastly_env.variables.iter().any(|var| var.name == "VAR1")); + assert!(fastly_env.variables.iter().any(|var| var.name == "VAR3")); - let cf_env = m.environment_for("Cloudflare"); + let cf_env = mfest.environment_for("Cloudflare"); assert_eq!(cf_env.variables.len(), 2); // VAR2 and VAR3 - assert!(cf_env.variables.iter().any(|v| v.name == "VAR2")); - assert!(cf_env.variables.iter().any(|v| v.name == "VAR3")); + assert!(cf_env.variables.iter().any(|var| var.name == "VAR2")); + assert!(cf_env.variables.iter().any(|var| var.name == "VAR3")); } #[test] @@ -1246,8 +1367,8 @@ name = "DB_PASSWORD" description = "Database password for production" "#; let loader = ManifestLoader::load_from_str(manifest); - let m = loader.manifest(); - let env = m.environment_for("any"); + let mfest = loader.manifest(); + let env = mfest.environment_for("any"); assert_eq!( env.secrets[0].description.as_deref(), Some("Database password for production") @@ -1264,8 +1385,8 @@ profile = "release" features = ["feature1", "feature2"] "#; let loader = ManifestLoader::load_from_str(manifest); - let m = loader.manifest(); - let adapter = m.adapters.get("fastly").unwrap(); + let mfest = loader.manifest(); + let adapter = &mfest.adapters["fastly"]; assert_eq!(adapter.build.target.as_deref(), Some("wasm32-wasip1")); assert_eq!(adapter.build.profile.as_deref(), Some("release")); assert_eq!(adapter.build.features, vec!["feature1", "feature2"]); @@ -1280,8 +1401,8 @@ serve = "fastly compute serve" deploy = "fastly compute deploy" "#; let loader = ManifestLoader::load_from_str(manifest); - let m = loader.manifest(); - let adapter = m.adapters.get("fastly").unwrap(); + let mfest = loader.manifest(); + let adapter = &mfest.adapters["fastly"]; assert_eq!( adapter.commands.build.as_deref(), Some("fastly compute build") @@ -1304,8 +1425,8 @@ crate = "crates/fastly-adapter" manifest = "fastly.toml" "#; let loader = ManifestLoader::load_from_str(manifest); - let m = loader.manifest(); - let adapter = m.adapters.get("fastly").unwrap(); + let mfest = loader.manifest(); + let adapter = &mfest.adapters["fastly"]; assert_eq!( adapter.adapter.crate_path.as_deref(), Some("crates/fastly-adapter") @@ -1318,11 +1439,11 @@ manifest = "fastly.toml" fn empty_manifest_has_defaults() { let manifest = ""; let loader = ManifestLoader::load_from_str(manifest); - let m = loader.manifest(); - assert!(m.app.name.is_none()); - assert!(m.app.entry.is_none()); - assert!(m.triggers.http.is_empty()); - assert!(m.adapters.is_empty()); + let mfest = loader.manifest(); + assert!(mfest.app.name.is_none()); + assert!(mfest.app.entry.is_none()); + assert!(mfest.triggers.http.is_empty()); + assert!(mfest.adapters.is_empty()); } #[test] @@ -1343,159 +1464,164 @@ manifest = "fastly.toml" assert_eq!(HttpMethod::Head.as_str(), "HEAD"); } - // Config store tests - #[test] - fn config_store_name_falls_back_to_default_constant() { - // [stores.config] present but no name and no adapter overrides: - // config_store_name() must return DEFAULT_CONFIG_STORE_NAME. - let toml = "[stores.config]\n"; - let m = ManifestLoader::load_from_str(toml); - let config = m.manifest().stores.config.as_ref().unwrap(); - assert_eq!( - config.config_store_name("fastly"), - DEFAULT_CONFIG_STORE_NAME - ); - assert_eq!( - config.config_store_name("cloudflare"), - DEFAULT_CONFIG_STORE_NAME - ); - assert_eq!(config.config_store_name("axum"), DEFAULT_CONFIG_STORE_NAME); - } - - #[test] - fn config_store_name_defaults_when_omitted() { - // No [stores.config] section at all: callers skip the config store entirely. - let manifest = ManifestLoader::load_from_str(""); - assert!(manifest.manifest().stores.config.is_none()); - } + // -- Portable store declarations --------------------------------------- #[test] - fn config_store_name_uses_global_name() { + fn store_declaration_round_trips() { let toml = r#" +[stores.kv] +ids = ["sessions", "cache"] +default = "sessions" + [stores.config] -name = "app_config" +ids = ["app_config"] + +[stores.secrets] +ids = ["default"] "#; - let m = ManifestLoader::load_from_str(toml); - let config = m.manifest().stores.config.as_ref().unwrap(); - assert_eq!(config.config_store_name("fastly"), "app_config"); - assert_eq!(config.config_store_name("cloudflare"), "app_config"); - assert_eq!(config.config_store_name("axum"), "app_config"); - } + let loader = ManifestLoader::load_from_str(toml); + let stores = &loader.manifest().stores; - #[test] - fn config_store_name_adapter_override() { - let toml = r#" -[stores.config] -name = "global_config" + let kv = stores.kv.as_ref().expect("kv declared"); + assert_eq!(kv.ids, ["sessions", "cache"]); + assert_eq!(kv.default_id(), "sessions"); -[stores.config.adapters.fastly] -name = "my-config-link" + let config = stores.config.as_ref().expect("config declared"); + assert_eq!(config.ids, ["app_config"]); + assert_eq!(config.default_id(), "app_config"); -[stores.config.adapters.cloudflare] -name = "APP_CONFIG_BINDING" -"#; - let m = ManifestLoader::load_from_str(toml); - let config = m.manifest().stores.config.as_ref().unwrap(); - assert_eq!(config.config_store_name("fastly"), "my-config-link"); - assert_eq!(config.config_store_name("cloudflare"), "APP_CONFIG_BINDING"); - assert_eq!(config.config_store_name("axum"), "global_config"); + let secrets = stores.secrets.as_ref().expect("secrets declared"); + assert_eq!(secrets.default_id(), "default"); } #[test] - fn config_store_name_case_insensitive() { - let toml = r#" -[stores.config.adapters.fastly] -name = "fastly-store" -"#; - let m = ManifestLoader::load_from_str(toml); - let config = m.manifest().stores.config.as_ref().unwrap(); - assert_eq!(config.config_store_name("FASTLY"), "fastly-store"); - assert_eq!(config.config_store_name("Fastly"), "fastly-store"); - assert_eq!(config.config_store_name("fastly"), "fastly-store"); + fn store_declaration_default_id_falls_back_to_first_id() { + let loader = ManifestLoader::load_from_str("[stores.kv]\nids = [\"only\"]\n"); + let kv = loader.manifest().stores.kv.as_ref().expect("kv declared"); + assert!(kv.default.is_none()); + assert_eq!(kv.default_id(), "only"); } #[test] - fn config_store_mixed_case_adapter_key_fails_validation() { - let src = r#" -[stores.config.adapters.Fastly] -name = "fastly-store" -"#; - let manifest: Manifest = toml::from_str(src).expect("should parse"); - let result = manifest.validate(); + fn store_declaration_empty_ids_fails_validation() { + let manifest: Manifest = toml::from_str("[stores.kv]\nids = []\n").expect("should parse"); assert!( - result.is_err(), - "mixed-case config store adapter key should fail validation" + manifest.validate().is_err(), + "empty `ids` list should fail validation" ); } #[test] - fn config_store_unknown_adapter_key_fails_validation() { - let src = r#" -[stores.config.adapters.clouflare] -name = "APP_CONFIG" -"#; - let manifest: Manifest = toml::from_str(src).expect("should parse"); - let result = manifest.validate(); + fn store_declaration_blank_id_fails_validation() { + for raw in [ + "[stores.kv]\nids = [\"\"]\n", + "[stores.kv]\nids = [\" \"]\n", + "[stores.kv]\nids = [\"good\", \"\\n\"]\ndefault = \"good\"\n", + ] { + let manifest: Manifest = toml::from_str(raw).expect("should parse"); + let err = manifest + .validate() + .expect_err("blank/whitespace/control id should fail validation"); + assert!( + err.to_string().contains("non-empty and printable"), + "error should mention printable rule, got: {err}" + ); + } + } + + #[test] + fn store_declaration_duplicate_id_fails_validation() { + let manifest: Manifest = toml::from_str( + "[stores.kv]\nids = [\"app_config\", \"app_config\"]\ndefault = \"app_config\"\n", + ) + .expect("should parse"); + let err = manifest + .validate() + .expect_err("duplicate ids should fail validation"); assert!( - result.is_err(), - "unknown config store adapter key should fail validation" + err.to_string().contains("duplicate id"), + "error should mention duplicate, got: {err}" ); } #[test] - fn config_store_spin_adapter_key_fails_validation() { - // Spin config values come from component variables; there is no - // runtime store-name concept, so a spin adapter override would be - // silently ignored. Validation rejects it to surface the mistake early. - let src = r#" -[stores.config.adapters.spin] -name = "SPIN_CONFIG" -"#; - let manifest: Manifest = toml::from_str(src).expect("should parse"); + fn store_declaration_requires_default_with_multiple_ids() { + let manifest: Manifest = + toml::from_str("[stores.kv]\nids = [\"a\", \"b\"]\n").expect("should parse"); + let err = manifest + .validate() + .expect_err("missing `default` with >1 id should fail validation"); assert!( - manifest.validate().is_err(), - "spin config store adapter key should fail validation" + err.to_string().contains("default"), + "error should mention `default`, got: {err}" ); } #[test] - fn config_store_defaults_accessible() { - let toml = r#" -[stores.config.defaults] -"feature.checkout" = "true" -"service.timeout_ms" = "1500" -"#; - let m = ManifestLoader::load_from_str(toml); - let config = m.manifest().stores.config.as_ref().unwrap(); - let defaults = config.config_store_defaults(); - assert_eq!( - defaults.get("feature.checkout").map(|s| s.as_str()), - Some("true") - ); - assert_eq!( - defaults.get("service.timeout_ms").map(|s| s.as_str()), - Some("1500") + fn store_declaration_default_must_be_a_declared_id() { + let manifest: Manifest = + toml::from_str("[stores.kv]\nids = [\"a\", \"b\"]\ndefault = \"c\"\n") + .expect("should parse"); + let err = manifest + .validate() + .expect_err("`default` outside `ids` should fail validation"); + assert!( + err.to_string().contains("declared `ids`"), + "error should explain the `default` constraint, got: {err}" ); } #[test] - fn empty_manifest_has_no_config_store() { - let m = ManifestLoader::load_from_str(""); - assert!(m.manifest().stores.config.is_none()); + fn legacy_store_schema_is_a_hard_load_error() { + for legacy in [ + "[stores.kv]\nname = \"MY_KV\"\n", + "[stores.config]\nids = [\"app_config\"]\n\n[stores.config.defaults]\nkey = \"value\"\n", + "[stores.kv]\nids = [\"sessions\"]\n\n[stores.kv.adapters.spin]\nname = \"label\"\n", + "[stores.secrets]\nids = [\"default\"]\nenabled = false\n", + ] { + let err = ManifestLoader::try_load_from_str(legacy) + .err() + .unwrap_or_else(|| panic!("legacy manifest must fail to load: {legacy}")); + assert_eq!(err.kind(), io::ErrorKind::InvalidData); + assert!( + err.to_string() + .contains("docs/guide/manifest-store-migration.md"), + "legacy-schema error must reference the migration guide, got: {err}" + ); + } } #[test] - fn config_store_empty_global_name_fails_validation() { - let src = r#" -[stores.config] -name = "" -"#; - let manifest: Manifest = toml::from_str(src).expect("should parse"); - let result = manifest.validate(); - assert!( - result.is_err(), - "empty global config store name should fail validation" - ); + fn legacy_adapter_subtables_are_a_hard_load_error() { + // Pre-rewrite manifests carried per-adapter store / runtime tuning + // under `[adapters..]`. The portable model moved all of + // that to `EDGEZERO__*` env vars; stale subtables left in a + // migrated manifest must surface as a hard load error rather than + // be silently ignored. + for legacy in [ + // legacy per-adapter KV-store override (old [stores.kv.adapters.spin] hoisted) + "[adapters.spin.stores.kv.default]\nname = \"EDGEZERO_KV\"\n", + "[adapters.fastly.stores.config]\nname = \"app_config\"\n", + "[adapters.cloudflare.stores.secrets.default]\nname = \"WORKER_SECRETS\"\n", + // legacy runtime-tuning subtable under [adapters.axum] + "[adapters.axum.runtime]\nthreads = 4\n", + ] { + let err = ManifestLoader::try_load_from_str(legacy) + .err() + .unwrap_or_else(|| panic!("legacy adapter subtable must fail to load: {legacy}")); + assert_eq!(err.kind(), io::ErrorKind::InvalidData); + assert!( + err.to_string() + .contains("docs/guide/manifest-store-migration.md"), + "legacy adapter-subtable error must reference the migration guide, got: {err}" + ); + } + } + + #[test] + fn empty_manifest_has_no_config_store() { + let mfest = ManifestLoader::load_from_str(""); + assert!(mfest.manifest().stores.config.is_none()); } // Multiple triggers test @@ -1525,123 +1651,8 @@ body-mode = "buffered" assert_eq!(trigger.body_mode, Some(BodyMode::Buffered)); } - // -- KV store config --------------------------------------------------- - - #[test] - fn kv_store_name_defaults_when_omitted() { - let toml_str = r#" -[app] -name = "test" -"#; - let loader = ManifestLoader::load_from_str(toml_str); - let manifest = loader.manifest(); - assert_eq!(manifest.kv_store_name("fastly"), "EDGEZERO_KV"); - assert_eq!(manifest.kv_store_name("cloudflare"), "EDGEZERO_KV"); - } - - #[test] - fn kv_store_name_uses_global_name() { - let toml_str = r#" -[app] -name = "test" - -[stores.kv] -name = "MY_KV" -"#; - let loader = ManifestLoader::load_from_str(toml_str); - let manifest = loader.manifest(); - assert_eq!(manifest.kv_store_name("fastly"), "MY_KV"); - assert_eq!(manifest.kv_store_name("cloudflare"), "MY_KV"); - } - - #[test] - fn kv_store_name_adapter_override() { - let toml_str = r#" -[app] -name = "test" - -[stores.kv] -name = "GLOBAL_KV" - -[stores.kv.adapters.cloudflare] -name = "CF_BINDING" -"#; - let loader = ManifestLoader::load_from_str(toml_str); - let manifest = loader.manifest(); - assert_eq!(manifest.kv_store_name("cloudflare"), "CF_BINDING"); - assert_eq!(manifest.kv_store_name("fastly"), "GLOBAL_KV"); - } - - #[test] - fn kv_store_name_case_insensitive() { - let toml_str = r#" -[app] -name = "test" - -[stores.kv] -name = "DEFAULT" - -[stores.kv.adapters.Fastly] -name = "FASTLY_STORE" -"#; - let loader = ManifestLoader::load_from_str(toml_str); - let manifest = loader.manifest(); - assert_eq!(manifest.kv_store_name("fastly"), "FASTLY_STORE"); - assert_eq!(manifest.kv_store_name("FASTLY"), "FASTLY_STORE"); - } - // -- Secret store config ----------------------------------------------- - #[test] - fn secret_store_name_defaults_to_constant_when_absent() { - let manifest = ManifestLoader::load_from_str("[app]\nname = \"x\"\n"); - assert_eq!( - manifest.manifest().secret_store_name("fastly"), - DEFAULT_SECRET_STORE_NAME - ); - } - - #[test] - fn secret_store_name_uses_global_name_when_declared() { - let manifest = ManifestLoader::load_from_str("[stores.secrets]\nname = \"MY_SECRETS\"\n"); - assert_eq!( - manifest.manifest().secret_store_name("fastly"), - "MY_SECRETS" - ); - assert_eq!( - manifest.manifest().secret_store_name("cloudflare"), - "MY_SECRETS" - ); - } - - #[test] - fn secret_store_name_uses_per_adapter_override() { - let manifest = ManifestLoader::load_from_str( - "[stores.secrets]\nname = \"MY_SECRETS\"\n\ - [stores.secrets.adapters.fastly]\nname = \"FASTLY_STORE\"\n", - ); - assert_eq!( - manifest.manifest().secret_store_name("fastly"), - "FASTLY_STORE" - ); - assert_eq!( - manifest.manifest().secret_store_name("cloudflare"), - "MY_SECRETS" - ); - } - - #[test] - fn secrets_required_is_false_when_absent() { - let manifest = ManifestLoader::load_from_str("[app]\nname = \"x\"\n"); - assert!(manifest.manifest().stores.secrets.is_none()); - } - - #[test] - fn secrets_required_is_true_when_declared() { - let manifest = ManifestLoader::load_from_str("[stores.secrets]\nname = \"MY_SECRETS\"\n"); - assert!(manifest.manifest().stores.secrets.is_some()); - } - #[test] fn secret_store_enabled_is_false_when_absent() { let manifest = ManifestLoader::load_from_str("[app]\nname = \"x\"\n"); @@ -1651,39 +1662,12 @@ name = "FASTLY_STORE" #[test] fn secret_store_enabled_is_true_when_declared() { - let manifest = ManifestLoader::load_from_str("[stores.secrets]\nname = \"MY_SECRETS\"\n"); + let manifest = ManifestLoader::load_from_str("[stores.secrets]\nids = [\"default\"]\n"); + assert!(manifest.manifest().stores.secrets.is_some()); assert!(manifest.manifest().secret_store_enabled("fastly")); assert!(manifest.manifest().secret_store_enabled("cloudflare")); } - #[test] - fn secret_store_enabled_can_be_disabled_per_adapter() { - let manifest = ManifestLoader::load_from_str( - "[stores.secrets]\nname = \"MY_SECRETS\"\n\ - [stores.secrets.adapters.cloudflare]\nenabled = false\n", - ); - assert!(manifest.manifest().secret_store_enabled("fastly")); - assert!(!manifest.manifest().secret_store_enabled("cloudflare")); - } - - #[test] - fn secret_store_enabled_can_be_enabled_only_for_specific_adapter() { - let manifest = ManifestLoader::load_from_str( - "[stores.secrets]\nenabled = false\n\ - [stores.secrets.adapters.fastly]\nenabled = true\nname = \"FASTLY_STORE\"\n", - ); - assert!(manifest.manifest().secret_store_enabled("fastly")); - assert!(!manifest.manifest().secret_store_enabled("cloudflare")); - assert_eq!( - manifest.manifest().secret_store_name("fastly"), - "FASTLY_STORE" - ); - assert_eq!( - manifest.manifest().secret_store_name("cloudflare"), - DEFAULT_SECRET_STORE_NAME - ); - } - // -- Adapter host/port config ------------------------------------------ #[test] @@ -1695,8 +1679,8 @@ host = "0.0.0.0" port = 3000 "#; let loader = ManifestLoader::load_from_str(manifest); - let m = loader.manifest(); - let adapter = m.adapters.get("axum").unwrap(); + let manifest_data = loader.manifest(); + let adapter = &manifest_data.adapters["axum"]; assert_eq!(adapter.adapter.host.as_deref(), Some("0.0.0.0")); assert_eq!(adapter.adapter.port, Some(3000)); } @@ -1708,9 +1692,54 @@ port = 3000 crate = "crates/axum-adapter" "#; let loader = ManifestLoader::load_from_str(manifest); - let m = loader.manifest(); - let adapter = m.adapters.get("axum").unwrap(); + let manifest_data = loader.manifest(); + let adapter = &manifest_data.adapters["axum"]; assert!(adapter.adapter.host.is_none()); assert!(adapter.adapter.port.is_none()); } + + #[test] + fn adapter_definition_accepts_spin_component_field() { + // `component` is the Spin component id used by `provision` + // and `config push` when `spin.toml` declares multiple + // `[component.*]`. Documented in docs/guide/adapters/spin.md and + // must round-trip through the manifest model now even though the + // runtime ignores it. + let manifest = r#" +[adapters.spin.adapter] +crate = "crates/my-app-adapter-spin" +manifest = "crates/my-app-adapter-spin/spin.toml" +component = "my-app" +"#; + let loader = ManifestLoader::load_from_str(manifest); + let manifest_data = loader.manifest(); + let adapter = &manifest_data.adapters["spin"]; + assert_eq!(adapter.adapter.component.as_deref(), Some("my-app")); + } + + #[test] + fn adapter_definition_rejects_unknown_field_with_migration_pointer() { + // Hard cutoff: the portable manifest enumerates the per-adapter + // tuning surface explicitly. Anything else (e.g. a stale + // pre-rewrite `runtime` knob, or a typo'd `compnent`) is a load + // error rather than a silent drop. + let manifest = r#" +[adapters.axum.adapter] +crate = "crates/axum-adapter" +runtime_threads = 4 +"#; + let err = ManifestLoader::try_load_from_str(manifest) + .err() + .expect("unknown adapter-definition field must fail to load"); + assert_eq!(err.kind(), io::ErrorKind::InvalidData); + let msg = err.to_string(); + assert!( + msg.contains("runtime_threads"), + "error should name the offending field, got: {msg}" + ); + assert!( + msg.contains("docs/guide/manifest-store-migration.md"), + "error should reference the migration guide, got: {msg}" + ); + } } diff --git a/crates/edgezero-core/src/middleware.rs b/crates/edgezero-core/src/middleware.rs index de8582d5..47fffeac 100644 --- a/crates/edgezero-core/src/middleware.rs +++ b/crates/edgezero-core/src/middleware.rs @@ -11,24 +11,57 @@ use crate::http::Response; pub type BoxMiddleware = Arc; +pub struct FnMiddleware +where + F: Send + Sync + 'static, +{ + func: F, +} + +impl FnMiddleware +where + F: Send + Sync + 'static, +{ + #[inline] + pub fn new(func: F) -> Self { + Self { func } + } +} + +#[async_trait(?Send)] +impl Middleware for FnMiddleware +where + F: Fn(RequestContext, Next<'_>) -> Fut + Send + Sync + 'static, + Fut: Future>, +{ + #[inline] + async fn handle(&self, ctx: RequestContext, next: Next<'_>) -> Result { + (self.func)(ctx, next).await + } +} + #[async_trait(?Send)] pub trait Middleware: Send + Sync + 'static { async fn handle(&self, ctx: RequestContext, next: Next<'_>) -> Result; } -pub struct Next<'a> { - middlewares: &'a [BoxMiddleware], - handler: &'a dyn DynHandler, +pub struct Next<'mw> { + handler: &'mw dyn DynHandler, + middlewares: &'mw [BoxMiddleware], } -impl<'a> Next<'a> { - pub fn new(middlewares: &'a [BoxMiddleware], handler: &'a dyn DynHandler) -> Self { +impl<'mw> Next<'mw> { + #[inline] + pub fn new(middlewares: &'mw [BoxMiddleware], handler: &'mw dyn DynHandler) -> Self { Self { - middlewares, handler, + middlewares, } } + /// # Errors + /// Returns whatever error the next middleware or the final handler produces. + #[inline] pub async fn run(self, ctx: RequestContext) -> Result { if let Some((head, tail)) = self.middlewares.split_first() { head.handle(ctx, Next::new(tail, self.handler)).await @@ -42,17 +75,18 @@ pub struct RequestLogger; #[async_trait(?Send)] impl Middleware for RequestLogger { + #[inline] async fn handle(&self, ctx: RequestContext, next: Next<'_>) -> Result { let method = ctx.request().method().clone(); - let path = ctx.request().uri().path().to_string(); + let path = ctx.request().uri().path().to_owned(); let start = Instant::now(); match next.run(ctx).await { Ok(response) => { let status = response.status(); - let elapsed = start.elapsed().as_secs_f64() * 1000.0; + let elapsed = start.elapsed().as_millis(); tracing::info!( - "request method={} path={} status={} elapsed_ms={:.2}", + "request method={} path={} status={} elapsed_ms={}", method, path, status.as_u16(), @@ -63,9 +97,9 @@ impl Middleware for RequestLogger { Err(err) => { let status = err.status(); let message = err.message(); - let elapsed = start.elapsed().as_secs_f64() * 1000.0; + let elapsed = start.elapsed().as_millis(); tracing::error!( - "request method={} path={} status={} error={} elapsed_ms={:.2}", + "request method={} path={} status={} error={} elapsed_ms={}", method, path, status.as_u16(), @@ -78,50 +112,25 @@ impl Middleware for RequestLogger { } } -pub struct FnMiddleware -where - F: Send + Sync + 'static, -{ - f: F, -} - -impl FnMiddleware -where - F: Send + Sync + 'static, -{ - pub fn new(f: F) -> Self { - Self { f } - } -} - -#[async_trait(?Send)] -impl Middleware for FnMiddleware +#[inline] +pub fn middleware_fn(func: F) -> FnMiddleware where F: Fn(RequestContext, Next<'_>) -> Fut + Send + Sync + 'static, Fut: Future>, { - async fn handle(&self, ctx: RequestContext, next: Next<'_>) -> Result { - (self.f)(ctx, next).await - } -} - -pub fn middleware_fn(f: F) -> FnMiddleware -where - F: Fn(RequestContext, Next<'_>) -> Fut + Send + Sync + 'static, - Fut: Future>, -{ - FnMiddleware::new(f) + FnMiddleware::new(func) } #[cfg(test)] mod tests { use super::*; use crate::body::Body; - use crate::handler::IntoHandler; + use crate::handler::IntoHandler as _; use crate::http::{request_builder, Method, Response, StatusCode}; use crate::params::PathParams; use crate::response::response_with_body; use futures::executor::block_on; + use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Arc, Mutex}; struct RecordingMiddleware { @@ -129,19 +138,16 @@ mod tests { name: &'static str, } + struct ShortCircuit; + #[async_trait(?Send)] impl Middleware for RecordingMiddleware { async fn handle(&self, ctx: RequestContext, next: Next<'_>) -> Result { - { - let mut entries = self.log.lock().unwrap(); - entries.push(self.name.to_string()); - } + self.log.lock().unwrap().push(self.name.to_owned()); next.run(ctx).await } } - struct ShortCircuit; - #[async_trait(?Send)] impl Middleware for ShortCircuit { async fn handle( @@ -149,7 +155,7 @@ mod tests { _ctx: RequestContext, _next: Next<'_>, ) -> Result { - Ok(response_with_body(StatusCode::UNAUTHORIZED, Body::empty())) + response_with_body(StatusCode::UNAUTHORIZED, Body::empty()) } } @@ -163,7 +169,17 @@ mod tests { } async fn ok_handler(_ctx: RequestContext) -> Result { - Ok(response_with_body(StatusCode::OK, Body::empty())) + response_with_body(StatusCode::OK, Body::empty()) + } + + #[test] + fn middleware_can_short_circuit() { + let handler = ok_handler.into_handler(); + + let middlewares: Vec = vec![Arc::new(ShortCircuit)]; + let response = block_on(Next::new(&middlewares, handler.as_ref()).run(empty_context())) + .expect("response"); + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); } #[test] @@ -180,31 +196,38 @@ mod tests { }; let handler = (|_ctx: RequestContext| async move { - Ok::(response_with_body(StatusCode::OK, Body::empty())) + response_with_body(StatusCode::OK, Body::empty()) }) .into_handler(); - let middlewares: Vec = vec![ - Arc::new(first) as BoxMiddleware, - Arc::new(second) as BoxMiddleware, - ]; + let middlewares: Vec = vec![Arc::new(first), Arc::new(second)]; let result = block_on(Next::new(&middlewares, handler.as_ref()).run(empty_context())) .expect("response"); assert_eq!(result.status(), StatusCode::OK); let calls = log.lock().unwrap().clone(); - assert_eq!(calls, vec!["first".to_string(), "second".to_string()]); + assert_eq!(calls, vec!["first".to_owned(), "second".to_owned()]); } #[test] - fn middleware_can_short_circuit() { - let handler = ok_handler.into_handler(); + fn middleware_fn_executes_closure() { + let called = Arc::new(AtomicBool::new(false)); + let outer_flag = Arc::clone(&called); + let middleware = middleware_fn(move |_ctx, _next| { + let inner_flag = Arc::clone(&outer_flag); + async move { + inner_flag.store(true, Ordering::SeqCst); + response_with_body(StatusCode::OK, Body::empty()) + } + }); - let middlewares: Vec = vec![Arc::new(ShortCircuit) as BoxMiddleware]; + let handler = ok_handler.into_handler(); + let middlewares: Vec = vec![Arc::new(middleware)]; let response = block_on(Next::new(&middlewares, handler.as_ref()).run(empty_context())) .expect("response"); - assert_eq!(response.status(), StatusCode::UNAUTHORIZED); + assert_eq!(response.status(), StatusCode::OK); + assert!(called.load(Ordering::SeqCst)); } #[test] @@ -234,24 +257,4 @@ mod tests { .expect_err("error"); assert_eq!(err.status(), StatusCode::BAD_REQUEST); } - - #[test] - fn middleware_fn_executes_closure() { - let called = Arc::new(Mutex::new(false)); - let flag = Arc::clone(&called); - let middleware = middleware_fn(move |_ctx, _next| { - let flag = Arc::clone(&flag); - async move { - *flag.lock().unwrap() = true; - Ok(response_with_body(StatusCode::OK, Body::empty())) - } - }); - - let handler = ok_handler.into_handler(); - let middlewares: Vec = vec![Arc::new(middleware) as BoxMiddleware]; - let response = block_on(Next::new(&middlewares, handler.as_ref()).run(empty_context())) - .expect("response"); - assert_eq!(response.status(), StatusCode::OK); - assert!(*called.lock().unwrap()); - } } diff --git a/crates/edgezero-core/src/params.rs b/crates/edgezero-core/src/params.rs index eb0b919a..67bd1776 100644 --- a/crates/edgezero-core/src/params.rs +++ b/crates/edgezero-core/src/params.rs @@ -9,14 +9,9 @@ pub struct PathParams { } impl PathParams { - pub fn new(inner: HashMap) -> Self { - Self { inner } - } - - pub fn get(&self, key: &str) -> Option<&str> { - self.inner.get(key).map(|s| s.as_str()) - } - + /// # Errors + /// Returns [`serde_json::Error`] if the path parameters cannot be deserialized into `T`. + #[inline] pub fn deserialize(&self) -> Result where T: DeserializeOwned, @@ -24,6 +19,17 @@ impl PathParams { let value = serde_json::to_value(&self.inner)?; serde_json::from_value(value) } + + #[inline] + pub fn get(&self, key: &str) -> Option<&str> { + self.inner.get(key).map(String::as_str) + } + + #[must_use] + #[inline] + pub fn new(inner: HashMap) -> Self { + Self { inner } + } } #[cfg(test)] @@ -31,24 +37,17 @@ mod tests { use super::*; use serde::Deserialize; - fn params(map: &[(&str, &str)]) -> PathParams { - let inner = map - .iter() - .map(|(k, v)| ((*k).to_string(), (*v).to_string())) - .collect(); - PathParams::new(inner) - } - #[derive(Debug, Deserialize, PartialEq)] struct StringParams { id: String, } - #[test] - fn get_returns_expected_value() { - let params = params(&[("id", "7")]); - assert_eq!(params.get("id"), Some("7")); - assert_eq!(params.get("missing"), None); + fn params(map: &[(&str, &str)]) -> PathParams { + let inner = map + .iter() + .map(|(key, value)| ((*key).to_owned(), (*value).to_owned())) + .collect(); + PathParams::new(inner) } #[test] @@ -60,14 +59,22 @@ mod tests { #[test] fn deserialize_propagates_errors() { - #[allow(dead_code)] + #[expect(dead_code, reason = "field exercised only via Deserialize")] #[derive(Debug, Deserialize)] struct NumericParams { id: u32, } let params = params(&[("id", "not-a-number")]); - let result: Result = params.deserialize(); - assert!(result.is_err()); + params + .deserialize::() + .expect_err("`id` is not a number"); + } + + #[test] + fn get_returns_expected_value() { + let params = params(&[("id", "7")]); + assert_eq!(params.get("id"), Some("7")); + assert_eq!(params.get("missing"), None); } } diff --git a/crates/edgezero-core/src/proxy.rs b/crates/edgezero-core/src/proxy.rs index ec288570..60e96e13 100644 --- a/crates/edgezero-core/src/proxy.rs +++ b/crates/edgezero-core/src/proxy.rs @@ -13,69 +13,112 @@ use crate::http::{ /// forwarded the request (e.g. "fastly", "cloudflare", "spin"). pub const PROXY_HEADER: &str = "x-edgezero-proxy"; -/// Outbound request description for a proxy operation. -pub struct ProxyRequest { - method: Method, - uri: Uri, - headers: HeaderMap, - body: Body, - extensions: Extensions, +#[async_trait(?Send)] +pub trait ProxyClient: Send + Sync { + async fn send(&self, request: ProxyRequest) -> Result; } -impl ProxyRequest { - pub fn new(method: Method, uri: Uri) -> Self { - Self { - method, - uri, - headers: HeaderMap::new(), - body: Body::empty(), - extensions: Extensions::new(), - } - } +#[derive(Clone)] +pub struct ProxyHandle { + client: Arc, +} - pub fn from_request(request: Request, uri: Uri) -> Self { - let (parts, body) = request.into_parts(); - Self { - method: parts.method, - uri, - headers: parts.headers, - body, - extensions: parts.extensions, - } +impl ProxyHandle { + #[must_use] + #[inline] + pub fn client(&self) -> Arc { + Arc::clone(&self.client) } - pub fn method(&self) -> &Method { - &self.method + /// # Errors + /// Returns [`EdgeError`] if the underlying [`ProxyClient`] fails or the + /// response cannot be assembled. + #[inline] + pub async fn forward(&self, request: ProxyRequest) -> Result { + let response = self.client.send(request).await?; + response.into_response() } - pub fn uri(&self) -> &Uri { - &self.uri + #[inline] + pub fn new(client: Arc) -> Self { + Self { client } } - pub fn headers(&self) -> &HeaderMap { - &self.headers + #[inline] + pub fn with_client(client: C) -> Self + where + C: ProxyClient + 'static, + { + Self { + client: Arc::new(client), + } } +} - pub fn headers_mut(&mut self) -> &mut HeaderMap { - &mut self.headers +/// Outbound request description for a proxy operation. +pub struct ProxyRequest { + body: Body, + extensions: Extensions, + headers: HeaderMap, + method: Method, + uri: Uri, +} + +impl fmt::Debug for ProxyRequest { + #[inline] + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("ProxyRequest") + .field("method", &self.method) + .field("uri", &self.uri) + .field("headers", &self.headers) + .finish_non_exhaustive() } +} +impl ProxyRequest { + #[inline] pub fn body(&self) -> &Body { &self.body } + #[inline] pub fn body_mut(&mut self) -> &mut Body { &mut self.body } + #[inline] pub fn extensions(&self) -> &Extensions { &self.extensions } + #[inline] pub fn extensions_mut(&mut self) -> &mut Extensions { &mut self.extensions } + #[inline] + pub fn from_request(request: Request, uri: Uri) -> Self { + let (parts, body) = request.into_parts(); + Self { + body, + extensions: parts.extensions, + headers: parts.headers, + method: parts.method, + uri, + } + } + + #[inline] + pub fn headers(&self) -> &HeaderMap { + &self.headers + } + + #[inline] + pub fn headers_mut(&mut self) -> &mut HeaderMap { + &mut self.headers + } + + #[inline] pub fn into_parts(self) -> (Method, Uri, HeaderMap, Body, Extensions) { ( self.method, @@ -85,121 +128,112 @@ impl ProxyRequest { self.extensions, ) } -} -impl fmt::Debug for ProxyRequest { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.debug_struct("ProxyRequest") - .field("method", &self.method) - .field("uri", &self.uri) - .field("headers", &self.headers) - .finish() + #[inline] + pub fn method(&self) -> &Method { + &self.method } -} - -pub struct ProxyResponse { - status: StatusCode, - headers: HeaderMap, - body: Body, - extensions: Extensions, -} -impl ProxyResponse { - pub fn new(status: StatusCode, body: Body) -> Self { + #[inline] + pub fn new(method: Method, uri: Uri) -> Self { Self { - status, - headers: HeaderMap::new(), - body, + body: Body::empty(), extensions: Extensions::new(), + headers: HeaderMap::new(), + method, + uri, } } - pub fn status(&self) -> StatusCode { - self.status + #[inline] + pub fn uri(&self) -> &Uri { + &self.uri } +} - pub fn headers_mut(&mut self) -> &mut HeaderMap { - &mut self.headers - } +pub struct ProxyResponse { + body: Body, + extensions: Extensions, + headers: HeaderMap, + status: StatusCode, +} - pub fn headers(&self) -> &HeaderMap { - &self.headers +impl fmt::Debug for ProxyResponse { + #[inline] + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("ProxyResponse") + .field("status", &self.status) + .finish_non_exhaustive() } +} +impl ProxyResponse { + #[inline] pub fn body(&self) -> &Body { &self.body } + #[inline] pub fn body_mut(&mut self) -> &mut Body { &mut self.body } + #[inline] pub fn extensions(&self) -> &Extensions { &self.extensions } + #[inline] pub fn extensions_mut(&mut self) -> &mut Extensions { &mut self.extensions } - pub fn into_response(self) -> Response { - let mut builder = response_builder().status(self.status); - for (name, value) in self.headers.iter() { - builder = builder.header(name, value); - } - builder - .body(self.body) - .expect("proxy response builder should not fail") + #[inline] + pub fn headers(&self) -> &HeaderMap { + &self.headers } -} -impl fmt::Debug for ProxyResponse { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.debug_struct("ProxyResponse") - .field("status", &self.status) - .finish() + #[inline] + pub fn headers_mut(&mut self) -> &mut HeaderMap { + &mut self.headers } -} - -#[derive(Clone)] -pub struct ProxyHandle { - client: Arc, -} -impl ProxyHandle { - pub fn new(client: Arc) -> Self { - Self { client } + /// # Errors + /// Returns [`EdgeError::internal`] if the underlying `http::Response::builder()` + /// rejects a header — should be unreachable since we only store names/values + /// that were already validated, but propagation lets a faulty upstream stream + /// fail the request instead of crashing the worker. + #[inline] + pub fn into_response(self) -> Result { + let mut builder = response_builder().status(self.status); + for (name, value) in &self.headers { + builder = builder.header(name, value); + } + builder.body(self.body).map_err(EdgeError::internal) } - pub fn with_client(client: C) -> Self - where - C: ProxyClient + 'static, - { + #[inline] + pub fn new(status: StatusCode, body: Body) -> Self { Self { - client: Arc::new(client), + body, + extensions: Extensions::new(), + headers: HeaderMap::new(), + status, } } - pub fn client(&self) -> Arc { - Arc::clone(&self.client) - } - - pub async fn forward(&self, request: ProxyRequest) -> Result { - let response = self.client.send(request).await?; - Ok(response.into_response()) + #[inline] + pub fn status(&self) -> StatusCode { + self.status } } -#[async_trait(?Send)] -pub trait ProxyClient: Send + Sync { - async fn send(&self, request: ProxyRequest) -> Result; -} - pub struct ProxyService { client: C, } impl ProxyService { + #[inline] pub fn new(client: C) -> Self { Self { client } } @@ -209,9 +243,13 @@ impl ProxyService where C: ProxyClient, { + /// # Errors + /// Returns [`EdgeError`] if the underlying [`ProxyClient`] fails or the + /// response cannot be assembled. + #[inline] pub async fn forward(&self, request: ProxyRequest) -> Result { let response = self.client.send(request).await?; - Ok(response.into_response()) + response.into_response() } } @@ -219,33 +257,64 @@ where mod tests { use super::*; use crate::body::Body; + use crate::http::header::HeaderName; use crate::http::{request_builder, HeaderValue, Method, StatusCode, Uri}; use bytes::Bytes; use futures::executor::block_on; - use futures_util::{stream, StreamExt}; + use futures_util::{stream, StreamExt as _}; + + struct EchoBodyClient; + + struct EchoHeadersClient; + + struct EchoMethodClient; + + struct ErrorClient; + + struct StreamingClient; struct TestClient; #[async_trait(?Send)] - impl ProxyClient for TestClient { + impl ProxyClient for EchoBodyClient { async fn send(&self, request: ProxyRequest) -> Result { - let (method, uri, headers, _body, _) = request.into_parts(); - assert_eq!(method, Method::GET); - assert_eq!(uri, Uri::from_static("https://example.com")); - assert_eq!( - headers.get("x-demo"), - Some(&HeaderValue::from_static("true")) - ); + let (_, _, _, body, _) = request.into_parts(); + Ok(ProxyResponse::new(StatusCode::OK, body)) + } + } - let chunks = stream::iter(vec![ - Bytes::from_static(b"hello"), - Bytes::from_static(b" world"), - ]); - Ok(ProxyResponse::new(StatusCode::OK, Body::stream(chunks))) + #[async_trait(?Send)] + impl ProxyClient for EchoHeadersClient { + async fn send(&self, request: ProxyRequest) -> Result { + let mut resp = ProxyResponse::new(StatusCode::OK, Body::empty()); + // Echo back headers with x-echo- prefix + for (name, value) in request.headers() { + let echo_name = format!("x-echo-{}", name.as_str()); + if let Ok(header_name) = echo_name.parse::() { + resp.headers_mut().insert(header_name, value.clone()); + } + } + Ok(resp) } } - struct StreamingClient; + #[async_trait(?Send)] + impl ProxyClient for EchoMethodClient { + async fn send(&self, request: ProxyRequest) -> Result { + let method_str = request.method().as_str(); + Ok(ProxyResponse::new( + StatusCode::OK, + Body::from(method_str.to_owned()), + )) + } + } + + #[async_trait(?Send)] + impl ProxyClient for ErrorClient { + async fn send(&self, _request: ProxyRequest) -> Result { + Err(EdgeError::bad_request("connection failed")) + } + } #[async_trait(?Send)] impl ProxyClient for StreamingClient { @@ -259,20 +328,37 @@ mod tests { } } - #[test] - fn proxy_forward_roundtrips() { - let request = request_builder() - .method(Method::GET) - .uri("/local") - .header("x-demo", "true") - .body(Body::empty()) - .expect("request"); - - let target = Uri::from_static("https://example.com"); - let proxy_request = ProxyRequest::from_request(request, target); - let service = ProxyService::new(TestClient); - let response = block_on(service.forward(proxy_request)).expect("response"); - assert_eq!(response.status(), StatusCode::OK); + #[async_trait(?Send)] + impl ProxyClient for TestClient { + async fn send(&self, request: ProxyRequest) -> Result { + let (method, uri, headers, _body, _) = request.into_parts(); + assert_eq!(method, Method::GET); + assert_eq!(uri, Uri::from_static("https://example.com")); + assert_eq!( + headers.get("x-demo"), + Some(&HeaderValue::from_static("true")) + ); + + let chunks = stream::iter(vec![ + Bytes::from_static(b"hello"), + Bytes::from_static(b" world"), + ]); + Ok(ProxyResponse::new(StatusCode::OK, Body::stream(chunks))) + } + } + + fn collect_body(body: Body) -> Vec { + match body { + Body::Once(bytes) => bytes.to_vec(), + Body::Stream(mut stream) => block_on(async { + let mut data = Vec::new(); + while let Some(result) = stream.next().await { + let chunk = result.expect("chunk"); + data.extend_from_slice(&chunk); + } + data + }), + } } #[test] @@ -294,28 +380,154 @@ mod tests { assert_eq!(collected, b"stream-onestream-two"); } - fn collect_body(body: Body) -> Vec { - match body { - Body::Once(bytes) => bytes.to_vec(), - Body::Stream(mut stream) => block_on(async { - let mut data = Vec::new(); - while let Some(chunk) = stream.next().await { - let chunk = chunk.expect("chunk"); - data.extend_from_slice(&chunk); - } - data - }), + #[test] + fn proxy_forward_roundtrips() { + let request = request_builder() + .method(Method::GET) + .uri("/local") + .header("x-demo", "true") + .body(Body::empty()) + .expect("request"); + + let target = Uri::from_static("https://example.com"); + let proxy_request = ProxyRequest::from_request(request, target); + let service = ProxyService::new(TestClient); + let response = block_on(service.forward(proxy_request)).expect("response"); + assert_eq!(response.status(), StatusCode::OK); + } + + #[test] + fn proxy_forwards_request_body() { + let service = ProxyService::new(EchoBodyClient); + let request = request_builder() + .method(Method::POST) + .uri("/test") + .body(Body::from("request body content")) + .expect("request"); + + let proxy_req = + ProxyRequest::from_request(request, Uri::from_static("https://example.com")); + let response = block_on(service.forward(proxy_req)).expect("response"); + + let body_bytes = collect_body(response.into_body()); + assert_eq!(body_bytes, b"request body content"); + } + + #[test] + fn proxy_forwards_request_headers() { + let service = ProxyService::new(EchoHeadersClient); + let request = request_builder() + .method(Method::GET) + .uri("/test") + .header("x-custom-header", "custom-value") + .header("authorization", "Bearer token123") + .body(Body::empty()) + .expect("request"); + + let proxy_req = + ProxyRequest::from_request(request, Uri::from_static("https://example.com")); + let response = block_on(service.forward(proxy_req)).expect("response"); + + assert_eq!( + response + .headers() + .get("x-echo-x-custom-header") + .and_then(|value| value.to_str().ok()), + Some("custom-value") + ); + assert_eq!( + response + .headers() + .get("x-echo-authorization") + .and_then(|value| value.to_str().ok()), + Some("Bearer token123") + ); + } + + #[test] + fn proxy_forwards_various_methods() { + let service = ProxyService::new(EchoMethodClient); + + for method in [ + Method::GET, + Method::POST, + Method::PUT, + Method::DELETE, + Method::PATCH, + Method::HEAD, + Method::OPTIONS, + ] { + let req = ProxyRequest::new(method.clone(), Uri::from_static("https://example.com")); + let response = block_on(service.forward(req)).expect("response"); + assert_eq!(response.status(), StatusCode::OK); } } - // ProxyRequest tests #[test] - fn proxy_request_new_creates_empty_request() { + fn proxy_handle_forward_returns_response() { + let handle = ProxyHandle::with_client(TestClient); + let request = request_builder() + .method(Method::GET) + .uri("/test") + .header("x-demo", "true") + .body(Body::empty()) + .expect("request"); + + let proxy_req = + ProxyRequest::from_request(request, Uri::from_static("https://example.com")); + let response = block_on(handle.forward(proxy_req)).expect("response"); + assert_eq!(response.status(), StatusCode::OK); + } + + #[test] + fn proxy_handle_new_wraps_client() { + let client = Arc::new(TestClient); + let handle = ProxyHandle::new(client); + assert!(Arc::strong_count(&handle.client()) >= 1); + } + + #[test] + fn proxy_handle_propagates_client_errors() { + let handle = ProxyHandle::with_client(ErrorClient); let req = ProxyRequest::new(Method::GET, Uri::from_static("https://example.com")); - assert_eq!(req.method(), &Method::GET); - assert_eq!(req.uri(), &Uri::from_static("https://example.com")); - assert!(req.headers().is_empty()); - assert!(matches!(req.body(), Body::Once(b) if b.is_empty())); + block_on(handle.forward(req)).expect_err("ErrorClient propagates an error"); + } + + #[test] + fn proxy_handle_with_client_creates_arc() { + let handle = ProxyHandle::with_client(TestClient); + assert!(Arc::strong_count(&handle.client()) >= 1); + } + + #[test] + fn proxy_request_body_mut_allows_modification() { + let mut req = ProxyRequest::new(Method::POST, Uri::from_static("https://example.com")); + *req.body_mut() = Body::from("new body content"); + assert!(matches!( + req.body(), + Body::Once(bytes) if bytes.as_ref() == b"new body content" + )); + } + + #[test] + fn proxy_request_debug_format() { + let mut req = ProxyRequest::new(Method::GET, Uri::from_static("https://example.com")); + req.headers_mut() + .insert("x-debug", HeaderValue::from_static("test")); + let debug = format!("{req:?}"); + assert!(debug.contains("ProxyRequest")); + assert!(debug.contains("GET")); + assert!(debug.contains("example.com")); + } + + #[test] + fn proxy_request_extensions_mut_allows_modification() { + let mut req = ProxyRequest::new(Method::GET, Uri::from_static("https://example.com")); + req.extensions_mut().insert("custom-data".to_owned()); + assert_eq!( + req.extensions().get::(), + Some(&"custom-data".to_owned()) + ); } #[test] @@ -336,7 +548,7 @@ mod tests { proxy_req .headers() .get("x-custom") - .and_then(|v| v.to_str().ok()), + .and_then(|value| value.to_str().ok()), Some("value") ); } @@ -349,26 +561,6 @@ mod tests { assert!(req.headers().get("authorization").is_some()); } - #[test] - fn proxy_request_body_mut_allows_modification() { - let mut req = ProxyRequest::new(Method::POST, Uri::from_static("https://example.com")); - *req.body_mut() = Body::from("new body content"); - assert!(matches!( - req.body(), - Body::Once(bytes) if bytes.as_ref() == b"new body content" - )); - } - - #[test] - fn proxy_request_extensions_mut_allows_modification() { - let mut req = ProxyRequest::new(Method::GET, Uri::from_static("https://example.com")); - req.extensions_mut().insert("custom-data".to_string()); - assert_eq!( - req.extensions().get::(), - Some(&"custom-data".to_string()) - ); - } - #[test] fn proxy_request_into_parts_destructures() { let mut req = ProxyRequest::new( @@ -384,56 +576,51 @@ mod tests { assert_eq!(uri, Uri::from_static("https://example.com/resource")); assert!(headers.get("x-test").is_some()); assert!(matches!( - body, - Body::Once(ref bytes) if bytes.as_ref() == b"body" + &body, + Body::Once(bytes) if bytes.as_ref() == b"body" )); } #[test] - fn proxy_request_debug_format() { - let mut req = ProxyRequest::new(Method::GET, Uri::from_static("https://example.com")); - req.headers_mut() - .insert("x-debug", HeaderValue::from_static("test")); - let debug = format!("{:?}", req); - assert!(debug.contains("ProxyRequest")); - assert!(debug.contains("GET")); - assert!(debug.contains("example.com")); + fn proxy_request_new_creates_empty_request() { + let req = ProxyRequest::new(Method::GET, Uri::from_static("https://example.com")); + assert_eq!(req.method(), &Method::GET); + assert_eq!(req.uri(), &Uri::from_static("https://example.com")); + assert!(req.headers().is_empty()); + assert!(matches!(req.body(), Body::Once(bytes) if bytes.is_empty())); } - // ProxyResponse tests #[test] - fn proxy_response_new_creates_response() { - let resp = ProxyResponse::new(StatusCode::OK, Body::from("response body")); - assert_eq!(resp.status(), StatusCode::OK); + fn proxy_response_body_mut_allows_modification() { + let mut resp = ProxyResponse::new(StatusCode::OK, Body::empty()); + *resp.body_mut() = Body::from("updated body"); assert!(matches!( resp.body(), - Body::Once(bytes) if bytes.as_ref() == b"response body" + Body::Once(bytes) if bytes.as_ref() == b"updated body" )); } #[test] - fn proxy_response_headers_mut_allows_modification() { - let mut resp = ProxyResponse::new(StatusCode::OK, Body::empty()); - resp.headers_mut() - .insert("content-type", HeaderValue::from_static("application/json")); - assert!(resp.headers().get("content-type").is_some()); + fn proxy_response_debug_format() { + let resp = ProxyResponse::new(StatusCode::NOT_FOUND, Body::empty()); + let debug = format!("{resp:?}"); + assert!(debug.contains("ProxyResponse")); + assert!(debug.contains("404")); } #[test] - fn proxy_response_body_mut_allows_modification() { + fn proxy_response_extensions_mut_allows_modification() { let mut resp = ProxyResponse::new(StatusCode::OK, Body::empty()); - *resp.body_mut() = Body::from("updated body"); - assert!(matches!( - resp.body(), - Body::Once(bytes) if bytes.as_ref() == b"updated body" - )); + resp.extensions_mut().insert(42_i32); + assert_eq!(resp.extensions().get::(), Some(&42_i32)); } #[test] - fn proxy_response_extensions_mut_allows_modification() { + fn proxy_response_headers_mut_allows_modification() { let mut resp = ProxyResponse::new(StatusCode::OK, Body::empty()); - resp.extensions_mut().insert(42i32); - assert_eq!(resp.extensions().get::(), Some(&42)); + resp.headers_mut() + .insert("content-type", HeaderValue::from_static("application/json")); + assert!(resp.headers().get("content-type").is_some()); } #[test] @@ -442,57 +629,19 @@ mod tests { resp.headers_mut() .insert("x-custom", HeaderValue::from_static("header")); - let http_resp = resp.into_response(); + let http_resp = resp.into_response().expect("response"); assert_eq!(http_resp.status(), StatusCode::CREATED); assert!(http_resp.headers().get("x-custom").is_some()); } #[test] - fn proxy_response_debug_format() { - let resp = ProxyResponse::new(StatusCode::NOT_FOUND, Body::empty()); - let debug = format!("{:?}", resp); - assert!(debug.contains("ProxyResponse")); - assert!(debug.contains("404")); - } - - // ProxyHandle tests - #[test] - fn proxy_handle_new_wraps_client() { - let client = Arc::new(TestClient); - let handle = ProxyHandle::new(client); - assert!(Arc::strong_count(&handle.client()) >= 1); - } - - #[test] - fn proxy_handle_with_client_creates_arc() { - let handle = ProxyHandle::with_client(TestClient); - assert!(Arc::strong_count(&handle.client()) >= 1); - } - - #[test] - fn proxy_handle_forward_returns_response() { - let handle = ProxyHandle::with_client(TestClient); - let request = request_builder() - .method(Method::GET) - .uri("/test") - .header("x-demo", "true") - .body(Body::empty()) - .expect("request"); - - let proxy_req = - ProxyRequest::from_request(request, Uri::from_static("https://example.com")); - let response = block_on(handle.forward(proxy_req)).expect("response"); - assert_eq!(response.status(), StatusCode::OK); - } - - // ProxyClient error handling - struct ErrorClient; - - #[async_trait(?Send)] - impl ProxyClient for ErrorClient { - async fn send(&self, _request: ProxyRequest) -> Result { - Err(EdgeError::bad_request("connection failed")) - } + fn proxy_response_new_creates_response() { + let resp = ProxyResponse::new(StatusCode::OK, Body::from("response body")); + assert_eq!(resp.status(), StatusCode::OK); + assert!(matches!( + resp.body(), + Body::Once(bytes) if bytes.as_ref() == b"response body" + )); } #[test] @@ -504,122 +653,4 @@ mod tests { let err = result.unwrap_err(); assert_eq!(err.status(), StatusCode::BAD_REQUEST); } - - #[test] - fn proxy_handle_propagates_client_errors() { - let handle = ProxyHandle::with_client(ErrorClient); - let req = ProxyRequest::new(Method::GET, Uri::from_static("https://example.com")); - let result = block_on(handle.forward(req)); - assert!(result.is_err()); - } - - // Test various HTTP methods - struct EchoMethodClient; - - #[async_trait(?Send)] - impl ProxyClient for EchoMethodClient { - async fn send(&self, request: ProxyRequest) -> Result { - let method_str = request.method().as_str(); - Ok(ProxyResponse::new( - StatusCode::OK, - Body::from(method_str.to_string()), - )) - } - } - - #[test] - fn proxy_forwards_various_methods() { - let service = ProxyService::new(EchoMethodClient); - - for method in [ - Method::GET, - Method::POST, - Method::PUT, - Method::DELETE, - Method::PATCH, - Method::HEAD, - Method::OPTIONS, - ] { - let req = ProxyRequest::new(method.clone(), Uri::from_static("https://example.com")); - let response = block_on(service.forward(req)).expect("response"); - assert_eq!(response.status(), StatusCode::OK); - } - } - - // Test body forwarding - struct EchoBodyClient; - - #[async_trait(?Send)] - impl ProxyClient for EchoBodyClient { - async fn send(&self, request: ProxyRequest) -> Result { - let (_, _, _, body, _) = request.into_parts(); - Ok(ProxyResponse::new(StatusCode::OK, body)) - } - } - - #[test] - fn proxy_forwards_request_body() { - let service = ProxyService::new(EchoBodyClient); - let request = request_builder() - .method(Method::POST) - .uri("/test") - .body(Body::from("request body content")) - .expect("request"); - - let proxy_req = - ProxyRequest::from_request(request, Uri::from_static("https://example.com")); - let response = block_on(service.forward(proxy_req)).expect("response"); - - let body_bytes = collect_body(response.into_body()); - assert_eq!(body_bytes, b"request body content"); - } - - // Test header forwarding - struct EchoHeadersClient; - - #[async_trait(?Send)] - impl ProxyClient for EchoHeadersClient { - async fn send(&self, request: ProxyRequest) -> Result { - let mut resp = ProxyResponse::new(StatusCode::OK, Body::empty()); - // Echo back headers with x-echo- prefix - for (name, value) in request.headers().iter() { - let echo_name = format!("x-echo-{}", name.as_str()); - if let Ok(header_name) = echo_name.parse::() { - resp.headers_mut().insert(header_name, value.clone()); - } - } - Ok(resp) - } - } - - #[test] - fn proxy_forwards_request_headers() { - let service = ProxyService::new(EchoHeadersClient); - let request = request_builder() - .method(Method::GET) - .uri("/test") - .header("x-custom-header", "custom-value") - .header("authorization", "Bearer token123") - .body(Body::empty()) - .expect("request"); - - let proxy_req = - ProxyRequest::from_request(request, Uri::from_static("https://example.com")); - let response = block_on(service.forward(proxy_req)).expect("response"); - - assert_eq!( - response - .headers() - .get("x-echo-x-custom-header") - .and_then(|v| v.to_str().ok()), - Some("custom-value") - ); - assert_eq!( - response - .headers() - .get("x-echo-authorization") - .and_then(|v| v.to_str().ok()), - Some("Bearer token123") - ); - } } diff --git a/crates/edgezero-core/src/responder.rs b/crates/edgezero-core/src/responder.rs index d75ecb03..745f4d59 100644 --- a/crates/edgezero-core/src/responder.rs +++ b/crates/edgezero-core/src/responder.rs @@ -3,6 +3,8 @@ use crate::http::Response; use crate::response::IntoResponse; pub trait Responder: Sized { + /// # Errors + /// Returns [`EdgeError`] if the value cannot be turned into a response (e.g., a `Result`'s `Err` variant). fn respond(self) -> Result; } @@ -10,8 +12,9 @@ impl Responder for T where T: IntoResponse, { + #[inline] fn respond(self) -> Result { - Ok(self.into_response()) + self.into_response() } } @@ -19,8 +22,9 @@ impl Responder for Result where T: IntoResponse, { + #[inline] fn respond(self) -> Result { - self.map(IntoResponse::into_response) + self.and_then(IntoResponse::into_response) } } @@ -34,7 +38,7 @@ mod tests { fn responder_for_into_response_types() { let response = "hello".respond().expect("response"); assert_eq!(response.status(), StatusCode::OK); - assert_eq!(response.body().as_bytes(), b"hello"); + assert_eq!(response.body().as_bytes().expect("buffered"), b"hello"); } #[test] diff --git a/crates/edgezero-core/src/response.rs b/crates/edgezero-core/src/response.rs index 1c1e94c5..807604ad 100644 --- a/crates/edgezero-core/src/response.rs +++ b/crates/edgezero-core/src/response.rs @@ -1,34 +1,48 @@ use crate::body::Body; +use crate::error::EdgeError; use crate::http::{ header::{CONTENT_LENGTH, CONTENT_TYPE}, HeaderValue, Response, StatusCode, }; /// Convert common return types into `Response`. +/// +/// **Breaking change (pre-1.0):** this trait now returns `Result`. Callers must propagate response-building failures (typically +/// invalid headers) instead of letting them panic at the `http::Builder` +/// boundary. pub trait IntoResponse { - fn into_response(self) -> Response; + /// # Errors + /// Returns [`EdgeError::internal`] if the underlying HTTP response cannot + /// be assembled — propagated so the request can fail cleanly instead of + /// crashing the worker. + fn into_response(self) -> Result; } impl IntoResponse for Response { - fn into_response(self) -> Response { - self + #[inline] + fn into_response(self) -> Result { + Ok(self) } } impl IntoResponse for Body { - fn into_response(self) -> Response { + #[inline] + fn into_response(self) -> Result { response_with_body(StatusCode::OK, self) } } impl IntoResponse for &str { - fn into_response(self) -> Response { + #[inline] + fn into_response(self) -> Result { response_with_body(StatusCode::OK, Body::text(self)) } } impl IntoResponse for String { - fn into_response(self) -> Response { + #[inline] + fn into_response(self) -> Result { response_with_body(StatusCode::OK, Body::text(self)) } } @@ -36,6 +50,7 @@ impl IntoResponse for String { pub struct Text(T); impl Text { + #[inline] pub fn new(value: T) -> Self { Self(value) } @@ -45,13 +60,15 @@ impl IntoResponse for Text where T: Into, { - fn into_response(self) -> Response { + #[inline] + fn into_response(self) -> Result { response_with_body(StatusCode::OK, Body::text(self.0.into())) } } impl IntoResponse for () { - fn into_response(self) -> Response { + #[inline] + fn into_response(self) -> Result { response_with_body(StatusCode::NO_CONTENT, Body::empty()) } } @@ -60,20 +77,25 @@ impl IntoResponse for (StatusCode, T) where T: IntoResponse, { - fn into_response(self) -> Response { + #[inline] + fn into_response(self) -> Result { let (status, inner) = self; - let mut response = inner.into_response(); + let mut response = inner.into_response()?; *response.status_mut() = status; - response + Ok(response) } } -pub fn response_with_body(status: StatusCode, body: Body) -> Response { +/// # Errors +/// Returns [`EdgeError::internal`] if the underlying [`http::response::Builder`] +/// rejects the supplied status, headers, or body. +#[inline] +pub fn response_with_body(status: StatusCode, body: Body) -> Result { use crate::http::response_builder; let mut builder = response_builder().status(status); - if let Body::Once(ref bytes) = body { + if let Body::Once(bytes) = &body { if !bytes.is_empty() { builder = builder .header(CONTENT_LENGTH, bytes.len().to_string()) @@ -84,9 +106,7 @@ pub fn response_with_body(status: StatusCode, body: Body) -> Response { } } - builder - .body(body) - .expect("static response builder should not fail") + builder.body(body).map_err(EdgeError::internal) } #[cfg(test)] @@ -95,20 +115,20 @@ mod tests { #[test] fn response_with_body_sets_length_and_type() { - let response = response_with_body(StatusCode::OK, Body::from("hello")); + let response = response_with_body(StatusCode::OK, Body::from("hello")).expect("response"); assert_eq!(response.status(), StatusCode::OK); let headers = response.headers(); assert_eq!( headers .get(CONTENT_LENGTH) - .and_then(|v| v.to_str().ok()) + .and_then(|value| value.to_str().ok()) .unwrap(), "5" ); assert_eq!( headers .get(CONTENT_TYPE) - .and_then(|v| v.to_str().ok()) + .and_then(|value| value.to_str().ok()) .unwrap(), "text/plain; charset=utf-8" ); @@ -116,28 +136,30 @@ mod tests { #[test] fn empty_body_does_not_set_length() { - let response = response_with_body(StatusCode::OK, Body::empty()); + let response = response_with_body(StatusCode::OK, Body::empty()).expect("response"); assert!(response.headers().get(CONTENT_LENGTH).is_none()); } #[test] fn text_wrapper_builds_response() { - let response = Text::new("hello").into_response(); + let response = Text::new("hello").into_response().expect("response"); assert_eq!(response.status(), StatusCode::OK); - assert_eq!(response.body().as_bytes(), b"hello"); + assert_eq!(response.body().as_bytes().expect("buffered"), b"hello"); } #[test] fn unit_type_sets_no_content() { - let response = ().into_response(); + let response = ().into_response().expect("response"); assert_eq!(response.status(), StatusCode::NO_CONTENT); - assert!(response.body().as_bytes().is_empty()); + assert!(response.body().as_bytes().expect("buffered").is_empty()); } #[test] fn status_code_tuple_overrides_status() { - let response = (StatusCode::CREATED, "created").into_response(); + let response = (StatusCode::CREATED, "created") + .into_response() + .expect("response"); assert_eq!(response.status(), StatusCode::CREATED); - assert_eq!(response.body().as_bytes(), b"created"); + assert_eq!(response.body().as_bytes().expect("buffered"), b"created"); } } diff --git a/crates/edgezero-core/src/router.rs b/crates/edgezero-core/src/router.rs index e524fa86..18e242d7 100644 --- a/crates/edgezero-core/src/router.rs +++ b/crates/edgezero-core/src/router.rs @@ -1,5 +1,6 @@ use std::collections::{HashMap, HashSet}; use std::sync::Arc; +use std::task::{Context, Poll}; use matchit::Router as PathRouter; use serde::Serialize; @@ -15,10 +16,26 @@ use crate::http::{ }; use crate::middleware::{BoxMiddleware, Middleware, Next}; use crate::params::PathParams; -use crate::response::IntoResponse; +use crate::response::IntoResponse as _; pub const DEFAULT_ROUTE_LISTING_PATH: &str = "/__edgezero/routes"; +struct RouteEntry { + handler: BoxHandler, +} + +impl Clone for RouteEntry { + fn clone(&self) -> Self { + Self { + handler: Arc::clone(&self.handler), + } + } + + fn clone_from(&mut self, source: &Self) { + self.handler = Arc::clone(&source.handler); + } +} + #[derive(Clone, Debug)] pub struct RouteInfo { method: Method, @@ -26,17 +43,22 @@ pub struct RouteInfo { } impl RouteInfo { - pub fn new(method: Method, path: impl Into) -> Self { + #[must_use] + #[inline] + pub fn method(&self) -> &Method { + &self.method + } + + #[inline] + pub fn new>(method: Method, path: S) -> Self { Self { method, path: path.into(), } } - pub fn method(&self) -> &Method { - &self.method - } - + #[must_use] + #[inline] pub fn path(&self) -> &str { &self.path } @@ -48,119 +70,74 @@ struct RouteListingEntry { path: String, } -fn build_listing_response( - payload: &T, - builder: ResponseBuilder, -) -> Result { - let body = Body::json(payload).map_err(EdgeError::internal)?; - let response = builder - .status(StatusCode::OK) - .header(CONTENT_TYPE, HeaderValue::from_static("application/json")) - .body(body) - .map_err(EdgeError::internal)?; - Ok(response) +enum RouteMatch<'route> { + Found(&'route RouteEntry, PathParams), + MethodNotAllowed(Vec), + NotFound, } #[derive(Default)] pub struct RouterBuilder { - routes: HashMap>, middlewares: Vec, route_info: Vec, route_listing_path: Option, + routes: HashMap>, } impl RouterBuilder { - pub fn new() -> Self { - Self::default() - } - - pub fn enable_route_listing(self) -> Self { - self.enable_route_listing_at(DEFAULT_ROUTE_LISTING_PATH) - } - - pub fn enable_route_listing_at(mut self, path: S) -> Self - where - S: Into, - { - let path = path.into(); - assert!(!path.is_empty(), "route listing path cannot be empty"); - assert!( - path.starts_with('/'), - "route listing path must begin with '/'" - ); - self.route_listing_path = Some(path); - self - } - - pub fn route(mut self, path: &str, method: Method, handler: H) -> Self - where - H: IntoHandler, - { - self.add_route(path, method, handler); - self - } - - pub fn get(self, path: &str, handler: H) -> Self - where - H: IntoHandler, - { - self.route(path, Method::GET, handler) - } - - pub fn post(self, path: &str, handler: H) -> Self - where - H: IntoHandler, - { - self.route(path, Method::POST, handler) - } - - pub fn put(self, path: &str, handler: H) -> Self - where - H: IntoHandler, - { - self.route(path, Method::PUT, handler) - } - - pub fn delete(self, path: &str, handler: H) -> Self + #[expect( + clippy::panic, + reason = "duplicate route is a build-time programmer error, not a runtime condition" + )] + fn add_route(&mut self, path: &str, method: Method, handler: H) where H: IntoHandler, { - self.route(path, Method::DELETE, handler) - } - - pub fn middleware(mut self, middleware: M) -> Self - where - M: Middleware, - { - self.middlewares.push(Arc::new(middleware)); - self - } + let router = self.routes.entry(method.clone()).or_default(); - pub fn middleware_arc(mut self, middleware: BoxMiddleware) -> Self { - self.middlewares.push(middleware); - self - } + router + .insert( + path, + RouteEntry { + handler: handler.into_handler(), + }, + ) + .unwrap_or_else(|err| panic!("duplicate route definition for {path}: {err}")); + self.route_info + .push(RouteInfo::new(method, path.to_owned())); + } + + /// # Panics + /// Panics if a route is registered for both an explicit path and the route-listing path. + /// Both paths are programmer-supplied at build time; a duplicate is a routing-config bug + /// that should fail loudly before the binary ever serves traffic. + #[expect( + clippy::panic, + reason = "duplicate route is a build-time programmer error, not a runtime condition" + )] + #[must_use] + #[inline] pub fn build(mut self) -> RouterService { let listing_path = self.route_listing_path.clone(); let mut route_info = self.route_info.clone(); - if let Some(ref path) = listing_path { + if let Some(path) = &listing_path { route_info.push(RouteInfo::new(Method::GET, path.clone())); } - let route_index = Arc::new(route_info); + let route_index: Arc<[RouteInfo]> = Arc::from(route_info); if let Some(path) = listing_path { - let index = Arc::clone(&route_index); + let outer_index = Arc::clone(&route_index); let listing_handler = move |_ctx: RequestContext| { - let index = Arc::clone(&index); + let inner_index = Arc::clone(&outer_index); async move { - let payload: Vec = index + let payload: Vec = inner_index .iter() .map(|route| RouteListingEntry { - method: route.method().as_str().to_string(), - path: route.path().to_string(), + method: route.method().as_str().to_owned(), + path: route.path().to_owned(), }) .collect(); @@ -177,85 +154,119 @@ impl RouterBuilder { handler: listing_handler.into_handler(), }, ) - .unwrap_or_else(|err| panic!("duplicate route definition for {}: {}", path, err)); + .unwrap_or_else(|err| panic!("duplicate route definition for {path}: {err}")); } RouterService::new(self.routes, self.middlewares, route_index) } - fn add_route(&mut self, path: &str, method: Method, handler: H) + #[must_use] + #[inline] + pub fn delete(self, path: &str, handler: H) -> Self where H: IntoHandler, { - let router = self.routes.entry(method.clone()).or_default(); + self.route(path, Method::DELETE, handler) + } - router - .insert( - path, - RouteEntry { - handler: handler.into_handler(), - }, - ) - .unwrap_or_else(|err| panic!("duplicate route definition for {}: {}", path, err)); + #[must_use] + #[inline] + pub fn enable_route_listing(self) -> Self { + self.enable_route_listing_at(DEFAULT_ROUTE_LISTING_PATH) + } - self.route_info - .push(RouteInfo::new(method, path.to_string())); + /// # Panics + /// Panics if `path` is empty or does not begin with `/`. + #[must_use] + #[inline] + pub fn enable_route_listing_at(mut self, path: S) -> Self + where + S: Into, + { + let route_listing_path = path.into(); + assert!( + !route_listing_path.is_empty(), + "route listing path cannot be empty" + ); + assert!( + route_listing_path.starts_with('/'), + "route listing path must begin with '/'" + ); + self.route_listing_path = Some(route_listing_path); + self } -} -#[derive(Clone)] -pub struct RouterService { - inner: Arc, -} + #[must_use] + #[inline] + pub fn get(self, path: &str, handler: H) -> Self + where + H: IntoHandler, + { + self.route(path, Method::GET, handler) + } -impl RouterService { - fn new( - routes: HashMap>, - middlewares: Vec, - route_index: Arc>, - ) -> Self { - Self { - inner: Arc::new(RouterInner { - routes, - middlewares, - route_index, - }), - } + #[must_use] + #[inline] + pub fn middleware(mut self, middleware: M) -> Self + where + M: Middleware, + { + self.middlewares.push(Arc::new(middleware)); + self } - pub fn builder() -> RouterBuilder { - RouterBuilder::new() + #[must_use] + #[inline] + pub fn middleware_arc(mut self, middleware: BoxMiddleware) -> Self { + self.middlewares.push(middleware); + self } - pub fn routes(&self) -> Vec { - (*self.inner.route_index).clone() + #[must_use] + #[inline] + pub fn new() -> Self { + Self::default() } - pub async fn oneshot(&self, request: Request) -> Response { - let mut service = self.clone(); - match service.call(request).await { - Ok(response) => response, - Err(err) => err.into_response(), - } + #[must_use] + #[inline] + pub fn post(self, path: &str, handler: H) -> Self + where + H: IntoHandler, + { + self.route(path, Method::POST, handler) + } + + #[must_use] + #[inline] + pub fn put(self, path: &str, handler: H) -> Self + where + H: IntoHandler, + { + self.route(path, Method::PUT, handler) + } + + #[must_use] + #[inline] + pub fn route(mut self, path: &str, method: Method, handler: H) -> Self + where + H: IntoHandler, + { + self.add_route(path, method, handler); + self } } struct RouterInner { - routes: HashMap>, middlewares: Vec, - route_index: Arc>, -} - -enum RouteMatch<'a> { - Found(&'a RouteEntry, PathParams), - MethodNotAllowed(Vec), - NotFound, + route_index: Arc<[RouteInfo]>, + routes: HashMap>, } impl RouterInner { async fn dispatch(&self, request: Request) -> Result { let method = request.method().clone(); - let path = request.uri().path().to_string(); + let path = request.uri().path().to_owned(); match self.find_route(&method, &path) { RouteMatch::Found(entry, params) => { @@ -264,7 +275,7 @@ impl RouterInner { next.run(ctx).await } RouteMatch::MethodNotAllowed(mut allowed) => { - allowed.sort_by(|a, b| a.as_str().cmp(b.as_str())); + allowed.sort_by(|left, right| left.as_str().cmp(right.as_str())); Err(EdgeError::method_not_allowed(&method, &allowed)) } RouteMatch::NotFound => Err(EdgeError::not_found(path)), @@ -278,19 +289,19 @@ impl RouterInner { matched .params .iter() - .map(|(k, v)| (k.to_string(), v.to_string())) + .map(|(key, value)| (key.to_owned(), value.to_owned())) .collect(), ); return RouteMatch::Found(matched.value, params); } } - let mut allowed = HashSet::new(); - for (candidate_method, router) in &self.routes { - if router.at(path).is_ok() { - allowed.insert(candidate_method.clone()); - } - } + let allowed: HashSet = self + .routes + .iter() + .filter(|(_, router)| router.at(path).is_ok()) + .map(|(candidate_method, _)| candidate_method.clone()) + .collect(); if allowed.is_empty() { RouteMatch::NotFound @@ -300,34 +311,79 @@ impl RouterInner { } } +#[derive(Clone)] +pub struct RouterService { + inner: Arc, +} + impl Service for RouterService { - type Response = Response; type Error = EdgeError; type Future = HandlerFuture; + type Response = Response; - fn poll_ready( - &mut self, - _cx: &mut std::task::Context<'_>, - ) -> std::task::Poll> { - std::task::Poll::Ready(Ok(())) + #[inline] + fn call(&mut self, req: Request) -> Self::Future { + let inner = Arc::clone(&self.inner); + Box::pin(async move { inner.dispatch(req).await }) } - fn call(&mut self, request: Request) -> Self::Future { - let inner = Arc::clone(&self.inner); - Box::pin(async move { inner.dispatch(request).await }) + #[inline] + fn poll_ready(&mut self, _cx: &mut Context<'_>) -> Poll> { + Poll::Ready(Ok(())) } } -struct RouteEntry { - handler: BoxHandler, -} +impl RouterService { + #[must_use] + #[inline] + pub fn builder() -> RouterBuilder { + RouterBuilder::new() + } -impl Clone for RouteEntry { - fn clone(&self) -> Self { + fn new( + routes: HashMap>, + middlewares: Vec, + route_index: Arc<[RouteInfo]>, + ) -> Self { Self { - handler: Arc::clone(&self.handler), + inner: Arc::new(RouterInner { + middlewares, + route_index, + routes, + }), + } + } + + /// # Errors + /// Returns [`EdgeError`] if the dispatched handler errors AND the error + /// itself fails to render as a response. + #[inline] + pub async fn oneshot(&self, request: Request) -> Result { + let mut service = self.clone(); + match service.call(request).await { + Ok(response) => Ok(response), + Err(err) => err.into_response(), } } + + #[must_use] + #[inline] + pub fn routes(&self) -> Vec { + self.inner.route_index.to_vec() + } +} + +fn build_listing_response( + payload: &T, + builder: ResponseBuilder, +) -> Result { + let body = Body::json(payload).map_err(EdgeError::internal)?; + let response = builder + .status(StatusCode::OK) + .header(CONTENT_TYPE, HeaderValue::from_static("application/json")) + .body(body) + .map_err(EdgeError::internal)?; + Ok(response) } #[cfg(test)] @@ -341,137 +397,162 @@ mod tests { use crate::response::response_with_body; use futures::executor::block_on; use futures::task::noop_waker_ref; + use serde::ser::Error as _; use serde::{Deserialize, Serialize}; use serde_json::json; use std::sync::{Arc, Mutex}; use std::task::{Context, Poll}; async fn ok_handler(_ctx: RequestContext) -> Result { - Ok(response_with_body(StatusCode::OK, Body::empty())) + response_with_body(StatusCode::OK, Body::empty()) } #[test] - fn route_matches_path_params() { - #[derive(Deserialize)] - struct Params { - id: String, + fn builder_accepts_middleware_and_middleware_arc() { + struct RecordingMiddleware { + log: Arc>>, + name: &'static str, } - async fn handler(ctx: RequestContext) -> Result { - let params: Params = ctx.path()?; - Ok(format!("hello {}", params.id)) + #[async_trait::async_trait(?Send)] + impl Middleware for RecordingMiddleware { + async fn handle( + &self, + ctx: RequestContext, + next: Next<'_>, + ) -> Result { + self.log.lock().unwrap().push(self.name); + next.run(ctx).await + } } - let service = RouterService::builder().get("/hello/{id}", handler).build(); + let log = Arc::new(Mutex::new(Vec::new())); + let first = RecordingMiddleware { + log: Arc::clone(&log), + name: "first", + }; + let second = RecordingMiddleware { + log: Arc::clone(&log), + name: "second", + }; + + let service = RouterService::builder() + .middleware(first) + .middleware_arc({ + let arc: BoxMiddleware = Arc::new(second); + arc + }) + .get("/test", ok_handler) + .build(); let request = request_builder() .method(Method::GET) - .uri("/hello/world") + .uri("/test") .body(Body::empty()) .expect("request"); - let response = block_on(service.clone().call(request)).expect("response"); assert_eq!(response.status(), StatusCode::OK); - assert_eq!(response.body().as_bytes(), b"hello world"); + + let entries = log.lock().unwrap().clone(); + assert_eq!(entries, vec!["first", "second"]); } #[test] - fn route_listing_outputs_all_routes() { - async fn noop(_ctx: RequestContext) -> Result<(), EdgeError> { - Ok(()) - } - + fn builder_supports_put_and_delete_routes() { let service = RouterService::builder() - .enable_route_listing() - .get("/health", noop) - .post("/items", noop) + .put("/items", ok_handler) + .delete("/items", ok_handler) .build(); - let request = request_builder() - .method(Method::GET) - .uri(DEFAULT_ROUTE_LISTING_PATH) + let put_request = request_builder() + .method(Method::PUT) + .uri("/items") .body(Body::empty()) .expect("request"); + let put_response = block_on(service.clone().call(put_request)).expect("response"); + assert_eq!(put_response.status(), StatusCode::OK); - let response = block_on(service.clone().call(request)).expect("response"); - assert_eq!(response.status(), StatusCode::OK); + let delete_request = request_builder() + .method(Method::DELETE) + .uri("/items") + .body(Body::empty()) + .expect("request"); + let delete_response = block_on(service.clone().call(delete_request)).expect("response"); + assert_eq!(delete_response.status(), StatusCode::OK); + } - let body = response.body().as_bytes(); - let payload: Vec = serde_json::from_slice(body).expect("json payload"); + #[test] + #[should_panic(expected = "duplicate route definition")] + fn duplicate_route_definition_panics() { + let _service = RouterService::builder() + .get("/dup", ok_handler) + .get("/dup", ok_handler) + .build(); + } - assert!(payload.contains(&json!({ - "method": "GET", - "path": DEFAULT_ROUTE_LISTING_PATH - }))); - assert!(payload.contains(&json!({ - "method": "GET", - "path": "/health" - }))); - assert!(payload.contains(&json!({ - "method": "POST", - "path": "/items" - }))); + #[test] + fn handler_returns_bad_request_for_invalid_path_params() { + #[derive(Deserialize)] + struct Params { + id: String, + } - let routes = service.routes(); - assert!(routes - .iter() - .any(|route| route.path() == "/health" && *route.method() == Method::GET)); + async fn handler(ctx: RequestContext) -> Result { + let params: Params = ctx.path()?; + let id = params + .id + .parse::() + .map_err(|_e| EdgeError::bad_request("invalid id"))?; + Ok(format!("hello {id}")) + } - let health_request = request_builder() + let service = RouterService::builder().get("/items/{id}", handler).build(); + let ok_request = request_builder() .method(Method::GET) - .uri("/health") + .uri("/items/42") .body(Body::empty()) .expect("request"); - let health_response = block_on(service.clone().call(health_request)).expect("response"); - assert_eq!(health_response.status(), StatusCode::NO_CONTENT); + let ok_response = block_on(service.clone().call(ok_request)).expect("response"); + assert_eq!(ok_response.status(), StatusCode::OK); + assert_eq!( + ok_response.body().as_bytes().expect("buffered"), + b"hello 42" + ); - let items_request = request_builder() - .method(Method::POST) - .uri("/items") + let request = request_builder() + .method(Method::GET) + .uri("/items/abc") .body(Body::empty()) .expect("request"); - let items_response = block_on(service.clone().call(items_request)).expect("response"); - assert_eq!(items_response.status(), StatusCode::NO_CONTENT); - } - #[test] - fn route_listing_response_handles_json_failure() { - struct FailingSerialize; - - impl Serialize for FailingSerialize { - fn serialize(&self, _serializer: S) -> Result - where - S: serde::Serializer, - { - Err(serde::ser::Error::custom("boom")) - } - } - - let err = build_listing_response(&FailingSerialize, response_builder()) - .expect_err("expected error"); - assert_eq!(err.status(), StatusCode::INTERNAL_SERVER_ERROR); + let error = block_on(service.clone().call(request)).expect_err("error"); + assert_eq!(error.status(), StatusCode::BAD_REQUEST); } #[test] - fn route_listing_response_handles_builder_failure() { - #[derive(Serialize)] - struct Payload { - ok: bool, - } + fn oneshot_returns_error_response() { + let service = RouterService::builder().build(); + let request = request_builder() + .method(Method::GET) + .uri("/missing") + .body(Body::empty()) + .expect("request"); - let builder = response_builder().header("bad\nname", "value"); - let err = - build_listing_response(&Payload { ok: true }, builder).expect_err("expected error"); - assert_eq!(err.status(), StatusCode::INTERNAL_SERVER_ERROR); + let response = block_on(service.oneshot(request)).expect("response"); + assert_eq!(response.status(), StatusCode::NOT_FOUND); } #[test] - #[should_panic(expected = "duplicate route definition")] - fn route_listing_duplicate_path_panics() { - RouterService::builder() - .enable_route_listing() - .get(DEFAULT_ROUTE_LISTING_PATH, ok_handler) - .build(); + fn oneshot_returns_success_response() { + let service = RouterService::builder().get("/ok", ok_handler).build(); + let request = request_builder() + .method(Method::GET) + .uri("/ok") + .body(Body::empty()) + .expect("request"); + + let response = block_on(service.oneshot(request)).expect("response"); + assert_eq!(response.status(), StatusCode::OK); } #[test] @@ -519,193 +600,159 @@ mod tests { } #[test] - fn handler_returns_bad_request_for_invalid_path_params() { - #[derive(Deserialize)] - struct Params { - id: String, - } - - async fn handler(ctx: RequestContext) -> Result { - let params: Params = ctx.path()?; - let id = params - .id - .parse::() - .map_err(|_| EdgeError::bad_request("invalid id"))?; - Ok(format!("hello {}", id)) - } - - let service = RouterService::builder().get("/items/{id}", handler).build(); - let ok_request = request_builder() - .method(Method::GET) - .uri("/items/42") - .body(Body::empty()) - .expect("request"); - let ok_response = block_on(service.clone().call(ok_request)).expect("response"); - assert_eq!(ok_response.status(), StatusCode::OK); - assert_eq!(ok_response.body().as_bytes(), b"hello 42"); + fn route_entry_clone_copies_handler() { + let entry = RouteEntry { + handler: ok_handler.into_handler(), + }; + let cloned = entry.clone(); let request = request_builder() .method(Method::GET) - .uri("/items/abc") + .uri("/test") .body(Body::empty()) .expect("request"); - - let error = block_on(service.clone().call(request)).expect_err("error"); - assert_eq!(error.status(), StatusCode::BAD_REQUEST); + let ctx = RequestContext::new(request, PathParams::default()); + let response = block_on(cloned.handler.call(ctx)).expect("response"); + assert_eq!(response.status(), StatusCode::OK); } #[test] - fn streams_body_through_router() { - use bytes::Bytes; - use futures_util::stream; - use futures_util::StreamExt; - - async fn handler(_ctx: RequestContext) -> Result { - let chunks = stream::iter(vec![ - Bytes::from_static(b"chunk-one\n"), - Bytes::from_static(b"chunk-two\n"), - ]); + #[should_panic(expected = "duplicate route definition")] + fn route_listing_duplicate_path_panics() { + let _service = RouterService::builder() + .enable_route_listing() + .get(DEFAULT_ROUTE_LISTING_PATH, ok_handler) + .build(); + } - Ok((StatusCode::OK, Body::stream(chunks)).into_response()) + #[test] + fn route_listing_outputs_all_routes() { + async fn noop(_ctx: RequestContext) -> Result<(), EdgeError> { + Ok(()) } - let service = RouterService::builder().get("/stream", handler).build(); + let service = RouterService::builder() + .enable_route_listing() + .get("/health", noop) + .post("/items", noop) + .build(); let request = request_builder() .method(Method::GET) - .uri("/stream") + .uri(DEFAULT_ROUTE_LISTING_PATH) .body(Body::empty()) .expect("request"); let response = block_on(service.clone().call(request)).expect("response"); - let mut stream = response.into_body().into_stream().expect("stream body"); - let collected = block_on(async { - let mut acc = Vec::new(); - while let Some(chunk) = stream.next().await { - let chunk = chunk.expect("chunk"); - acc.extend_from_slice(&chunk); - } - acc - }); - assert_eq!(collected, b"chunk-one\nchunk-two\n"); - } + assert_eq!(response.status(), StatusCode::OK); - #[test] - #[should_panic(expected = "route listing path cannot be empty")] - fn route_listing_rejects_empty_path() { - let _ = RouterService::builder().enable_route_listing_at(""); - } + let body = response.body().as_bytes().expect("buffered"); + let payload: Vec = serde_json::from_slice(body).expect("json payload"); - #[test] - #[should_panic(expected = "route listing path must begin with '/'")] - fn route_listing_rejects_missing_slash() { - let _ = RouterService::builder().enable_route_listing_at("routes"); - } + assert!(payload.contains(&json!({ + "method": "GET", + "path": DEFAULT_ROUTE_LISTING_PATH + }))); + assert!(payload.contains(&json!({ + "method": "GET", + "path": "/health" + }))); + assert!(payload.contains(&json!({ + "method": "POST", + "path": "/items" + }))); - #[test] - fn builder_supports_put_and_delete_routes() { - let service = RouterService::builder() - .put("/items", ok_handler) - .delete("/items", ok_handler) - .build(); + let routes = service.routes(); + assert!(routes + .iter() + .any(|route| route.path() == "/health" && *route.method() == Method::GET)); - let put_request = request_builder() - .method(Method::PUT) - .uri("/items") + let health_request = request_builder() + .method(Method::GET) + .uri("/health") .body(Body::empty()) .expect("request"); - let put_response = block_on(service.clone().call(put_request)).expect("response"); - assert_eq!(put_response.status(), StatusCode::OK); + let health_response = block_on(service.clone().call(health_request)).expect("response"); + assert_eq!(health_response.status(), StatusCode::NO_CONTENT); - let delete_request = request_builder() - .method(Method::DELETE) + let items_request = request_builder() + .method(Method::POST) .uri("/items") .body(Body::empty()) .expect("request"); - let delete_response = block_on(service.clone().call(delete_request)).expect("response"); - assert_eq!(delete_response.status(), StatusCode::OK); + let items_response = block_on(service.clone().call(items_request)).expect("response"); + assert_eq!(items_response.status(), StatusCode::NO_CONTENT); } #[test] - #[should_panic(expected = "duplicate route definition")] - fn duplicate_route_definition_panics() { - RouterService::builder() - .get("/dup", ok_handler) - .get("/dup", ok_handler) - .build(); + #[should_panic(expected = "route listing path cannot be empty")] + fn route_listing_rejects_empty_path() { + let _builder = RouterService::builder().enable_route_listing_at(""); } #[test] - fn builder_accepts_middleware_and_middleware_arc() { - struct RecordingMiddleware { - log: Arc>>, - name: &'static str, - } + #[should_panic(expected = "route listing path must begin with '/'")] + fn route_listing_rejects_missing_slash() { + let _builder = RouterService::builder().enable_route_listing_at("routes"); + } - #[async_trait::async_trait(?Send)] - impl Middleware for RecordingMiddleware { - async fn handle( - &self, - ctx: RequestContext, - next: Next<'_>, - ) -> Result { - self.log.lock().unwrap().push(self.name); - next.run(ctx).await - } + #[test] + fn route_listing_response_handles_builder_failure() { + #[derive(Serialize)] + struct Payload { + ok: bool, } - let log = Arc::new(Mutex::new(Vec::new())); - let first = RecordingMiddleware { - log: Arc::clone(&log), - name: "first", - }; - let second = RecordingMiddleware { - log: Arc::clone(&log), - name: "second", - }; + let builder = response_builder().header("bad\nname", "value"); + let err = + build_listing_response(&Payload { ok: true }, builder).expect_err("expected error"); + assert_eq!(err.status(), StatusCode::INTERNAL_SERVER_ERROR); + } - let service = RouterService::builder() - .middleware(first) - .middleware_arc(Arc::new(second) as BoxMiddleware) - .get("/test", ok_handler) - .build(); + #[test] + fn route_listing_response_handles_json_failure() { + struct FailingSerialize; - let request = request_builder() - .method(Method::GET) - .uri("/test") - .body(Body::empty()) - .expect("request"); - let response = block_on(service.clone().call(request)).expect("response"); - assert_eq!(response.status(), StatusCode::OK); + impl Serialize for FailingSerialize { + fn serialize(&self, _serializer: S) -> Result + where + S: serde::Serializer, + { + Err(S::Error::custom("boom")) + } + } - let entries = log.lock().unwrap().clone(); - assert_eq!(entries, vec!["first", "second"]); + let err = build_listing_response(&FailingSerialize, response_builder()) + .expect_err("expected error"); + assert_eq!(err.status(), StatusCode::INTERNAL_SERVER_ERROR); } #[test] - fn oneshot_returns_success_response() { - let service = RouterService::builder().get("/ok", ok_handler).build(); - let request = request_builder() - .method(Method::GET) - .uri("/ok") - .body(Body::empty()) - .expect("request"); + fn route_matches_path_params() { + #[derive(Deserialize)] + struct Params { + id: String, + } - let response = block_on(service.oneshot(request)); - assert_eq!(response.status(), StatusCode::OK); - } + async fn handler(ctx: RequestContext) -> Result { + let params: Params = ctx.path()?; + Ok(format!("hello {}", params.id)) + } + + let service = RouterService::builder().get("/hello/{id}", handler).build(); - #[test] - fn oneshot_returns_error_response() { - let service = RouterService::builder().build(); let request = request_builder() .method(Method::GET) - .uri("/missing") + .uri("/hello/world") .body(Body::empty()) .expect("request"); - let response = block_on(service.oneshot(request)); - assert_eq!(response.status(), StatusCode::NOT_FOUND); + let response = block_on(service.clone().call(request)).expect("response"); + assert_eq!(response.status(), StatusCode::OK); + assert_eq!( + response.body().as_bytes().expect("buffered"), + b"hello world" + ); } #[test] @@ -718,19 +765,38 @@ mod tests { } #[test] - fn route_entry_clone_copies_handler() { - let entry = RouteEntry { - handler: ok_handler.into_handler(), - }; - let cloned = entry.clone(); + fn streams_body_through_router() { + use bytes::Bytes; + use futures_util::stream; + use futures_util::StreamExt as _; + + async fn handler(_ctx: RequestContext) -> Result { + let chunks = stream::iter(vec![ + Bytes::from_static(b"chunk-one\n"), + Bytes::from_static(b"chunk-two\n"), + ]); + + (StatusCode::OK, Body::stream(chunks)).into_response() + } + + let service = RouterService::builder().get("/stream", handler).build(); let request = request_builder() .method(Method::GET) - .uri("/test") + .uri("/stream") .body(Body::empty()) .expect("request"); - let ctx = RequestContext::new(request, PathParams::default()); - let response = block_on(cloned.handler.call(ctx)).expect("response"); - assert_eq!(response.status(), StatusCode::OK); + + let response = block_on(service.clone().call(request)).expect("response"); + let mut stream = response.into_body().into_stream().expect("stream body"); + let collected = block_on(async { + let mut acc = Vec::new(); + while let Some(result) = stream.next().await { + let chunk = result.expect("chunk"); + acc.extend_from_slice(&chunk); + } + acc + }); + assert_eq!(collected, b"chunk-one\nchunk-two\n"); } } diff --git a/crates/edgezero-core/src/secret_store.rs b/crates/edgezero-core/src/secret_store.rs index 30cc3655..a4b0c34d 100644 --- a/crates/edgezero-core/src/secret_store.rs +++ b/crates/edgezero-core/src/secret_store.rs @@ -18,6 +18,8 @@ //! it never writes or deletes them. Provisioning secrets is the //! responsibility of each platform's deployment toolchain. +#[cfg(any(test, feature = "test-utils"))] +use std::collections::HashMap; use std::fmt; use std::sync::Arc; @@ -26,13 +28,93 @@ use bytes::Bytes; use crate::error::EdgeError; +// --------------------------------------------------------------------------- +// Contract test macro +// --------------------------------------------------------------------------- + +/// Generate a suite of contract tests for any [`SecretStore`] implementation. +/// +/// The factory expression must produce a provider pre-populated with these +/// entries in the `"mystore"` store: +/// - `"contract_key"` → `Bytes::from("contract_value")` +/// - `"contract_key_2"` → `Bytes::from("another_value")` +/// - `"missing_key"` must NOT be present. +#[macro_export] +macro_rules! secret_store_contract_tests { + ($mod_name:ident, $factory:expr) => { + mod $mod_name { + use super::*; + use bytes::Bytes; + use $crate::secret_store::SecretStore; + + fn run(future: Fut) -> Fut::Output { + futures::executor::block_on(future) + } + + #[test] + fn contract_get_existing_returns_bytes() { + let provider = $factory; + run(async { + let result = provider.get_bytes("mystore", "contract_key").await.unwrap(); + assert_eq!(result, Some(Bytes::from("contract_value"))); + }); + } + + #[test] + fn contract_get_second_key_returns_bytes() { + let provider = $factory; + run(async { + let result = provider + .get_bytes("mystore", "contract_key_2") + .await + .unwrap(); + assert_eq!(result, Some(Bytes::from("another_value"))); + }); + } + + #[test] + fn contract_get_missing_returns_none() { + let provider = $factory; + run(async { + let result = provider.get_bytes("mystore", "missing_key").await.unwrap(); + assert!(result.is_none()); + }); + } + + #[test] + fn contract_wrong_store_returns_none() { + let provider = $factory; + run(async { + let result = provider + .get_bytes("other_store", "contract_key") + .await + .unwrap(); + assert!(result.is_none()); + }); + } + } + }; +} + +// --------------------------------------------------------------------------- +// Maximum name length +// --------------------------------------------------------------------------- + +/// Maximum length in bytes for any secret name or store name. +pub const MAX_NAME_LEN: usize = 512; + // --------------------------------------------------------------------------- // Error // --------------------------------------------------------------------------- /// Errors returned by secret store operations. #[derive(Debug, thiserror::Error)] +#[non_exhaustive] pub enum SecretError { + /// A general internal error. + #[error("secret store error: {0}")] + Internal(#[from] anyhow::Error), + /// The requested secret was not found. #[error("secret not found: {name}")] NotFound { name: String }, @@ -44,13 +126,10 @@ pub enum SecretError { /// A validation error (e.g., invalid secret name). #[error("validation error: {0}")] Validation(String), - - /// A general internal error. - #[error("secret store error: {0}")] - Internal(#[from] anyhow::Error), } impl From for EdgeError { + #[inline] fn from(err: SecretError) -> Self { match err { SecretError::NotFound { .. } => { @@ -67,51 +146,6 @@ impl From for EdgeError { } } -// --------------------------------------------------------------------------- -// Maximum name length -// --------------------------------------------------------------------------- - -/// Maximum length in bytes for any secret name or store name. -pub const MAX_NAME_LEN: usize = 512; - -// --------------------------------------------------------------------------- -// Multi-store provider trait -// --------------------------------------------------------------------------- - -/// Access secrets across multiple named stores. -/// -/// Platforms with a single flat namespace implement this differently: -/// - Env vars and in-memory test stores key values on `"{store_name}/{key}"`. -/// - Cloudflare and Spin ignore `store_name`; each platform exposes one flat -/// runtime namespace. Spin reads component variables, which must be declared -/// with lowercase variable names in `spin.toml`. -/// -/// Platforms with named stores, such as Fastly, open a store-specific handle -/// per `store_name`. -#[async_trait(?Send)] -pub trait SecretStore: Send + Sync { - /// Retrieve a secret from a named store. Returns `Ok(None)` if not found. - async fn get_bytes(&self, store_name: &str, key: &str) -> Result, SecretError>; -} - -// --------------------------------------------------------------------------- -// No-op provider (test-utils) -// --------------------------------------------------------------------------- - -/// A no-op [`SecretStore`] for tests that don't need secrets. -/// -/// All reads return `None`. -#[cfg(any(test, feature = "test-utils"))] -pub struct NoopSecretStore; - -#[cfg(any(test, feature = "test-utils"))] -#[async_trait(?Send)] -impl SecretStore for NoopSecretStore { - async fn get_bytes(&self, _store_name: &str, _key: &str) -> Result, SecretError> { - Ok(None) - } -} - // --------------------------------------------------------------------------- // In-memory provider (test-utils) // --------------------------------------------------------------------------- @@ -122,17 +156,23 @@ impl SecretStore for NoopSecretStore { /// across multiple named stores. #[cfg(any(test, feature = "test-utils"))] pub struct InMemorySecretStore { - secrets: std::collections::HashMap, + secrets: HashMap, } #[cfg(any(test, feature = "test-utils"))] impl InMemorySecretStore { /// Build with entries of the form `("{store_name}/{key}", value)`. - pub fn new(entries: impl IntoIterator, impl Into)>) -> Self { + #[inline] + pub fn new(entries: I) -> Self + where + I: IntoIterator, + K: Into, + V: Into, + { Self { secrets: entries .into_iter() - .map(|(k, v)| (k.into(), v.into())) + .map(|(key, value)| (key.into(), value.into())) .collect(), } } @@ -141,12 +181,32 @@ impl InMemorySecretStore { #[cfg(any(test, feature = "test-utils"))] #[async_trait(?Send)] impl SecretStore for InMemorySecretStore { + #[inline] async fn get_bytes(&self, store_name: &str, key: &str) -> Result, SecretError> { let compound = format!("{store_name}/{key}"); Ok(self.secrets.get(&compound).cloned()) } } +// --------------------------------------------------------------------------- +// No-op provider (test-utils) +// --------------------------------------------------------------------------- + +/// A no-op [`SecretStore`] for tests that don't need secrets. +/// +/// All reads return `None`. +#[cfg(any(test, feature = "test-utils"))] +pub struct NoopSecretStore; + +#[cfg(any(test, feature = "test-utils"))] +#[async_trait(?Send)] +impl SecretStore for NoopSecretStore { + #[inline] + async fn get_bytes(&self, _store_name: &str, _key: &str) -> Result, SecretError> { + Ok(None) + } +} + // --------------------------------------------------------------------------- // Provider handle // --------------------------------------------------------------------------- @@ -160,18 +220,18 @@ pub struct SecretHandle { } impl fmt::Debug for SecretHandle { + #[inline] fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { f.debug_struct("SecretHandle").finish_non_exhaustive() } } impl SecretHandle { - /// Create a new handle wrapping a multi-store provider. - pub fn new(provider: Arc) -> Self { - Self { provider } - } - /// Retrieve a secret from a named store. Returns `Ok(None)` if not found. + /// + /// # Errors + /// Returns [`SecretError::Validation`] for invalid `store_name`/`key`, [`SecretError::Unavailable`] if the backend is offline, or [`SecretError::Internal`] on backend failure. + #[inline] pub async fn get_bytes( &self, store_name: &str, @@ -182,7 +242,17 @@ impl SecretHandle { self.provider.get_bytes(store_name, key).await } + /// Create a new handle wrapping a multi-store provider. + #[inline] + pub fn new(provider: Arc) -> Self { + Self { provider } + } + /// Retrieve a secret as raw bytes. Returns `SecretError::NotFound` if absent. + /// + /// # Errors + /// Returns [`SecretError::NotFound`] if the secret is absent, plus the same errors as [`SecretHandle::get_bytes`]. + #[inline] pub async fn require_bytes(&self, store_name: &str, key: &str) -> Result { self.get_bytes(store_name, key) .await? @@ -192,21 +262,46 @@ impl SecretHandle { } /// Retrieve a secret as a UTF-8 string. Returns `SecretError::NotFound` if absent. + /// + /// # Errors + /// Returns [`SecretError::Internal`] if the secret bytes are not valid UTF-8, plus the same errors as [`SecretHandle::require_bytes`]. + #[inline] pub async fn require_str(&self, store_name: &str, key: &str) -> Result { let bytes = self.require_bytes(store_name, key).await?; - String::from_utf8(bytes.into()) - .map_err(|e| SecretError::Internal(anyhow::anyhow!("secret is not valid UTF-8: {e}"))) + String::from_utf8(bytes.into()).map_err(|err| { + SecretError::Internal(anyhow::anyhow!("secret is not valid UTF-8: {err}")) + }) } } +// --------------------------------------------------------------------------- +// Multi-store provider trait +// --------------------------------------------------------------------------- + +/// Access secrets across multiple named stores. +/// +/// Platforms with a single flat namespace implement this differently: +/// - Env vars and in-memory test stores key values on `"{store_name}/{key}"`. +/// - Cloudflare and Spin ignore `store_name`; each platform exposes one flat +/// runtime namespace. Spin reads component variables, which must be declared +/// with lowercase variable names in `spin.toml`. +/// +/// Platforms with named stores, such as Fastly, open a store-specific handle +/// per `store_name`. +#[async_trait(?Send)] +pub trait SecretStore: Send + Sync { + /// Retrieve a secret from a named store. Returns `Ok(None)` if not found. + async fn get_bytes(&self, store_name: &str, key: &str) -> Result, SecretError>; +} + // --------------------------------------------------------------------------- // Shared validation // --------------------------------------------------------------------------- -pub(crate) fn validate_name(name: &str) -> Result<(), SecretError> { +fn validate_name(name: &str) -> Result<(), SecretError> { if name.is_empty() { return Err(SecretError::Validation( - "secret name cannot be empty".to_string(), + "secret name cannot be empty".to_owned(), )); } if name.len() > MAX_NAME_LEN { @@ -216,232 +311,163 @@ pub(crate) fn validate_name(name: &str) -> Result<(), SecretError> { MAX_NAME_LEN ))); } - if name.chars().any(|c| c.is_control()) { + if name.chars().any(char::is_control) { return Err(SecretError::Validation( - "secret name contains invalid control characters".to_string(), + "secret name contains invalid control characters".to_owned(), )); } Ok(()) } -// --------------------------------------------------------------------------- -// Contract test macro -// --------------------------------------------------------------------------- - -/// Generate a suite of contract tests for any [`SecretStore`] implementation. -/// -/// The factory expression must produce a provider pre-populated with these -/// entries in the `"mystore"` store: -/// - `"contract_key"` → `Bytes::from("contract_value")` -/// - `"contract_key_2"` → `Bytes::from("another_value")` -/// - `"missing_key"` must NOT be present. -#[macro_export] -macro_rules! secret_store_contract_tests { - ($mod_name:ident, $factory:expr) => { - mod $mod_name { - use super::*; - use bytes::Bytes; - use $crate::secret_store::SecretStore; - - fn run(f: F) -> F::Output { - futures::executor::block_on(f) - } - - #[test] - fn contract_get_existing_returns_bytes() { - let provider = $factory; - run(async { - let result = provider.get_bytes("mystore", "contract_key").await.unwrap(); - assert_eq!(result, Some(Bytes::from("contract_value"))); - }); - } - - #[test] - fn contract_get_second_key_returns_bytes() { - let provider = $factory; - run(async { - let result = provider - .get_bytes("mystore", "contract_key_2") - .await - .unwrap(); - assert_eq!(result, Some(Bytes::from("another_value"))); - }); - } - - #[test] - fn contract_get_missing_returns_none() { - let provider = $factory; - run(async { - let result = provider.get_bytes("mystore", "missing_key").await.unwrap(); - assert!(result.is_none()); - }); - } - - #[test] - fn contract_wrong_store_returns_none() { - let provider = $factory; - run(async { - let result = provider - .get_bytes("other_store", "contract_key") - .await - .unwrap(); - assert!(result.is_none()); - }); - } - } - }; -} - // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- #[cfg(test)] mod tests { + secret_store_contract_tests!(in_memory_provider_contract, { + InMemorySecretStore::new([ + ("mystore/contract_key", Bytes::from("contract_value")), + ("mystore/contract_key_2", Bytes::from("another_value")), + ]) + }); + use super::*; use crate::http::StatusCode; use bytes::Bytes; use futures::executor::block_on; - // ----------------------------------------------------------------------- - // SecretStoreProvider tests - // ----------------------------------------------------------------------- + fn provider_handle_with(entries: &[(&str, &str)]) -> SecretHandle { + let provider = InMemorySecretStore::new( + entries + .iter() + .map(|(key, value)| ((*key).to_owned(), Bytes::from((*value).to_owned()))), + ); + SecretHandle::new(Arc::new(provider)) + } #[test] - fn provider_in_memory_returns_value_for_existing_key() { - let provider = InMemorySecretStore::new([("store/key", Bytes::from("hello"))]); + fn noop_provider_always_returns_none() { + let provider = NoopSecretStore; block_on(async { - let result = provider.get_bytes("store", "key").await.unwrap(); - assert_eq!(result, Some(Bytes::from("hello"))); + let result = provider.get_bytes("any_store", "any_key").await.unwrap(); + assert!(result.is_none()); }); } #[test] - fn provider_in_memory_returns_none_for_missing_key() { - let provider = InMemorySecretStore::new([("store/key", Bytes::from("hello"))]); + fn provider_handle_get_bytes_returns_none_for_missing() { + let handle = provider_handle_with(&[]); block_on(async { - let result = provider.get_bytes("store", "missing").await.unwrap(); + let result = handle.get_bytes("store", "missing").await.unwrap(); assert!(result.is_none()); }); } #[test] - fn provider_in_memory_returns_none_for_wrong_store() { - let provider = InMemorySecretStore::new([("store/key", Bytes::from("hello"))]); + fn provider_handle_get_bytes_returns_value() { + let handle = provider_handle_with(&[("signing-keys/current", "abc123")]); block_on(async { - let result = provider.get_bytes("other", "key").await.unwrap(); - assert!(result.is_none()); + let result = handle.get_bytes("signing-keys", "current").await.unwrap(); + assert_eq!(result, Some(Bytes::from("abc123"))); }); } #[test] - fn noop_provider_always_returns_none() { - let provider = NoopSecretStore; + fn provider_handle_require_bytes_errors_for_missing() { + let handle = provider_handle_with(&[]); block_on(async { - let result = provider.get_bytes("any_store", "any_key").await.unwrap(); - assert!(result.is_none()); + let err = handle.require_bytes("store", "missing").await.unwrap_err(); + assert!(matches!(err, SecretError::NotFound { .. })); }); } - // ----------------------------------------------------------------------- - // SecretProviderHandle tests - // ----------------------------------------------------------------------- - - fn provider_handle_with(entries: &[(&str, &str)]) -> SecretHandle { - let provider = InMemorySecretStore::new( - entries - .iter() - .map(|(k, v)| (k.to_string(), Bytes::from(v.to_string()))), - ); - SecretHandle::new(std::sync::Arc::new(provider)) - } - #[test] - fn provider_handle_get_bytes_returns_value() { - let h = provider_handle_with(&[("signing-keys/current", "abc123")]); + fn provider_handle_require_str_returns_value() { + let handle = provider_handle_with(&[("api-keys/prod", "secret_val")]); block_on(async { - let result = h.get_bytes("signing-keys", "current").await.unwrap(); - assert_eq!(result, Some(Bytes::from("abc123"))); + let val = handle.require_str("api-keys", "prod").await.unwrap(); + assert_eq!(val, "secret_val"); }); } #[test] - fn provider_handle_get_bytes_returns_none_for_missing() { - let h = provider_handle_with(&[]); + fn provider_handle_validates_control_chars_in_key() { + let handle = provider_handle_with(&[]); block_on(async { - let result = h.get_bytes("store", "missing").await.unwrap(); - assert!(result.is_none()); + let err = handle.get_bytes("store", "bad\x00key").await.unwrap_err(); + assert!(matches!(err, SecretError::Validation(_))); }); } #[test] - fn provider_handle_require_bytes_errors_for_missing() { - let h = provider_handle_with(&[]); + fn provider_handle_validates_control_chars_in_store_name() { + let handle = provider_handle_with(&[]); block_on(async { - let err = h.require_bytes("store", "missing").await.unwrap_err(); - assert!(matches!(err, SecretError::NotFound { .. })); + let err = handle.get_bytes("bad\x00store", "key").await.unwrap_err(); + assert!(matches!(err, SecretError::Validation(_))); }); } #[test] - fn provider_handle_require_str_returns_value() { - let h = provider_handle_with(&[("api-keys/prod", "secret_val")]); + fn provider_handle_validates_empty_key() { + let handle = provider_handle_with(&[]); block_on(async { - let val = h.require_str("api-keys", "prod").await.unwrap(); - assert_eq!(val, "secret_val"); + let err = handle.get_bytes("store", "").await.unwrap_err(); + assert!(matches!(err, SecretError::Validation(_))); }); } #[test] fn provider_handle_validates_empty_store_name() { - let h = provider_handle_with(&[]); + let handle = provider_handle_with(&[]); block_on(async { - let err = h.get_bytes("", "key").await.unwrap_err(); + let err = handle.get_bytes("", "key").await.unwrap_err(); assert!(matches!(err, SecretError::Validation(_))); }); } #[test] - fn provider_handle_validates_empty_key() { - let h = provider_handle_with(&[]); + fn provider_handle_validates_oversized_name() { + let handle = provider_handle_with(&[]); block_on(async { - let err = h.get_bytes("store", "").await.unwrap_err(); + let name = "x".repeat(MAX_NAME_LEN + 1); + let err = handle.get_bytes(&name, "key").await.unwrap_err(); assert!(matches!(err, SecretError::Validation(_))); }); } #[test] - fn provider_handle_validates_control_chars_in_store_name() { - let h = provider_handle_with(&[]); + fn provider_in_memory_returns_none_for_missing_key() { + let provider = InMemorySecretStore::new([("store/key", Bytes::from("hello"))]); block_on(async { - let err = h.get_bytes("bad\x00store", "key").await.unwrap_err(); - assert!(matches!(err, SecretError::Validation(_))); + let result = provider.get_bytes("store", "missing").await.unwrap(); + assert!(result.is_none()); }); } #[test] - fn provider_handle_validates_control_chars_in_key() { - let h = provider_handle_with(&[]); + fn provider_in_memory_returns_none_for_wrong_store() { + let provider = InMemorySecretStore::new([("store/key", Bytes::from("hello"))]); block_on(async { - let err = h.get_bytes("store", "bad\x00key").await.unwrap_err(); - assert!(matches!(err, SecretError::Validation(_))); + let result = provider.get_bytes("other", "key").await.unwrap(); + assert!(result.is_none()); }); } #[test] - fn provider_handle_validates_oversized_name() { - let h = provider_handle_with(&[]); + fn provider_in_memory_returns_value_for_existing_key() { + let provider = InMemorySecretStore::new([("store/key", Bytes::from("hello"))]); block_on(async { - let name = "x".repeat(MAX_NAME_LEN + 1); - let err = h.get_bytes(&name, "key").await.unwrap_err(); - assert!(matches!(err, SecretError::Validation(_))); + let result = provider.get_bytes("store", "key").await.unwrap(); + assert_eq!(result, Some(Bytes::from("hello"))); }); } #[test] fn secret_error_not_found_does_not_leak_secret_name() { let err: EdgeError = SecretError::NotFound { - name: "API_KEY".to_string(), + name: "API_KEY".to_owned(), } .into(); assert_eq!(err.status(), StatusCode::INTERNAL_SERVER_ERROR); @@ -450,15 +476,8 @@ mod tests { #[test] fn secret_error_validation_does_not_leak_details() { - let err: EdgeError = SecretError::Validation("bad\x00name".to_string()).into(); + let err: EdgeError = SecretError::Validation("bad\x00name".to_owned()).into(); assert_eq!(err.status(), StatusCode::INTERNAL_SERVER_ERROR); assert!(!err.message().contains("bad")); } - - secret_store_contract_tests!(in_memory_provider_contract, { - InMemorySecretStore::new([ - ("mystore/contract_key", Bytes::from("contract_value")), - ("mystore/contract_key_2", Bytes::from("another_value")), - ]) - }); } diff --git a/crates/edgezero-core/src/store_registry.rs b/crates/edgezero-core/src/store_registry.rs new file mode 100644 index 00000000..f2b8a6ed --- /dev/null +++ b/crates/edgezero-core/src/store_registry.rs @@ -0,0 +1,263 @@ +//! Per-request store registry — one entry per logical store id. +//! +//! Each adapter builds a [`StoreRegistry`] at request setup, keyed by the +//! logical ids declared in `[stores.]`. Handlers resolve a handle by id +//! (or via the `_default()` helper for the common single-store case). For +//! adapters that are *Single* for a given store kind (per the +//! capability matrix in the design doc) every id maps to the same +//! flat handle. +//! +//! Type aliases: +//! - [`KvRegistry`] = `StoreRegistry` +//! - [`ConfigRegistry`] = `StoreRegistry` +//! - [`SecretRegistry`] = `StoreRegistry` +//! +//! KV and config handles are already bound to a single backing store by +//! construction, so the `Bound*` aliases for those are just the existing +//! handle types. [`BoundSecretStore`] is a real wrapper because the +//! underlying [`SecretHandle::get_bytes`] takes a `store_name` argument — +//! the registry captures the per-id platform name (resolved from +//! `EDGEZERO__STORES__SECRETS____NAME`) so handlers can call +//! [`BoundSecretStore::get_bytes`] with just the key. + +use std::collections::BTreeMap; + +use bytes::Bytes; + +use crate::config_store::ConfigStoreHandle; +use crate::key_value_store::KvHandle; +use crate::secret_store::{SecretError, SecretHandle}; + +/// A per-bind KV handle, returned by [`KvRegistry::named`] / [`KvRegistry::default`]. +pub type BoundKvStore = KvHandle; + +/// A per-bind config handle, returned by +/// [`ConfigRegistry::named`] / [`ConfigRegistry::default`]. +pub type BoundConfigStore = ConfigStoreHandle; + +/// A per-bind secret handle: a [`SecretHandle`] pre-bound to a platform +/// store name. The registry resolves the name per logical id at request +/// setup from `EDGEZERO__STORES__SECRETS____NAME` (defaulting to the +/// logical id), so handler code reads +/// `secrets.named(id)?.require_str(key)` without re-passing the platform +/// name on every call. +#[derive(Clone, Debug)] +pub struct BoundSecretStore { + handle: SecretHandle, + store_name: String, +} + +impl BoundSecretStore { + /// Retrieve a secret by key against the bound platform store. + /// + /// # Errors + /// See [`SecretHandle::get_bytes`]. + #[inline] + pub async fn get_bytes(&self, key: &str) -> Result, SecretError> { + self.handle.get_bytes(&self.store_name, key).await + } + + /// Underlying [`SecretHandle`] (escape hatch for callers that need the + /// store-name argument explicitly). + #[inline] + #[must_use] + pub fn handle(&self) -> &SecretHandle { + &self.handle + } + + /// Bind `handle` to the platform store name `store_name`. + #[inline] + #[must_use] + pub fn new(handle: SecretHandle, store_name: String) -> Self { + Self { handle, store_name } + } + + /// Retrieve a secret as raw bytes, error on absent. + /// + /// # Errors + /// See [`SecretHandle::require_bytes`]. + #[inline] + pub async fn require_bytes(&self, key: &str) -> Result { + self.handle.require_bytes(&self.store_name, key).await + } + + /// Retrieve a secret as a UTF-8 string, error on absent. + /// + /// # Errors + /// See [`SecretHandle::require_str`]. + #[inline] + pub async fn require_str(&self, key: &str) -> Result { + self.handle.require_str(&self.store_name, key).await + } + + /// Platform store name this binding resolves to. + #[inline] + #[must_use] + pub fn store_name(&self) -> &str { + &self.store_name + } +} + +/// Registry of per-id store handles, with a declared default. +/// +/// Constructed by adapters at request setup from the baked store metadata +/// (`Hooks::stores()`) plus the `EDGEZERO__STORES__*` environment overlay. +#[derive(Clone, Debug)] +pub struct StoreRegistry { + by_id: BTreeMap, + default_id: String, +} + +impl StoreRegistry { + /// Return the default handle. + /// + /// Always `Some` for a registry constructed via [`Self::new`] — the + /// invariant is enforced at construction time. `Option` is kept on the + /// signature for API symmetry with [`Self::named`]. + #[must_use] + #[inline] + pub fn default(&self) -> Option { + self.by_id.get(&self.default_id).cloned() + } + + /// The resolved default id for this kind. + #[must_use] + #[inline] + pub fn default_id(&self) -> &str { + &self.default_id + } + + /// Try to build a registry from a pre-built id → handle map and the + /// declared default id, dropping it entirely when the default id is + /// not registered. Adapters that skip a failed-to-open backend per id + /// (logging a warning) call this instead of [`Self::new`] so the + /// registry isn't constructed with a default that has nowhere to + /// resolve to. Returning `None` in that case bubbles up as "no + /// registry wired", which surfaces as a clear 503 at the handler + /// rather than a silent `None` from [`Self::default`]. + #[must_use] + #[inline] + pub fn from_parts(by_id: BTreeMap, default_id: String) -> Option { + if by_id.is_empty() || !by_id.contains_key(&default_id) { + return None; + } + Some(Self { by_id, default_id }) + } + + /// Iterate over the registered logical ids. + #[inline] + pub fn ids(&self) -> impl Iterator { + self.by_id.keys().map(String::as_str) + } + + /// Look up the handle for `id`. Returns `None` if `id` was not registered. + #[must_use] + #[inline] + pub fn named(&self, id: &str) -> Option { + self.by_id.get(id).cloned() + } + + /// Create a registry from a pre-built id → handle map and the resolved + /// default id. + /// + /// # Panics + /// Panics (in both debug and release) if `default_id` is not a key in + /// `by_id`. Adapter builders that drop a failed-to-open id must ensure + /// they don't construct a registry whose declared default is missing — + /// either skip the whole registry, or fail the request loudly. + /// Surfacing this as a panic enforces the [`Self::default`] invariant + /// at construction time, matching the spec's intent that a declared + /// default always resolves. + #[must_use] + #[inline] + pub fn new(by_id: BTreeMap, default_id: String) -> Self { + assert!( + by_id.contains_key(&default_id), + "StoreRegistry default id `{default_id}` is not present among the registered ids: {ids:?}", + ids = by_id.keys().collect::>() + ); + Self { by_id, default_id } + } + + /// Build a one-id registry from a single handle, used when an + /// adapter has a single store and wants to normalise its + /// wiring to the registry path (so the extractor and + /// registry-aware accessors don't need a legacy-handle + /// fallback). `id` is the logical id the handle is registered + /// under AND the resolved default. + #[must_use] + #[inline] + pub fn single_id(id: String, handle: H) -> Self { + let mut by_id: BTreeMap = BTreeMap::new(); + by_id.insert(id.clone(), handle); + Self::new(by_id, id) + } +} + +/// Registry of per-id KV handles. +pub type KvRegistry = StoreRegistry; +/// Registry of per-id config handles. +pub type ConfigRegistry = StoreRegistry; +/// Registry of per-id secret handles. +pub type SecretRegistry = StoreRegistry; + +#[cfg(test)] +mod tests { + use super::*; + + fn build_registry(entries: &[(&str, &str)], default_id: &str) -> StoreRegistry { + let by_id: BTreeMap = entries + .iter() + .map(|(id, value)| ((*id).to_owned(), (*value).to_owned())) + .collect(); + StoreRegistry::new(by_id, default_id.to_owned()) + } + + #[test] + fn named_returns_handle_for_known_id() { + let registry = build_registry(&[("sessions", "a"), ("cache", "b")], "sessions"); + assert_eq!(registry.named("cache"), Some("b".to_owned())); + } + + #[test] + fn named_returns_none_for_unknown_id() { + let registry = build_registry(&[("sessions", "a")], "sessions"); + assert_eq!(registry.named("missing"), None); + } + + #[test] + fn default_returns_default_handle() { + let registry = build_registry(&[("sessions", "a"), ("cache", "b")], "cache"); + assert_eq!(registry.default(), Some("b".to_owned())); + } + + #[test] + fn default_id_returns_resolved_default() { + let registry = build_registry(&[("sessions", "a"), ("cache", "b")], "cache"); + assert_eq!(registry.default_id(), "cache"); + } + + #[test] + fn ids_yields_all_registered_ids_in_sorted_order() { + let registry = build_registry(&[("cache", "b"), ("sessions", "a")], "sessions"); + let ids: Vec<&str> = registry.ids().collect(); + assert_eq!(ids, vec!["cache", "sessions"]); + } + + #[test] + fn registry_is_cloneable() { + let r1 = build_registry(&[("a", "1")], "a"); + let r2 = r1.clone(); + assert_eq!(r1.named("a"), r2.named("a")); + } + + #[test] + #[should_panic(expected = "is not present among the registered ids")] + fn new_panics_when_default_is_not_among_registered_ids() { + // The invariant is enforced in both debug and release builds — a + // builder that drops a failed-to-open default id must not still + // call `new(by_id, missing_default)`. Catching this loudly avoids + // silent registries whose `default()` returns `None`. + let _registry: StoreRegistry = build_registry(&[("sessions", "a")], "cache"); + } +} diff --git a/crates/edgezero-macros/Cargo.toml b/crates/edgezero-macros/Cargo.toml index d050dc34..c108d1ca 100644 --- a/crates/edgezero-macros/Cargo.toml +++ b/crates/edgezero-macros/Cargo.toml @@ -5,6 +5,9 @@ version = { workspace = true } authors = { workspace = true } license = { workspace = true } +[lints] +workspace = true + [lib] proc-macro = true @@ -18,4 +21,10 @@ toml = { workspace = true } validator = { workspace = true, features = ["derive"] } [dev-dependencies] +# `edgezero-core` re-exports `AppConfig`; the derive tests assert +# against the trait/types over the re-export path the way downstream +# users will. Cargo allows dev-dep cycles (only the main dep edge +# matters for build ordering). +edgezero-core = { workspace = true } tempfile = { workspace = true } +trybuild = { workspace = true } diff --git a/crates/edgezero-macros/src/action.rs b/crates/edgezero-macros/src/action.rs index e905d221..92a03c63 100644 --- a/crates/edgezero-macros/src/action.rs +++ b/crates/edgezero-macros/src/action.rs @@ -1,13 +1,13 @@ use proc_macro::TokenStream; use quote::{format_ident, quote}; -use syn::{spanned::Spanned, Error, FnArg, ItemFn, Pat, PathArguments, Type}; +use syn::{spanned::Spanned as _, Error, FnArg, ItemFn, Pat, PathArguments, Type}; pub fn expand_action(attr: TokenStream, item: TokenStream) -> TokenStream { - expand_action_impl(attr.into(), item.into()).into() + expand_action_impl(&attr.into(), item.into()).into() } -pub(crate) fn expand_action_impl( - attr: proc_macro2::TokenStream, +fn expand_action_impl( + attr: &proc_macro2::TokenStream, item: proc_macro2::TokenStream, ) -> proc_macro2::TokenStream { if !attr.is_empty() { @@ -41,6 +41,12 @@ pub(crate) fn expand_action_impl( inner_fn.sig.ident = inner_ident.clone(); inner_fn.vis = syn::Visibility::Inherited; inner_fn.attrs.clear(); + // `#[action]` requires the user fn to be `async` so we can `.await` it + // from the generated outer fn. Some handler bodies have no awaits of + // their own — silence `clippy::unused_async` for those. + inner_fn + .attrs + .push(syn::parse_quote!(#[allow(clippy::unused_async)])); if let Err(err) = normalize_request_context_patterns(&mut inner_fn) { return err.to_compile_error(); @@ -53,7 +59,13 @@ pub(crate) fn expand_action_impl( for (index, arg) in func.sig.inputs.iter().enumerate() { let pat_type = match arg { FnArg::Typed(pat_type) => pat_type, - FnArg::Receiver(_) => unreachable!(), + FnArg::Receiver(receiver) => { + return syn::Error::new( + receiver.span(), + "#[action] functions cannot have a `self` receiver", + ) + .to_compile_error(); + } }; let ty = &pat_type.ty; @@ -128,17 +140,14 @@ fn extract_request_context_binding(pat: &Pat) -> syn::Result> { } fn path_is_request_context(path: &syn::Path) -> bool { - path.segments - .last() - .map(|segment| { - segment.ident == "RequestContext" && matches!(segment.arguments, PathArguments::None) - }) - .unwrap_or(false) + path.segments.last().is_some_and(|segment| { + segment.ident == "RequestContext" && matches!(segment.arguments, PathArguments::None) + }) } fn normalize_request_context_patterns(func: &mut ItemFn) -> Result<(), Error> { let mut error: Option = None; - for arg in func.sig.inputs.iter_mut() { + for arg in &mut func.sig.inputs { if let FnArg::Typed(pat_type) = arg { if is_request_context_type(&pat_type.ty) { if let Err(err) = normalize_request_context_pat(&mut pat_type.pat) { @@ -164,7 +173,7 @@ mod tests { use proc_macro2::TokenStream; use quote::quote; - fn render(tokens: TokenStream) -> String { + fn render(tokens: &TokenStream) -> String { tokens.to_string() } @@ -182,8 +191,8 @@ mod tests { .unwrap() } }; - let output = expand_action_impl(TokenStream::new(), input); - let rendered = render(output); + let output = expand_action_impl(&TokenStream::new(), input); + let rendered = render(&output); assert!(rendered.contains("__demo_inner")); assert!(rendered.contains("fn demo")); assert!(rendered.contains("responder :: Responder :: respond")); @@ -194,8 +203,8 @@ mod tests { let input = quote! { fn invalid() {} }; - let output = expand_action_impl(TokenStream::new(), input); - let rendered = render(output); + let output = expand_action_impl(&TokenStream::new(), input); + let rendered = render(&output); assert!(rendered.contains("must be async")); } @@ -206,8 +215,8 @@ mod tests { unimplemented!() } }; - let output = expand_action_impl(quote!(path = "/demo"), input); - let rendered = render(output); + let output = expand_action_impl("e!(path = "/demo"), input); + let rendered = render(&output); assert!(rendered.contains("does not accept arguments")); } @@ -218,8 +227,8 @@ mod tests { unimplemented!() } }; - let output = expand_action_impl(TokenStream::new(), input); - let rendered = render(output); + let output = expand_action_impl(&TokenStream::new(), input); + let rendered = render(&output); assert!(rendered.contains("does not support self receivers")); } @@ -239,8 +248,8 @@ mod tests { .unwrap()) } }; - let output = expand_action_impl(TokenStream::new(), input); - let rendered = render(output); + let output = expand_action_impl(&TokenStream::new(), input); + let rendered = render(&output); let collapsed = collapse_whitespace(&rendered); assert!(collapsed.contains("__with_ctx_inner(__ctx)")); } @@ -261,8 +270,8 @@ mod tests { .unwrap()) } }; - let output = expand_action_impl(TokenStream::new(), input); - let rendered = render(output); + let output = expand_action_impl(&TokenStream::new(), input); + let rendered = render(&output); let collapsed = collapse_whitespace(&rendered); assert!(collapsed.contains("__tuple_ctx_inner(__ctx)")); } @@ -275,8 +284,8 @@ mod tests { second: ::edgezero_core::context::RequestContext, ) {} }; - let output = expand_action_impl(TokenStream::new(), input); - let rendered = render(output); + let output = expand_action_impl(&TokenStream::new(), input); + let rendered = render(&output); assert!(rendered.contains("support at most one RequestContext argument")); } @@ -289,8 +298,8 @@ mod tests { unimplemented!() } }; - let output = expand_action_impl(TokenStream::new(), input); - let rendered = render(output); + let output = expand_action_impl(&TokenStream::new(), input); + let rendered = render(&output); assert!(rendered.contains("expects exactly one binding")); } @@ -307,8 +316,8 @@ mod tests { .unwrap() } }; - let output = expand_action_impl(TokenStream::new(), input); - let rendered = render(output); + let output = expand_action_impl(&TokenStream::new(), input); + let rendered = render(&output); let collapsed = collapse_whitespace(&rendered); assert!( collapsed.contains("FromRequest>::from_request"), diff --git a/crates/edgezero-macros/src/app.rs b/crates/edgezero-macros/src/app.rs index 7196d994..06618229 100644 --- a/crates/edgezero-macros/src/app.rs +++ b/crates/edgezero-macros/src/app.rs @@ -1,3 +1,4 @@ +use crate::manifest_definitions::{Manifest, StoreDeclaration}; use proc_macro::TokenStream; use proc_macro2::{Span, TokenStream as TokenStream2}; use quote::quote; @@ -6,29 +7,117 @@ use std::fs; use std::path::PathBuf; use syn::parse::{Parse, ParseStream}; use syn::{parse_macro_input, Ident, LitStr, Token}; -use validator::Validate; - -#[allow(dead_code)] -mod manifest_definitions { - include!(concat!( - env!("CARGO_MANIFEST_DIR"), - "/../edgezero-core/src/manifest.rs" - )); +use validator::Validate as _; + +struct AppArgs { + app_ident: Option, + path: LitStr, +} + +impl Parse for AppArgs { + fn parse(input: ParseStream) -> syn::Result { + let path: LitStr = input.parse()?; + let app_ident = if input.peek(Token![,]) { + input.parse::()?; + Some(input.parse::()?) + } else { + None + }; + if !input.is_empty() { + return Err(input.error("unexpected tokens after app! macro arguments")); + } + Ok(Self { app_ident, path }) + } +} + +/// Render a `StoreMetadata { default, ids }` literal for one `[stores.]` +/// declaration, or `None` when the declaration is absent. +fn store_metadata_tokens(maybe_declaration: Option<&StoreDeclaration>) -> TokenStream2 { + let Some(declaration) = maybe_declaration else { + return quote! { None }; + }; + let default_lit = LitStr::new(declaration.default_id(), Span::call_site()); + let id_lits = declaration + .ids + .iter() + .map(|id| LitStr::new(id, Span::call_site())); + quote! { + Some(edgezero_core::app::StoreMetadata { + default: #default_lit, + ids: &[#(#id_lits),*], + }) + } +} + +/// Codegen the `Hooks::stores()` impl from the portable `[stores.*]` schema. +fn build_stores_tokens(manifest: &Manifest) -> TokenStream2 { + let config = store_metadata_tokens(manifest.stores.config.as_ref()); + let kv = store_metadata_tokens(manifest.stores.kv.as_ref()); + let secrets = store_metadata_tokens(manifest.stores.secrets.as_ref()); + quote! { + fn stores() -> edgezero_core::app::StoresMetadata { + edgezero_core::app::StoresMetadata { + config: #config, + kv: #kv, + secrets: #secrets, + } + } + } +} + +fn build_middleware_tokens(manifest: &Manifest) -> Result, String> { + manifest + .app + .middleware + .iter() + .map(|middleware| { + let path = parse_handler_path(middleware)?; + Ok(quote! { + builder = builder.middleware(#path); + }) + }) + .collect() +} + +fn build_route_tokens(manifest: &Manifest) -> Result, String> { + let mut tokens = Vec::new(); + for trigger in &manifest.triggers.http { + let Some(handler) = trigger.handler.as_deref() else { + continue; + }; + let handler_path = parse_handler_path(handler)?; + let path_lit = LitStr::new(&trigger.path, Span::call_site()); + + for method in trigger.methods() { + tokens.push(route_for_method(method, &path_lit, &handler_path)); + } + } + Ok(tokens) } -use manifest_definitions::{Manifest, DEFAULT_CONFIG_STORE_NAME}; pub fn expand_app(input: TokenStream) -> TokenStream { let args = parse_macro_input!(input as AppArgs); let manifest_path = resolve_manifest_path(args.path.value()); - let manifest_source = fs::read_to_string(&manifest_path) - .unwrap_or_else(|err| panic!("failed to read {}: {err}", manifest_path.display())); + let manifest_source = match fs::read_to_string(&manifest_path) { + Ok(source) => source, + Err(err) => { + let msg = format!("failed to read {}: {err}", manifest_path.display()); + return quote!(compile_error!(#msg);).into(); + } + }; - let mut manifest: Manifest = toml::from_str(&manifest_source) - .unwrap_or_else(|err| panic!("failed to parse {}: {err}", manifest_path.display())); - manifest - .validate() - .unwrap_or_else(|err| panic!("failed to validate {}: {err}", manifest_path.display())); + let mut manifest: Manifest = match toml::from_str(&manifest_source) { + Ok(parsed) => parsed, + Err(err) => { + let msg = format!("failed to parse {}: {err}", manifest_path.display()); + return quote!(compile_error!(#msg);).into(); + } + }; + if let Err(err) = manifest.validate() { + let msg = format!("failed to validate {}: {err}", manifest_path.display()); + return quote!(compile_error!(#msg);).into(); + } manifest.finalize(); let app_ident = args @@ -38,12 +127,18 @@ pub fn expand_app(input: TokenStream) -> TokenStream { .app .name .clone() - .unwrap_or_else(|| "EdgeZero App".to_string()); + .unwrap_or_else(|| "EdgeZero App".to_owned()); let app_name_lit = LitStr::new(&app_name, Span::call_site()); - let middleware_tokens = build_middleware_tokens(&manifest); - let route_tokens = build_route_tokens(&manifest); - let config_store_tokens = build_config_store_tokens(&manifest); + let middleware_tokens = match build_middleware_tokens(&manifest) { + Ok(tokens) => tokens, + Err(msg) => return quote!(compile_error!(#msg);).into(), + }; + let route_tokens = match build_route_tokens(&manifest) { + Ok(tokens) => tokens, + Err(msg) => return quote!(compile_error!(#msg);).into(), + }; + let stores_tokens = build_stores_tokens(&manifest); let output = quote! { pub struct #app_ident; @@ -53,11 +148,19 @@ pub fn expand_app(input: TokenStream) -> TokenStream { build_router() } + fn configure(_app: &mut edgezero_core::app::App) {} + fn name() -> &'static str { #app_name_lit } - #config_store_tokens + #stores_tokens + + fn build_app() -> edgezero_core::app::App { + let mut app = edgezero_core::app::App::with_name(Self::routes(), Self::name()); + Self::configure(&mut app); + app + } } pub fn build_router() -> edgezero_core::router::RouterService { @@ -71,85 +174,15 @@ pub fn expand_app(input: TokenStream) -> TokenStream { output.into() } -fn resolve_manifest_path(relative: String) -> PathBuf { - let manifest_dir = env::var("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR env var"); - let mut path = PathBuf::from(manifest_dir); - path.push(relative); - path -} - -fn build_route_tokens(manifest: &Manifest) -> Vec { - manifest - .triggers - .http - .iter() - .filter_map(|trigger| { - let handler = trigger.handler.as_deref()?; - let handler_path = parse_handler_path(handler); - let path_lit = LitStr::new(&trigger.path, Span::call_site()); - - let methods = trigger.methods(); - - let mut tokens = Vec::new(); - for method in methods { - let route_tokens = route_for_method(method, &path_lit, &handler_path); - tokens.push(route_tokens); - } - Some(tokens) - }) - .flatten() - .collect() -} - -fn build_middleware_tokens(manifest: &Manifest) -> Vec { - manifest - .app - .middleware - .iter() - .map(|middleware| { - let path = parse_handler_path(middleware); - quote! { - builder = builder.middleware(#path); - } - }) - .collect() -} - -fn build_config_store_tokens(manifest: &Manifest) -> TokenStream2 { - let Some(config) = manifest.stores.config.as_ref() else { - return quote! {}; - }; - - let fallback_name = config.name.as_deref().unwrap_or(DEFAULT_CONFIG_STORE_NAME); - let fallback_name_lit = LitStr::new(fallback_name, Span::call_site()); - let override_entries: Vec<_> = config - .adapters - .iter() - .map(|(adapter, cfg)| { - let adapter_lit = LitStr::new(adapter, Span::call_site()); - let name_lit = LitStr::new(&cfg.name, Span::call_site()); - quote! { - edgezero_core::app::ConfigStoreAdapterMetadata::new(#adapter_lit, #name_lit), - } - }) - .collect(); - - quote! { - fn config_store() -> Option<&'static edgezero_core::app::ConfigStoreMetadata> { - static CONFIG_STORE: edgezero_core::app::ConfigStoreMetadata = - edgezero_core::app::ConfigStoreMetadata::new( - #fallback_name_lit, - &[ - #(#override_entries)* - ], - ); - Some(&CONFIG_STORE) - } - } -} - -fn parse_handler_path(handler: &str) -> syn::ExprPath { - let mut handler_str = handler.trim().to_string(); +/// Parses a handler reference like `crate::handlers::root` from `edgezero.toml` +/// into the `syn::ExprPath` that the generated router code references. +/// +/// Returns `Err(message)` when the manifest contains a syntactically-invalid +/// handler path. Callers propagate the message into a `compile_error!()` so +/// rustc surfaces it as a normal build failure with the file/line of the +/// `app!(...)` call site, instead of as a "proc-macro panicked". +fn parse_handler_path(handler: &str) -> Result { + let mut handler_str = handler.trim().to_owned(); if handler_str.starts_with("crate::") || handler_str.starts_with("self::") || handler_str.starts_with("super::") @@ -159,13 +192,36 @@ fn parse_handler_path(handler: &str) -> syn::ExprPath { let crate_name = env::var("CARGO_PKG_NAME") .map(|name| name.replace('-', "_")) .unwrap_or_default(); - if !crate_name.is_empty() && handler_str.starts_with(&(crate_name.clone() + "::")) { - handler_str = format!("crate::{}", &handler_str[crate_name.len() + 2..]); + if !crate_name.is_empty() && handler_str.starts_with(&format!("{crate_name}::")) { + handler_str = format!( + "crate::{}", + handler_str + .get(crate_name.len().saturating_add(2)..) + .unwrap_or_default(), + ); } } syn::parse_str::(&handler_str) - .unwrap_or_else(|err| panic!("invalid handler path `{}`: {err}", handler)) + .map_err(|err| format!("invalid handler path `{handler}`: {err}")) +} + +/// Resolves the manifest path passed to `app!(...)` against the +/// invoking crate's `CARGO_MANIFEST_DIR`. +/// +/// `CARGO_MANIFEST_DIR` is unconditionally set by Cargo whenever a +/// proc-macro runs against a normal crate, so the lookup cannot fail in +/// practice. Treating it as fallible would require every caller of +/// `app!(...)` to handle an outcome that has never been observed and +/// cannot be triggered without bypassing Cargo entirely. +#[expect( + clippy::expect_used, + reason = "CARGO_MANIFEST_DIR is a Cargo invariant during macro expansion; \ + there is no realistic failure mode to propagate" +)] +fn resolve_manifest_path(relative: String) -> PathBuf { + let manifest_dir = env::var("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR env var"); + PathBuf::from(manifest_dir).join(relative) } fn route_for_method(method: &str, path: &LitStr, handler: &syn::ExprPath) -> TokenStream2 { @@ -188,23 +244,28 @@ fn route_for_method(method: &str, path: &LitStr, handler: &syn::ExprPath) -> Tok } } -struct AppArgs { - path: LitStr, - app_ident: Option, -} +#[cfg(test)] +mod tests { + use super::parse_handler_path; -impl Parse for AppArgs { - fn parse(input: ParseStream) -> syn::Result { - let path: LitStr = input.parse()?; - let app_ident = if input.peek(Token![,]) { - input.parse::()?; - Some(input.parse::()?) - } else { - None - }; - if !input.is_empty() { - return Err(input.error("unexpected tokens after app! macro arguments")); - } - Ok(Self { path, app_ident }) + #[test] + fn parse_handler_path_accepts_absolute_crate_path() { + let parsed = + parse_handler_path("crate::handlers::root").expect("valid handler path should parse"); + let rendered = quote::quote!(#parsed).to_string(); + assert_eq!(rendered, "crate :: handlers :: root"); + } + + #[test] + fn parse_handler_path_rejects_invalid_syntax_with_message() { + let err = parse_handler_path("not a valid path!").expect_err("expected parse failure"); + assert!( + err.contains("invalid handler path"), + "error message should name the failure, got: {err}" + ); + assert!( + err.contains("not a valid path!"), + "error message should echo the offending input, got: {err}" + ); } } diff --git a/crates/edgezero-macros/src/app_config.rs b/crates/edgezero-macros/src/app_config.rs new file mode 100644 index 00000000..0b151089 --- /dev/null +++ b/crates/edgezero-macros/src/app_config.rs @@ -0,0 +1,272 @@ +//! `#[derive(AppConfig)]` derive. +//! +//! Scans the input struct for `#[secret]` / `#[secret(store_ref)]` +//! field annotations, enforces the compile-time constraints, and +//! emits `impl ::edgezero_core::app_config::AppConfigMeta` with the +//! `SECRET_FIELDS` array. + +use proc_macro::TokenStream; +use proc_macro2::TokenStream as TokenStream2; +use quote::quote; +use syn::punctuated::Punctuated; +use syn::{ + parse_macro_input, Attribute, Data, DeriveInput, Field, Fields, Ident, Meta, Path, Type, +}; + +/// Recognised `#[secret(...)]` annotation kinds. +enum SecretAnnotation { + /// Plain `#[secret]` — the field value is a key in the resolved + /// default secret store. + KeyInDefault, + /// `#[secret(store_ref)]` — the field value is a `[stores.secrets]` + /// logical id. + StoreRef, +} + +/// Per-field annotation result captured during scanning. +struct FieldAnnotation { + kind: SecretAnnotation, + name: Ident, +} + +/// Inspect the input struct, emit `impl AppConfigMeta` with the +/// `SECRET_FIELDS` array. Errors surface as `compile_error!` tokens +/// substituted in place of the impl. +#[inline] +pub fn derive(tokens: TokenStream) -> TokenStream { + let parsed = parse_macro_input!(tokens as DeriveInput); + expand(&parsed) + .unwrap_or_else(|err| err.to_compile_error()) + .into() +} + +fn expand(input: &DeriveInput) -> Result { + let struct_ident = &input.ident; + let (impl_generics, type_generics, where_clause) = input.generics.split_for_impl(); + + let fields = struct_fields(input)?; + let mut annotations: Vec = Vec::new(); + for field in fields { + if let Some(annotation) = scan_field(field)? { + annotations.push(annotation); + } + } + + // SECRET_FIELDS emits the Rust field name verbatim. A container- + // level `#[serde(rename_all = ...)]` would desync that metadata + // from what `config validate` (and the Spin collision check) sees + // on the wire — silently — so reject it whenever any + // secret field is present. Structs with no secret fields are + // unaffected: SECRET_FIELDS is empty and the validator never + // compares names. + if !annotations.is_empty() { + enforce_no_container_rename_all(&input.attrs)?; + } + + let entries = annotations.iter().map(|annotation| { + let name_lit = annotation.name.to_string(); + let kind_tokens = match annotation.kind { + SecretAnnotation::KeyInDefault => { + quote!(::edgezero_core::app_config::SecretKind::KeyInDefault) + } + SecretAnnotation::StoreRef => quote!(::edgezero_core::app_config::SecretKind::StoreRef), + }; + quote! { + ::edgezero_core::app_config::SecretField { + name: #name_lit, + kind: #kind_tokens, + } + } + }); + + Ok(quote! { + #[automatically_derived] + impl #impl_generics ::edgezero_core::app_config::AppConfigMeta + for #struct_ident #type_generics #where_clause + { + const SECRET_FIELDS: &'static [::edgezero_core::app_config::SecretField] = + &[#(#entries),*]; + } + }) +} + +/// Borrow the struct's named fields, or error with a clear message. +fn struct_fields(input: &DeriveInput) -> Result<&Punctuated, syn::Error> { + let data = match &input.data { + Data::Struct(data) => data, + Data::Enum(_) | Data::Union(_) => { + return Err(syn::Error::new_spanned( + &input.ident, + "`#[derive(AppConfig)]` is only supported on structs", + )); + } + }; + match &data.fields { + Fields::Named(named) => Ok(&named.named), + Fields::Unnamed(_) => Err(syn::Error::new_spanned( + &input.ident, + "`#[derive(AppConfig)]` is only supported on structs with named fields", + )), + Fields::Unit => Err(syn::Error::new_spanned( + &input.ident, + "`#[derive(AppConfig)]` is only supported on structs with named fields (this struct has no fields)", + )), + } +} + +/// Inspect a single field. Returns `Ok(Some(...))` when the field +/// carries a recognised `#[secret]` annotation, `Ok(None)` when it +/// carries none, and `Err` for an invalid combination. +fn scan_field(field: &Field) -> Result, syn::Error> { + let Some(name) = field.ident.clone() else { + return Ok(None); + }; + + let mut secret_attrs = field + .attrs + .iter() + .filter(|attr| attr.path().is_ident("secret")); + let Some(first) = secret_attrs.next() else { + return Ok(None); + }; + if let Some(duplicate) = secret_attrs.next() { + return Err(syn::Error::new_spanned( + duplicate, + "duplicate `#[secret]` annotation on the same field", + )); + } + let kind = parse_secret_kind(first)?; + + enforce_scalar_string_type(field)?; + enforce_no_disallowed_serde_attrs(field)?; + + Ok(Some(FieldAnnotation { kind, name })) +} + +/// Decode `#[secret]` (`KeyInDefault`) and `#[secret(store_ref)]` +/// (`StoreRef`). Any other token list is a compile error. +fn parse_secret_kind(attr: &Attribute) -> Result { + match &attr.meta { + Meta::Path(_) => Ok(SecretAnnotation::KeyInDefault), + Meta::List(list) => { + let inner: Path = syn::parse2(list.tokens.clone()).map_err(|_unused| { + syn::Error::new_spanned( + &list.tokens, + "`#[secret(...)]` accepts only `store_ref` (e.g. `#[secret(store_ref)]`)", + ) + })?; + if inner.is_ident("store_ref") { + Ok(SecretAnnotation::StoreRef) + } else { + Err(syn::Error::new_spanned( + &list.tokens, + "`#[secret(...)]` accepts only `store_ref` (e.g. `#[secret(store_ref)]`)", + )) + } + } + Meta::NameValue(_) => Err(syn::Error::new_spanned( + attr, + "`#[secret = \"...\"]` form is not supported; use `#[secret]` or `#[secret(store_ref)]`", + )), + } +} + +/// `#[secret]` may only annotate a scalar string field. Per we +/// accept bare `String` only — generic or qualified forms (e.g. +/// `Option`, `Cow<'_, str>`) are intentionally rejected so +/// `cfg.api_token` resolves to a value at every call site. +fn enforce_scalar_string_type(field: &Field) -> Result<(), syn::Error> { + if !is_scalar_string_type(&field.ty) { + return Err(syn::Error::new_spanned( + &field.ty, + "`#[secret]` / `#[secret(store_ref)]` may only annotate a scalar string field (e.g. `String`)", + )); + } + Ok(()) +} + +fn is_scalar_string_type(ty: &Type) -> bool { + if let Type::Path(type_path) = ty { + if type_path.qself.is_none() { + if let Some(last) = type_path.path.segments.last() { + return last.ident == "String" && last.arguments.is_empty(); + } + } + } + false +} + +/// Container-level guard: a struct that carries any `#[secret]` field +/// must not also carry `#[serde(rename_all = ...)]`. The derive emits +/// `SECRET_FIELDS` with Rust field names verbatim, but `rename_all` +/// would translate the on-the-wire key name (e.g. `kebab-case` → +/// `api-token`), silently desyncing the typed `config validate` secret +/// checks from what the deserialiser actually accepts. Reject this at +/// compile time so the desync can't ship. +fn enforce_no_container_rename_all(attrs: &[Attribute]) -> Result<(), syn::Error> { + for attr in attrs { + if !attr.path().is_ident("serde") { + continue; + } + let mut offending = false; + let _parse_result: syn::Result<()> = attr.parse_nested_meta(|meta| { + if meta.path.is_ident("rename_all") { + offending = true; + } + Ok(()) + }); + if offending { + return Err(syn::Error::new_spanned( + attr, + "`#[derive(AppConfig)]` rejects `#[serde(rename_all = ...)]` on structs with `#[secret]` fields: SECRET_FIELDS uses Rust field names verbatim, so a container rename would silently desync `config validate` from runtime deserialisation", + )); + } + } + Ok(()) +} + +/// `#[secret]` cannot coexist with `#[serde(flatten)]` / +/// `#[serde(rename)]` / `#[serde(skip*)]` because the derive emits the +/// Rust field name verbatim and downstream tooling (config validate / +/// config push) expects that name to round-trip via TOML serde without +/// translation or omission. +fn enforce_no_disallowed_serde_attrs(field: &Field) -> Result<(), syn::Error> { + for attr in &field.attrs { + if !attr.path().is_ident("serde") { + continue; + } + let mut offending: Option<&'static str> = None; + // `parse_nested_meta` walks each comma-separated entry in the + // `#[serde(...)]` list. We swallow its own parse errors — those + // belong to the user's serde macros, not ours — and only react + // when a disallowed key is observed. + let _parse_result: syn::Result<()> = attr.parse_nested_meta(|meta| { + if let Some(ident) = meta.path.get_ident() { + offending = match ident.to_string().as_str() { + "flatten" => Some("flatten"), + "rename" => Some("rename"), + "skip" => Some("skip"), + "skip_deserializing" => Some("skip_deserializing"), + "skip_serializing" => Some("skip_serializing"), + // `skip_serializing_if = "..."` also omits the + // field from round-trips (config push reads + // SECRET_FIELDS, then serialises the typed + // struct), so reject it alongside the + // unconditional skip family. + "skip_serializing_if" => Some("skip_serializing_if"), + _ => offending, + }; + } + Ok(()) + }); + if let Some(name) = offending { + return Err(syn::Error::new_spanned( + attr, + format!( + "`#[secret]` is incompatible with `#[serde({name})]` — the derive emits the Rust field name verbatim and config validate / push round-trip it via TOML", + ), + )); + } + } + Ok(()) +} diff --git a/crates/edgezero-macros/src/lib.rs b/crates/edgezero-macros/src/lib.rs index 4e851476..17a572d9 100644 --- a/crates/edgezero-macros/src/lib.rs +++ b/crates/edgezero-macros/src/lib.rs @@ -1,14 +1,24 @@ mod action; mod app; +mod app_config; +mod manifest_definitions; use proc_macro::TokenStream; #[proc_macro_attribute] +#[inline] pub fn action(attr: TokenStream, item: TokenStream) -> TokenStream { action::expand_action(attr, item) } #[proc_macro] +#[inline] pub fn app(input: TokenStream) -> TokenStream { app::expand_app(input) } + +#[proc_macro_derive(AppConfig, attributes(secret))] +#[inline] +pub fn app_config_derive(input: TokenStream) -> TokenStream { + app_config::derive(input) +} diff --git a/crates/edgezero-macros/src/manifest_definitions.rs b/crates/edgezero-macros/src/manifest_definitions.rs new file mode 100644 index 00000000..4687b788 --- /dev/null +++ b/crates/edgezero-macros/src/manifest_definitions.rs @@ -0,0 +1,13 @@ +// Many manifest fields exist for downstream consumers (CLI, runtime +// adapters, etc.) but are unused inside the proc-macro itself, which only +// reads enough of the structure to generate routing. Allow `dead_code` so +// those fields don't trip warnings just because the macro doesn't touch them. +#![allow( + dead_code, + reason = "macro-side reads only the routing-relevant fields" +)] + +include!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../edgezero-core/src/manifest.rs" +)); diff --git a/crates/edgezero-macros/tests/app_config_derive.rs b/crates/edgezero-macros/tests/app_config_derive.rs new file mode 100644 index 00000000..1a816e11 --- /dev/null +++ b/crates/edgezero-macros/tests/app_config_derive.rs @@ -0,0 +1,105 @@ +//! Happy-path coverage for `#[derive(AppConfig)]`. Compile- +//! fail coverage lives next to `tests/ui/*.rs` and runs via `trybuild`. + +#[cfg(test)] +mod tests { + use edgezero_core::app_config::{AppConfigMeta as _, SecretField, SecretKind}; + + #[derive(serde::Deserialize, validator::Validate, edgezero_core::AppConfig)] + #[serde(deny_unknown_fields)] + struct ConfigNoSecrets { + _greeting: String, + } + + // The `#[secret]`-annotated fields below are exercised only via the + // `SECRET_FIELDS` associated constant the derive emits — Rust still + // counts them as "never read", so silence the dead-code lint at the + // struct level. + #[derive(serde::Deserialize, validator::Validate, edgezero_core::AppConfig)] + #[serde(deny_unknown_fields)] + #[expect( + dead_code, + reason = "fields exist only to feed `#[derive(AppConfig)]`; the SECRET_FIELDS array reads them via the derive, not via Rust field access" + )] + struct ConfigKeyInDefault { + _greeting: String, + #[secret] + api_token: String, + } + + #[derive(serde::Deserialize, validator::Validate, edgezero_core::AppConfig)] + #[serde(deny_unknown_fields)] + #[expect( + dead_code, + reason = "fields exist only to feed `#[derive(AppConfig)]`; the SECRET_FIELDS array reads them via the derive, not via Rust field access" + )] + struct ConfigStoreRef { + _greeting: String, + #[secret(store_ref)] + vault: String, + } + + #[derive(serde::Deserialize, validator::Validate, edgezero_core::AppConfig)] + #[serde(deny_unknown_fields)] + #[expect( + dead_code, + reason = "fields exist only to feed `#[derive(AppConfig)]`; the SECRET_FIELDS array reads them via the derive, not via Rust field access" + )] + struct ConfigBothKinds { + _greeting: String, + #[secret] + api_token: String, + #[secret(store_ref)] + vault: String, + } + + #[test] + fn no_secret_annotation_yields_empty_secret_fields() { + assert!(ConfigNoSecrets::SECRET_FIELDS.is_empty()); + } + + #[test] + fn plain_secret_attribute_yields_key_in_default() { + assert_eq!( + ConfigKeyInDefault::SECRET_FIELDS, + &[SecretField { + name: "api_token", + kind: SecretKind::KeyInDefault, + }] + ); + } + + #[test] + fn secret_store_ref_attribute_yields_store_ref() { + assert_eq!( + ConfigStoreRef::SECRET_FIELDS, + &[SecretField { + name: "vault", + kind: SecretKind::StoreRef, + }] + ); + } + + #[test] + fn both_secret_kinds_are_collected_in_source_order() { + assert_eq!( + ConfigBothKinds::SECRET_FIELDS, + &[ + SecretField { + name: "api_token", + kind: SecretKind::KeyInDefault, + }, + SecretField { + name: "vault", + kind: SecretKind::StoreRef, + }, + ] + ); + } + + #[test] + fn trybuild_compile_fail_fixtures() { + let cases = trybuild::TestCases::new(); + cases.compile_fail("tests/ui/secret_*.rs"); + } +} diff --git a/crates/edgezero-macros/tests/ui/secret_bogus_kind.rs b/crates/edgezero-macros/tests/ui/secret_bogus_kind.rs new file mode 100644 index 00000000..22ff3ad5 --- /dev/null +++ b/crates/edgezero-macros/tests/ui/secret_bogus_kind.rs @@ -0,0 +1,10 @@ +//! `#[secret(...)]` accepts only `store_ref`; any other argument is a +//! compile error. + +#[derive(serde::Deserialize, validator::Validate, edgezero_core::AppConfig)] +struct Config { + #[secret(bogus)] + api_token: String, +} + +fn main() {} diff --git a/crates/edgezero-macros/tests/ui/secret_bogus_kind.stderr b/crates/edgezero-macros/tests/ui/secret_bogus_kind.stderr new file mode 100644 index 00000000..553529c9 --- /dev/null +++ b/crates/edgezero-macros/tests/ui/secret_bogus_kind.stderr @@ -0,0 +1,5 @@ +error: `#[secret(...)]` accepts only `store_ref` (e.g. `#[secret(store_ref)]`) + --> tests/ui/secret_bogus_kind.rs:6:14 + | +6 | #[secret(bogus)] + | ^^^^^ diff --git a/crates/edgezero-macros/tests/ui/secret_on_non_scalar.rs b/crates/edgezero-macros/tests/ui/secret_on_non_scalar.rs new file mode 100644 index 00000000..c13e39f9 --- /dev/null +++ b/crates/edgezero-macros/tests/ui/secret_on_non_scalar.rs @@ -0,0 +1,10 @@ +//! `#[secret]` must annotate a scalar string field; a non-scalar type +//! (e.g. `Vec`) is a compile error. + +#[derive(serde::Deserialize, validator::Validate, edgezero_core::AppConfig)] +struct Config { + #[secret] + api_tokens: Vec, +} + +fn main() {} diff --git a/crates/edgezero-macros/tests/ui/secret_on_non_scalar.stderr b/crates/edgezero-macros/tests/ui/secret_on_non_scalar.stderr new file mode 100644 index 00000000..817d8c55 --- /dev/null +++ b/crates/edgezero-macros/tests/ui/secret_on_non_scalar.stderr @@ -0,0 +1,5 @@ +error: `#[secret]` / `#[secret(store_ref)]` may only annotate a scalar string field (e.g. `String`) + --> tests/ui/secret_on_non_scalar.rs:7:17 + | +7 | api_tokens: Vec, + | ^^^^^^^^^^^ diff --git a/crates/edgezero-macros/tests/ui/secret_with_serde_container_rename_all.rs b/crates/edgezero-macros/tests/ui/secret_with_serde_container_rename_all.rs new file mode 100644 index 00000000..a50d90fa --- /dev/null +++ b/crates/edgezero-macros/tests/ui/secret_with_serde_container_rename_all.rs @@ -0,0 +1,14 @@ +//! Container-level `#[serde(rename_all = ...)]` on a struct that has a +//! `#[secret]` field must be rejected: the renamer would translate the +//! TOML key to `api-token` while `SECRET_FIELDS` keeps reporting +//! `api_token`, silently desyncing the typed `config validate` secret +//! checks and the Spin collision check. + +#[derive(serde::Deserialize, validator::Validate, edgezero_core::AppConfig)] +#[serde(rename_all = "kebab-case")] +struct ConfigWithRenameAll { + #[secret] + api_token: String, +} + +fn main() {} diff --git a/crates/edgezero-macros/tests/ui/secret_with_serde_container_rename_all.stderr b/crates/edgezero-macros/tests/ui/secret_with_serde_container_rename_all.stderr new file mode 100644 index 00000000..c94cb25d --- /dev/null +++ b/crates/edgezero-macros/tests/ui/secret_with_serde_container_rename_all.stderr @@ -0,0 +1,5 @@ +error: `#[derive(AppConfig)]` rejects `#[serde(rename_all = ...)]` on structs with `#[secret]` fields: SECRET_FIELDS uses Rust field names verbatim, so a container rename would silently desync `config validate` from runtime deserialisation + --> tests/ui/secret_with_serde_container_rename_all.rs:8:1 + | +8 | #[serde(rename_all = "kebab-case")] + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ diff --git a/crates/edgezero-macros/tests/ui/secret_with_serde_flatten.rs b/crates/edgezero-macros/tests/ui/secret_with_serde_flatten.rs new file mode 100644 index 00000000..713d949a --- /dev/null +++ b/crates/edgezero-macros/tests/ui/secret_with_serde_flatten.rs @@ -0,0 +1,10 @@ +//! `#[secret]` is incompatible with `#[serde(flatten)]`. + +#[derive(serde::Deserialize, validator::Validate, edgezero_core::AppConfig)] +struct Config { + #[secret] + #[serde(flatten)] + api_token: String, +} + +fn main() {} diff --git a/crates/edgezero-macros/tests/ui/secret_with_serde_flatten.stderr b/crates/edgezero-macros/tests/ui/secret_with_serde_flatten.stderr new file mode 100644 index 00000000..90e8c374 --- /dev/null +++ b/crates/edgezero-macros/tests/ui/secret_with_serde_flatten.stderr @@ -0,0 +1,5 @@ +error: `#[secret]` is incompatible with `#[serde(flatten)]` — the derive emits the Rust field name verbatim and config validate / push round-trip it via TOML + --> tests/ui/secret_with_serde_flatten.rs:6:5 + | +6 | #[serde(flatten)] + | ^^^^^^^^^^^^^^^^^ diff --git a/crates/edgezero-macros/tests/ui/secret_with_serde_rename.rs b/crates/edgezero-macros/tests/ui/secret_with_serde_rename.rs new file mode 100644 index 00000000..be9a25ab --- /dev/null +++ b/crates/edgezero-macros/tests/ui/secret_with_serde_rename.rs @@ -0,0 +1,10 @@ +//! `#[secret]` is incompatible with `#[serde(rename)]`. + +#[derive(serde::Deserialize, validator::Validate, edgezero_core::AppConfig)] +struct Config { + #[secret] + #[serde(rename = "token")] + api_token: String, +} + +fn main() {} diff --git a/crates/edgezero-macros/tests/ui/secret_with_serde_rename.stderr b/crates/edgezero-macros/tests/ui/secret_with_serde_rename.stderr new file mode 100644 index 00000000..0fb8a0b5 --- /dev/null +++ b/crates/edgezero-macros/tests/ui/secret_with_serde_rename.stderr @@ -0,0 +1,5 @@ +error: `#[secret]` is incompatible with `#[serde(rename)]` — the derive emits the Rust field name verbatim and config validate / push round-trip it via TOML + --> tests/ui/secret_with_serde_rename.rs:6:5 + | +6 | #[serde(rename = "token")] + | ^^^^^^^^^^^^^^^^^^^^^^^^^^ diff --git a/crates/edgezero-macros/tests/ui/secret_with_serde_skip_serializing_if.rs b/crates/edgezero-macros/tests/ui/secret_with_serde_skip_serializing_if.rs new file mode 100644 index 00000000..b0c088b1 --- /dev/null +++ b/crates/edgezero-macros/tests/ui/secret_with_serde_skip_serializing_if.rs @@ -0,0 +1,16 @@ +//! `#[serde(skip_serializing_if = "...")]` conditionally omits the +//! field from serialisation. Combined with `#[secret]`, that would +//! make `config push` (which reads `SECRET_FIELDS`, then serialises +//! the typed struct) drop the secret key under the condition — +//! desyncing the on-the-wire shape from the SECRET_FIELDS invariant +//! relies on. Reject at compile time. + +#[derive(serde::Deserialize, serde::Serialize, validator::Validate, edgezero_core::AppConfig)] +#[serde(deny_unknown_fields)] +struct ConfigWithSkipSerializingIf { + #[secret] + #[serde(skip_serializing_if = "String::is_empty")] + api_token: String, +} + +fn main() {} diff --git a/crates/edgezero-macros/tests/ui/secret_with_serde_skip_serializing_if.stderr b/crates/edgezero-macros/tests/ui/secret_with_serde_skip_serializing_if.stderr new file mode 100644 index 00000000..5e905343 --- /dev/null +++ b/crates/edgezero-macros/tests/ui/secret_with_serde_skip_serializing_if.stderr @@ -0,0 +1,5 @@ +error: `#[secret]` is incompatible with `#[serde(skip_serializing_if)]` — the derive emits the Rust field name verbatim and config validate / push round-trip it via TOML + --> tests/ui/secret_with_serde_skip_serializing_if.rs:12:5 + | +12 | #[serde(skip_serializing_if = "String::is_empty")] + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ diff --git a/docs/.gitignore b/docs/.gitignore index 57a09c39..097c2293 100644 --- a/docs/.gitignore +++ b/docs/.gitignore @@ -1,3 +1,4 @@ node_modules .vitepress/dist .vitepress/cache +.vitepress/.temp diff --git a/docs/.prettierignore b/docs/.prettierignore index 94aa6e0c..2879ebbd 100644 --- a/docs/.prettierignore +++ b/docs/.prettierignore @@ -1,3 +1,11 @@ .vitepress/cache .vitepress/dist node_modules + +# Internal design docs (specs + plans) — mirror VitePress's srcExclude. +# Prettier's continuation-line indent rules mangle the wrapped prose in +# these handwritten documents (e.g. lines starting with `[[...]]` get +# treated as link references), so leave them un-reformatted. They sit +# under `docs/` only because the path is convenient for note-taking; +# they're gitignored and not part of the published site. +superpowers diff --git a/docs/.vitepress/config.mts b/docs/.vitepress/config.mts index 492496c6..f56ff1f1 100644 --- a/docs/.vitepress/config.mts +++ b/docs/.vitepress/config.mts @@ -5,6 +5,12 @@ export default defineConfig({ title: 'EdgeZero', description: 'Production-ready toolkit for portable edge HTTP workloads', base: '/edgezero/', + // `superpowers/` holds internal design docs (specs + plans) that are not + // part of the published site. They sit in `docs/` so the doc tooling + // (prettier, eslint) covers them, but VitePress should skip them: the + // raw spec text contains literal `{{ … }}` interpolations inside inline + // code that Vue's compiler would otherwise try to evaluate. + srcExclude: ['superpowers/**'], themeConfig: { // https://vitepress.dev/reference/default-theme-config nav: [ @@ -40,6 +46,7 @@ export default defineConfig({ { text: 'Overview', link: '/guide/adapters/overview' }, { text: 'Fastly Compute', link: '/guide/adapters/fastly' }, { text: 'Cloudflare Workers', link: '/guide/adapters/cloudflare' }, + { text: 'Fermyon Spin', link: '/guide/adapters/spin' }, { text: 'Axum (Native)', link: '/guide/adapters/axum' }, ], }, @@ -51,6 +58,11 @@ export default defineConfig({ link: '/guide/configuration', }, { text: 'CLI Reference', link: '/guide/cli-reference' }, + { text: 'CLI Walkthrough', link: '/guide/cli-walkthrough' }, + { + text: 'Manifest Store Migration', + link: '/guide/manifest-store-migration', + }, ], }, ], diff --git a/docs/eslint.config.js b/docs/eslint.config.js index 50481a76..d991fe3e 100644 --- a/docs/eslint.config.js +++ b/docs/eslint.config.js @@ -3,7 +3,12 @@ import tseslint from 'typescript-eslint' export default [ { - ignores: ['.vitepress/cache/**', '.vitepress/dist/**', 'node_modules/**'], + ignores: [ + '.vitepress/cache/**', + '.vitepress/dist/**', + '.vitepress/.temp/**', + 'node_modules/**', + ], }, js.configs.recommended, ...tseslint.configs.recommended, diff --git a/docs/guide/adapters/axum.md b/docs/guide/adapters/axum.md index fd3b47c8..27db1295 100644 --- a/docs/guide/adapters/axum.md +++ b/docs/guide/adapters/axum.md @@ -29,23 +29,24 @@ The Axum entrypoint wires the adapter: ```rust use my_app_core::App; -fn main() { - if let Err(err) = edgezero_adapter_axum::run_app::(include_str!("../../../edgezero.toml")) { - eprintln!("axum adapter failed: {err}"); - std::process::exit(1); - } +fn main() -> anyhow::Result<()> { + edgezero_adapter_axum::run_app::() } ``` -`run_app` installs `simple_logger`, builds the app, and wires the local config store from -`[stores.config]` automatically. +`run_app` installs `simple_logger`, builds the app, and reads bind / +store / logging config at runtime from `EDGEZERO__*` environment +variables (see [the migration guide](../manifest-store-migration.md)). +The portable store metadata baked into `App` by the `app!` macro +drives which logical stores are exposed; no `edgezero.toml` needs to +be loaded by the runtime. ## Development Server -The `edgezero dev` command uses the Axum adapter: +Run your project locally on the Axum adapter: ```bash -edgezero dev +edgezero serve --adapter axum ``` This starts a server at `http://127.0.0.1:8787` with standard logging to stdout. @@ -137,23 +138,44 @@ cargo test -p my-app-adapter-axum ## Config Store -For local development, the Axum adapter only reads environment variables for keys declared in -`[stores.config.defaults]`, then falls back to those defaults in `edgezero.toml`: +For local development, each declared `[stores.config]` id resolves to a +local-file config store backed by `.edgezero/local-config-.json`. +The portable manifest carries no inline defaults — the +pre-rewrite `[stores.config.defaults]` table is gone (see +[the migration guide](../manifest-store-migration.md)). ```toml [stores.config] -name = "app_config" +ids = ["app_config"] +# default = "app_config" # required when ids.len() > 1 +``` -[stores.config.defaults] -"greeting" = "hello from config store" -"feature.new_checkout" = "false" -"service.timeout_ms" = "" +```jsonc +// .edgezero/local-config-app_config.json +{ + "greeting": "hello from config store", + "feature.new_checkout": "false", + "service.timeout_ms": "1500", +} +``` + +Handlers access stores via the `Config` extractor or `ctx.config_store(id)`: + +```rust +async fn handler(config: Config) -> Result { + let store = config.default().ok_or_else(|| EdgeError::service_unavailable("no default config"))?; + let greeting = store.get("greeting").await?.unwrap_or_default(); + // … +} ``` -Handlers access the injected store through `ctx.config_store()`. Environment variables take -precedence over manifest defaults. If a key should be overrideable from env without carrying a real -default value, declare it with an empty-string placeholder. Do not pass raw user input straight to -`ctx.config_store()?.get(...)` in production handlers; validate or allowlist keys first. +Do not pass raw user input straight to `store.get(…)` in production +handlers; validate or allowlist keys first. Seed the per-id JSON +files with `edgezero config push --adapter axum` (or +` config push --adapter axum` for the typed flow with +`#[secret]` stripping), which writes the same +`.edgezero/local-config-.json` files the runtime reads — +no shell-out, no server to authenticate against. ## Container Deployment @@ -183,7 +205,7 @@ The runtime currently binds to `127.0.0.1:8787` regardless of the `axum.toml` po A typical development workflow: -1. **Start dev server**: `edgezero dev` +1. **Run locally**: `edgezero serve --adapter axum` 2. **Make changes** to handlers in `my-app-core` 3. **Test locally** with curl or browser 4. **Run tests**: `cargo test` diff --git a/docs/guide/adapters/cloudflare.md b/docs/guide/adapters/cloudflare.md index ccfd576c..c22e99e6 100644 --- a/docs/guide/adapters/cloudflare.md +++ b/docs/guide/adapters/cloudflare.md @@ -44,15 +44,20 @@ use worker::*; #[event(fetch)] pub async fn main(req: Request, env: Env, ctx: Context) -> Result { - edgezero_adapter_cloudflare::run_app::(include_str!("../../edgezero.toml"), req, env, ctx).await + edgezero_adapter_cloudflare::run_app::(req, env, ctx).await } ``` -`run_app` reads config-store metadata generated by `edgezero_core::app!` and injects the configured -Cloudflare binding automatically. If you implement `Hooks` manually, pass the manifest source string directly to `run_app`. +`run_app` reads the portable store metadata baked into `App` by the +`app!` macro and the `EDGEZERO__*` env vars exposed on the worker +`Env` (Workers cannot enumerate `Env`, so the canonical key set is +derived from the baked store ids and queried individually). Per-id +`KV` / `Config` / `Secret` registries are built and injected into +request extensions automatically. No `edgezero.toml` is loaded by +the runtime — see [the migration guide](../manifest-store-migration.md). The low-level `dispatch()` helper remains available only for fully manual wiring and does not inject -config-store metadata. Prefer `run_app` or `dispatch_with_config` for normal use. +store metadata. Prefer `run_app` or `dispatch_with_config` for normal use. `dispatch_with_config_handle` exists for advanced/manual cases where you already have a prepared `ConfigStoreHandle`. @@ -149,24 +154,30 @@ Access in handlers via the Cloudflare context or environment bindings. ## Config Store -Cloudflare does not expose a Fastly-style mutable config-store product, so EdgeZero maps -`[stores.config]` to a single JSON string binding in `wrangler.toml [vars]`: +Cloudflare does not expose a Fastly-style mutable config-store product, so each +declared `[stores.config]` id maps to a **KV namespace binding**. Reads are +asynchronous (`worker::kv::KvStore::get(key).text().await`). ```toml # edgezero.toml [stores.config] -name = "app_config" +ids = ["app_config"] +# default = "app_config" # required when ids.len() > 1 ``` ```toml # wrangler.toml -[vars] -app_config = '{"greeting":"hello from config store","feature.new_checkout":"false"}' +[[kv_namespaces]] +binding = "app_config" +id = "abc123…" ``` -At runtime the adapter parses that JSON object and injects it as `ctx.config_store()`. If the -configured binding is missing or contains invalid JSON, the adapter logs a warning and skips -config-store injection for that request. +The binding name comes from `EDGEZERO__STORES__CONFIG__APP_CONFIG__NAME` +(defaulting to the logical id `app_config` when unset). Populate the +namespace via `wrangler kv:key put`. Missing bindings log a one-time +warning and the id is dropped from the registry. See +[the migration guide](../manifest-store-migration.md) if you are coming +from the pre-rewrite `[vars]`-backed JSON-string form. ## KV Storage diff --git a/docs/guide/adapters/fastly.md b/docs/guide/adapters/fastly.md index 4db5621a..59d34c7d 100644 --- a/docs/guide/adapters/fastly.md +++ b/docs/guide/adapters/fastly.md @@ -45,15 +45,19 @@ use my_app_core::App; #[fastly::main] fn main(req: fastly::Request) -> Result { - edgezero_adapter_fastly::run_app::(include_str!("../../../edgezero.toml"), req) + edgezero_adapter_fastly::run_app::(req) } ``` -`run_app` reads logging and config-store settings from `edgezero.toml`, builds the app, and injects -the configured Fastly Config Store into request extensions automatically. +`run_app` reads logging and store config at runtime from `EDGEZERO__*` +environment variables (see +[the migration guide](../manifest-store-migration.md)) and builds +per-id `KV` / `Config` / `Secret` registries from the portable store +metadata baked into `App` by the `app!` macro. No `edgezero.toml` is +loaded by the runtime. The low-level `dispatch()` helper remains available only for fully manual wiring and does not inject -config-store metadata. Prefer `run_app` or `dispatch_with_config` for normal use. +store metadata. Prefer `run_app` or `dispatch_with_config` for normal use. `dispatch_with_config_handle` exists for advanced/manual cases where you already have a prepared `ConfigStoreHandle`. @@ -138,15 +142,17 @@ Fastly logging is wired when you call `init_logger` (or `run_app`); otherwise no ## Config Store -Fastly uses a native Config Store resource link for runtime configuration. Declare the logical store -name in `edgezero.toml`: +Fastly uses a native Config Store resource link for runtime configuration. Declare logical config +ids in `edgezero.toml`; each id opens its own platform store via +`EDGEZERO__STORES__CONFIG____NAME` (default = the logical id): ```toml [stores.config] -name = "app_config" +ids = ["app_config"] +# default = "app_config" # required when ids.len() > 1 ``` -For local Viceroy testing, mirror that binding in `fastly.toml`: +For local Viceroy testing, mirror the platform name in `fastly.toml`: ```toml [local_server.config_stores.app_config] @@ -156,8 +162,19 @@ format = "inline-toml" greeting = "hello from config store" ``` -Handlers can then read values through `ctx.config_store()`. If the configured store link is missing, -the adapter logs a warning and continues without injecting a config-store handle. +Handlers read values through the `Config` extractor or `ctx.config_store(id)`: + +```rust +async fn handler(config: Config) -> Result { + let store = config.named("app_config").ok_or_else(|| EdgeError::service_unavailable("no `app_config`"))?; + let greeting = store.get("greeting").await?.unwrap_or_default(); + // … +} +``` + +If a configured store link is missing, the adapter logs a one-time warning +and drops that id from the registry. Migrating from `name`/`adapters.*`? +See [the migration guide](../manifest-store-migration.md). ## Context Access diff --git a/docs/guide/adapters/overview.md b/docs/guide/adapters/overview.md index 58354db2..08745634 100644 --- a/docs/guide/adapters/overview.md +++ b/docs/guide/adapters/overview.md @@ -41,9 +41,16 @@ Adapters surface a `dispatch` function that bridges from the provider event loop This helper is what demo entrypoints and adapters call when wiring their platform-specific main functions. -## Config Store Resolution +## Store Registry Resolution -When wiring adapters, Fastly and Cloudflare check `Hooks::config_store()` first to allow custom overrides, and then fall back to the manifest. However, the Axum adapter resolves the config store exclusively from `edgezero.toml` defaults (`[stores.config.defaults]`) and currently ignores custom `Hooks::config_store()` implementations. +All four adapters resolve KV, config, and secret stores from the portable +`Hooks::stores()` metadata baked by the `app!` macro plus `EDGEZERO__*` +environment variables (see [the migration guide](../manifest-store-migration.md) +for the schema change). Each adapter builds a per-request +`StoreRegistry` keyed by logical id; handlers reach a bound store via +the id-keyed `Kv` / `Secrets` / `Config` extractors or the matching +`ctx.kv_store(id)` / `ctx.config_store(id)` / `ctx.secret_store(id)` +accessors. The pre-rewrite `Hooks::config_store()` hook is gone. ## Proxy Integration @@ -115,4 +122,5 @@ Adapters that fulfil these steps can be dropped into the EdgeZero CLI without re | ---------------------------------------- | ------------------- | ------------------------ | ------ | | [Fastly](/guide/adapters/fastly) | Fastly Compute@Edge | `wasm32-wasip1` | Stable | | [Cloudflare](/guide/adapters/cloudflare) | Cloudflare Workers | `wasm32-unknown-unknown` | Stable | +| [Spin](/guide/adapters/spin) | Fermyon Spin | `wasm32-wasip2` | Stable | | [Axum](/guide/adapters/axum) | Native (Tokio) | Host | Stable | diff --git a/docs/guide/adapters/spin.md b/docs/guide/adapters/spin.md new file mode 100644 index 00000000..35909500 --- /dev/null +++ b/docs/guide/adapters/spin.md @@ -0,0 +1,257 @@ +# Fermyon Spin + +Run EdgeZero applications on [Fermyon Spin](https://spinframework.dev/), +a WebAssembly-first application platform with a `wasm32-wasip2` target and +component-scoped KV / variable stores. + +## Prerequisites + +- Rust toolchain with `wasm32-wasip2` target (`rustup target add wasm32-wasip2`) +- Spin CLI ([install](https://spinframework.dev/install)) + +## Project Setup + +When scaffolding with `edgezero new my-app`, the Spin adapter includes: + +``` +crates/my-app-adapter-spin/ +├── Cargo.toml +├── spin.toml +└── src/ + └── lib.rs +``` + +### Entrypoint + +The Spin entrypoint wires the adapter via `#[http_service]`: + +```rust +use spin_sdk::{http::IntoResponse, http::Request, http_service}; +use my_app_core::App; + +#[http_service] +async fn handle(req: Request) -> anyhow::Result { + edgezero_adapter_spin::run_app::(req).await +} +``` + +`run_app` reads the portable store metadata baked into `App` by the `app!` +macro plus `EDGEZERO__*` environment variables; it does not require an +`edgezero.toml` to be present at runtime. + +## Building + +Build the Spin component: + +```bash +# Using the CLI +edgezero build --adapter spin + +# Or directly +cargo build --target wasm32-wasip2 --release -p my-app-adapter-spin +``` + +## Local Development + +```bash +# Using the CLI +edgezero serve --adapter spin + +# Or directly +spin up --from crates/my-app-adapter-spin +``` + +## Deployment + +```bash +# Using the CLI +edgezero deploy --adapter spin + +# Or directly +spin deploy --from crates/my-app-adapter-spin +``` + +## KV Storage + +Spin KV is **label-backed and multi-store** — each logical id in +`[stores.kv].ids` maps to a Spin store label declared in `spin.toml`. +Override the label per id with `EDGEZERO__STORES__KV____NAME`; with the +variable unset the label defaults to the logical id. + +```toml +# edgezero.toml +[stores.kv] +ids = ["sessions", "cache"] +default = "sessions" +``` + +```toml +# spin.toml +[component.my-app] +key_value_stores = ["sessions", "cache"] +``` + +Two Spin-specific KV constraints (see §6.7 of the design spec for the +full rationale): + +- **TTL is unsupported.** Spin's `key_value::Store::set` accepts no + expiry. `put_bytes_with_ttl` returns + `KvError::Unsupported { operation: "put_bytes_with_ttl" }` (mapped to + HTTP 501); never silently strips the TTL. +- **Listing is capped.** `Store::get_keys()` is unbounded, so the + adapter materialises the key list, filters by prefix, sorts, and pages + client-side. A `max_list_keys` cap (default `1000`, override via + `EDGEZERO__STORES__KV____MAX_LIST_KEYS`) guards against runaway + lists and yields `KvError::LimitExceeded` (HTTP 503) when exceeded. + +## Config Store + +Spin config is **KV-backed and multi-store** — each logical id in +`[stores.config].ids` opens a separate `spin_sdk::key_value::Store` at +runtime. The store accepts arbitrary UTF-8 keys, so the canonical dotted +key (`service.timeout_ms`) is read back verbatim — no key translation. +Override the label per id with `EDGEZERO__STORES__CONFIG____NAME`; +with the variable unset the label defaults to the logical id. + +```toml +# edgezero.toml +[stores.config] +ids = ["app_config", "feature_flags"] +default = "app_config" +``` + +```toml +# spin.toml — declare every label in the component's `key_value_stores` +[component.my-app] +key_value_stores = ["app_config", "feature_flags"] +``` + +```toml +# runtime-config.toml — register each custom label with a backend +# (the default `default` label is auto-provided by Spin; everything +# else needs an entry here, or `spin up` errors with +# "unknown key_value_stores label "). +[key_value_store.app_config] +type = "spin" + +[key_value_store.feature_flags] +type = "spin" +``` + +`edgezero new --adapter spin` scaffolds both files; `edgezero serve +--adapter spin` runs `spin up --runtime-config-file runtime-config.toml` +so locally-declared labels resolve to the SQLite-backed Spin KV +implementation. For production, swap `type = "spin"` for a managed +backend (`type = "azure_cosmos"`, `type = "redis"`, …) per the +[Spin runtime-config docs](https://spinframework.dev/v3/dynamic-configuration#key-value-store-runtime-configuration). + +`provision` writes the `[component.].key_value_stores` array for +you (it does NOT touch `runtime-config.toml` — keep that one +hand-edited). + +### Seeding the store + +`edgezero config push --adapter spin` reads `runtime-config.toml` and +dispatches to the right per-backend writer — no embedded HTTP endpoint, +no token to manage. Resolution order: + +1. **`--local` set**: forces SQLite-direct against + `/.spin/sqlite_key_value.db` (Spin's local KV file). + Useful for poking values in your local dev loop without + authenticating against Fermyon Cloud. Even under `--local`, every + non-`default` label MUST be declared in `runtime-config.toml` + (see point 4 below) — without the stanza, `spin up` errors with + `unknown key_value_stores label ` and the file you just + wrote is unreadable from the running app, so the dispatcher + refuses the push and tells you exactly which stanza to add. +2. **Manifest's `deploy` command targets Fermyon Cloud** (auto-detected + from `[adapters.spin.commands].deploy` containing `spin deploy` or + `spin cloud deploy`): one batched shellout per ≤96 KiB chunk of + `spin cloud key-value set --app --label
__…__` (uppercase, with `-` in +the app name replaced by `_`, segments joined by a double-underscore). +The overlay only applies to keys **already present in the file** — +it can't introduce new ones — and the existing TOML value's type +drives how the env string is coerced (`"true"` / `"false"` for +`bool`, parsed integers for numeric fields, etc.). + +```sh +# Override the nested service.timeout_ms key: +MY_APP__SERVICE__TIMEOUT_MS=2500 \ + cargo run -p my-app-adapter-axum +``` + +The env-segment translation is uppercase-only — it does **not** +substitute `-` for `_`, so dashed and underscored TOML keys remain +distinct env segments. The only way two siblings collapse is when +they differ only in letter case (e.g. `greeting_a` and `GREETING_A`, +both uppercasing to `GREETING_A`). That case is rejected as an +`EnvOverlay` error before any override is applied, so a +misconfiguration leaves the file values intact. ## Adapters Section @@ -325,7 +433,7 @@ value = "https://api.example.com" name = "API_KEY" [stores.secrets] -name = "EDGEZERO_SECRETS" +ids = ["default"] [adapters.fastly.adapter] crate = "crates/my-app-adapter-fastly" @@ -374,15 +482,24 @@ serve = "cargo run -p my-app-adapter-axum" Axum bind-address precedence is: -1. `EDGEZERO_HOST` / `EDGEZERO_PORT` -2. `edgezero.toml` `[adapters.axum.adapter]` `host` / `port` -3. `axum.toml` `[adapter]` `host` / `port` when launching through the Axum adapter CLI wrapper +1. `EDGEZERO__ADAPTER__HOST` / `EDGEZERO__ADAPTER__PORT` (canonical; + read directly by the runtime). The pre-rewrite + `EDGEZERO_HOST` / `EDGEZERO_PORT` shim is gone — rename any CI + scripts or local overrides to the canonical double-underscore + form. +2. `edgezero.toml` `[adapters.axum.adapter]` `host` / `port` (the CLI + translates these into `EDGEZERO__ADAPTER__HOST` / `EDGEZERO__ADAPTER__PORT` + when spawning the subprocess; if a canonical env var is already set, + it wins) +3. `axum.toml` `[adapter]` `host` / `port` when launching through the + Axum adapter CLI wrapper 4. default `127.0.0.1:8787` Example override: ```sh -EDGEZERO_HOST=0.0.0.0 EDGEZERO_PORT=3000 cargo run -p my-app-adapter-axum +EDGEZERO__ADAPTER__HOST=0.0.0.0 EDGEZERO__ADAPTER__PORT=3000 \ + cargo run -p my-app-adapter-axum ``` ## Using the Manifest @@ -403,7 +520,7 @@ The macro: - Parses HTTP triggers - Generates route registration - Wires middleware from the manifest -- Generates config-store metadata from `[stores.config]` when present +- Bakes portable store metadata (`Hooks::stores()`) from `[stores.kv]`, `[stores.config]`, and `[stores.secrets]` when present - Creates the `App` struct that implements `Hooks` (use `App::build_app()`) ### ManifestLoader diff --git a/docs/guide/getting-started.md b/docs/guide/getting-started.md index 9befc2a2..ad9d5e73 100644 --- a/docs/guide/getting-started.md +++ b/docs/guide/getting-started.md @@ -7,6 +7,7 @@ This guide walks you through creating your first EdgeZero application. - Rust toolchain (stable; see `.tool-versions` in the repo) - For Fastly: `wasm32-wasip1` target and the Fastly CLI - For Cloudflare: `wasm32-unknown-unknown` target and Wrangler +- For Spin: `wasm32-wasip2` target and the [Spin CLI](https://spinframework.dev/) ## Installation @@ -27,18 +28,21 @@ cd my-app This generates a workspace with: -- `crates/my-app-core` - Your shared handlers and routing logic +- `crates/my-app-core` - Your shared handlers, routing logic, and the typed `MyAppConfig` struct in `src/config.rs` +- `crates/my-app-cli` - Your project's own CLI binary, built on the `edgezero-cli` library - `crates/my-app-adapter-fastly` - Fastly Compute entrypoint - `crates/my-app-adapter-cloudflare` - Cloudflare Workers entrypoint - `crates/my-app-adapter-axum` - Native Axum entrypoint +- `crates/my-app-adapter-spin` - Fermyon Spin entrypoint - `edgezero.toml` - Manifest describing routes, middleware, and adapter config +- `my-app.toml` - Typed application config matching the `MyAppConfig` struct (see [Application config](/guide/configuration#application-config)) -## Start the Dev Server +## Run Your App Locally -Run the local Axum-powered development server: +Run your generated app on the native Axum adapter: ```bash -edgezero dev +edgezero serve --adapter axum ``` Your app is now running at `http://127.0.0.1:8787`. Try the generated endpoints: @@ -64,12 +68,17 @@ A scaffolded project looks like this: my-app/ ├── Cargo.toml # Workspace manifest ├── edgezero.toml # EdgeZero configuration +├── my-app.toml # Typed application config (loaded into MyAppConfig) ├── crates/ │ ├── my-app-core/ │ │ ├── Cargo.toml │ │ └── src/ │ │ ├── lib.rs # App definition with edgezero_core::app! +│ │ ├── config.rs # MyAppConfig with #[derive(AppConfig)] │ │ └── handlers.rs # Your route handlers +│ ├── my-app-cli/ +│ │ ├── Cargo.toml +│ │ └── src/main.rs # Your project's CLI, built on edgezero-cli │ ├── my-app-adapter-fastly/ │ │ ├── Cargo.toml │ │ ├── fastly.toml @@ -78,9 +87,13 @@ my-app/ │ │ ├── Cargo.toml │ │ ├── wrangler.toml │ │ └── src/main.rs -│ └── my-app-adapter-axum/ +│ ├── my-app-adapter-axum/ +│ │ ├── Cargo.toml +│ │ ├── axum.toml +│ │ └── src/main.rs +│ └── my-app-adapter-spin/ │ ├── Cargo.toml -│ ├── axum.toml +│ ├── spin.toml │ └── src/main.rs ``` diff --git a/docs/guide/kv.md b/docs/guide/kv.md index 8d7cb329..c468aac5 100644 --- a/docs/guide/kv.md +++ b/docs/guide/kv.md @@ -18,7 +18,11 @@ struct VisitData { } #[action] -async fn visit_counter(Kv(store): Kv) -> Result { +async fn visit_counter(kv: Kv) -> Result { + let store = kv + .default() + .ok_or_else(|| EdgeError::service_unavailable("no default kv configured"))?; + // Read-modify-write helper (Note: not atomic!) let data = store .read_modify_write("visits", VisitData::default(), |mut d| { @@ -33,31 +37,48 @@ async fn visit_counter(Kv(store): Kv) -> Result { ## Usage -### 1. Configure the Store Name +### 1. Declare logical KV store ids -In your `edgezero.toml`: +In your `edgezero.toml` — declare one or more logical ids (the portable +fact "this app uses a KV store called `sessions`"). Platform names are +resolved at runtime from `EDGEZERO__STORES__KV____NAME`; with the +variable unset, the platform name defaults to the logical id. ```toml [stores.kv] -name = "EDGEZERO_KV" # Default name for all adapters +ids = ["sessions", "cache"] +default = "sessions" # required when ids.len() > 1 ``` +For a single-store app the `default` field is optional and resolves to +`ids[0]`. Migrating from the pre-rewrite `name` / `[stores.kv.adapters.*]` +form? See [the migration guide](./manifest-store-migration.md). + ### 2. Access the Store -You can access the store using the `Kv` extractor (recommended) or via `RequestContext`. +Use the id-keyed `Kv` extractor (recommended) or `RequestContext` accessors. -**Using Extractor:** +**Using the extractor — pick a store by id at the call site:** ```rust -async fn handler(Kv(store): Kv) { ... } +async fn handler(kv: Kv) -> Result { + let sessions = kv + .named("sessions") + .ok_or_else(|| EdgeError::service_unavailable("no `sessions` kv"))?; + // — or, for the single-store common case — + let default = kv + .default() + .ok_or_else(|| EdgeError::service_unavailable("no default kv"))?; + // … +} ``` -**Using Context:** +**Using context:** ```rust async fn handler(ctx: RequestContext) { - let store = ctx.kv_handle().expect("kv configured"); - ... + let store = ctx.kv_store("sessions").expect("kv `sessions` configured"); + // or: ctx.kv_store_default() } ``` @@ -86,43 +107,37 @@ Use it only when approximate values are acceptable (e.g. visit counters, feature For strict correctness, use a transactional data store. ::: -Key listing is paginated by design. This avoids buffering an unbounded number of keys in memory and matches the underlying provider APIs. The Spin adapter returns `KvError::Validation` for key listing because Spin's current `Store::get_keys()` API is unbounded. +Key listing is paginated by design. This avoids buffering an unbounded number of keys in memory and matches the underlying provider APIs. The Spin adapter materialises `Store::get_keys()` and pages client-side; a `max_list_keys` cap (configurable via `EDGEZERO__STORES__KV____MAX_LIST_KEYS`, default `1000`) guards against runaway lists and yields `KvError::LimitExceeded` when exceeded. ## Platform Specifics ### Local Development -- **Axum**: Uses a persistent `redb` embedded database stored under `.edgezero/`. The default store name uses `.edgezero/kv.redb`; custom store names get their own derived file. Data persists across restarts (add `.edgezero/` to your `.gitignore`). -- **Fastly (Viceroy)**: Requires a `[local_server.kv_stores]` entry in `fastly.toml`. +- **Axum**: Uses a persistent `redb` embedded database stored under `.edgezero/`. Each declared KV id gets its own derived file; data persists across restarts (add `.edgezero/` to your `.gitignore`). +- **Fastly (Viceroy)**: Requires a `[local_server.kv_stores]` and `[setup.kv_stores]` entry per declared KV id. `edgezero provision --adapter fastly` writes both blocks for you; the example below assumes a `sessions` id. ```toml - [[local_server.kv_stores.EDGEZERO_KV]] + [[local_server.kv_stores.sessions]] key = "__init__" data = "" - [setup.kv_stores.EDGEZERO_KV] + [setup.kv_stores.sessions] description = "Application KV store" ``` -- **Cloudflare (Workerd)**: Requires a KV namespace and a binding in `wrangler.toml`. - 1. Create the namespace (run once per environment): - - ```sh - wrangler kv namespace create EDGEZERO_KV - wrangler kv namespace create EDGEZERO_KV --preview - ``` + Override the platform name per environment via + `EDGEZERO__STORES__KV__SESSIONS__NAME=`; provision honours + the override when it writes the setup blocks. - Each command prints an `id` — copy them into `wrangler.toml`: +- **Cloudflare (Workerd)**: `edgezero provision --adapter cloudflare` creates the namespace and appends the `[[kv_namespaces]]` binding using the env-resolved platform name (`EDGEZERO__STORES__KV____NAME` or the logical id by default). The example below shows what provision writes for a `sessions` id with no override: - 2. Add the binding to `wrangler.toml`: - ```toml - [[kv_namespaces]] - binding = "EDGEZERO_KV" - id = "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" # from step 1 - preview_id = "yyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyy" # from step 1 --preview - ``` + ```toml + [[kv_namespaces]] + binding = "sessions" + id = "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" # filled by provision + ``` - The `binding` name MUST match the store name configured in `edgezero.toml` (default: `"EDGEZERO_KV"`). + The `binding` name MUST match what the runtime opens — by default the logical id, otherwise the env override. - **Spin**: Requires a `key_value_stores` label in `spin.toml`. @@ -131,17 +146,16 @@ Key listing is paginated by design. This avoids buffering an unbounded number of key_value_stores = ["default"] ``` - The label MUST match the store name configured in `edgezero.toml`, or the Spin-specific override. Spin's local runtime auto-provisions the `"default"` label; custom labels require a Spin runtime config or cloud link. + The label MUST match what `EDGEZERO__STORES__KV____NAME` resolves to (or the logical id when the variable is unset). Spin's local runtime auto-provisions the `"default"` label; custom labels require a Spin runtime config or cloud link. Example: ```toml [stores.kv] - name = "EDGEZERO_KV" - - [stores.kv.adapters.spin] - name = "default" + ids = ["sessions"] + # No platform name in the manifest — set EDGEZERO__STORES__KV__SESSIONS__NAME=default + # at run time (or leave unset to bind the label "sessions"). ``` - `edgezero_adapter_spin::run_app` reads `edgezero.toml` and opens the resolved Spin label. Low-level manual dispatch helpers do not read the manifest. + `edgezero_adapter_spin::run_app` reads baked `[stores.*]` metadata + `EDGEZERO__*` env vars and opens the resolved Spin label per id. Low-level manual dispatch helpers (`dispatch`, `dispatch_with_kv_label`) bypass the env-config path. ### Consistency @@ -149,7 +163,7 @@ Both Fastly and Cloudflare KV stores are **eventually consistent**. - A value written at one edge location may not be immediately visible at another. - `read_modify_write()` is **not atomic**. Concurrent updates to the same key may result in lost writes. -- **TTL**: `put_with_ttl` enforces a minimum of **60 seconds** and a maximum of **1 year** before delegating to an adapter. Spin KV does not support TTL, so the Spin adapter returns `KvError::Validation` without writing the value. +- **TTL**: `put_with_ttl` enforces a minimum of **60 seconds** and a maximum of **1 year** before delegating to an adapter. Spin KV does not support TTL, so the Spin adapter returns `KvError::Unsupported { operation: "put_bytes_with_ttl" }` without writing the value. ## Limits & Validation diff --git a/docs/guide/manifest-store-migration.md b/docs/guide/manifest-store-migration.md new file mode 100644 index 00000000..6ce065c0 --- /dev/null +++ b/docs/guide/manifest-store-migration.md @@ -0,0 +1,171 @@ +# Migrating to the portable store schema + +Stage 2 of the CLI-extensions work rewrites `edgezero.toml`'s +`[stores.*]` sections to a portable, non-adapter-specific shape and +moves all adapter-specific runtime knobs to `EDGEZERO__*` environment +variables. This page is referenced by the loader's hard-error message +when it encounters a pre-rewrite manifest; follow it to bring an old +manifest forward. + +## TL;DR + +```toml +# Before (any of these is now a hard load error) +[stores.kv] +name = "EDGEZERO_KV" # ← removed +[stores.kv.adapters.spin] # ← removed (whole subtable) +name = "EDGEZERO_KV" +[stores.config.defaults] # ← removed +greeting = "hello" + +# After +[stores.kv] +ids = ["sessions", "cache"] +default = "sessions" # required when ids.len() > 1 +[stores.config] +ids = ["app_config"] # default optional with a single id +[stores.secrets] +ids = ["default"] +``` + +Platform names, tuning, bind host/port, and logging level are read at +runtime from `EDGEZERO__*` environment variables. An adapter binary +runs with **zero env vars set** — each logical id is used as its own +platform name. + +## What changed and why + +`edgezero.toml` is now portable: it declares what the app _is_, not +how any particular platform runs it. The old per-adapter store and +runtime tables (`[stores.*.adapters.*]`, `[adapters..adapter] +host`, etc.) coupled the manifest to a specific deployment shape; +keeping them required the manifest to be recompiled every time you +moved between environments. + +The new shape lets one manifest cover dev, staging, and production for +the same workload. Per-environment differences (which Cloudflare KV +namespace ID maps to the `sessions` store, what host axum binds to, +what log level the worker uses) live in the environment, not the file. + +## Field-by-field + +### `[stores.]` + +| Old | New | +| ----------------------------------------- | ----------------------------------------------------------------------------------------- | +| `name = "EDGEZERO_KV"` | `ids = ["edgezero_kv"]` (or whatever logical id your code uses) | +| `enabled = true` | (gone — the kind is enabled by being declared at all) | +| `[stores..adapters.] name` | `EDGEZERO__STORES______NAME` env var at run time (`` is the upper-case id) | +| `[stores.config.defaults]` | (gone — the local axum config store now reads `.edgezero/local-config-.json` instead) | + +The portable manifest accepts only `ids` (non-empty) and `default` +(required when `ids.len() > 1`; with a single id it resolves to that +id automatically). Both are validated at load time. + +### Capability matrix + +Each (adapter, kind) pair is one of two capabilities (full table in +the spec, §6.6): + +| Adapter | KV | Config | Secrets | +| ---------- | ---------------- | -------------------- | ----------------------- | +| axum | Multi (local) | Multi (local files) | Single (env vars) | +| cloudflare | Multi (KV ns) | Multi (KV ns) | Single (worker secrets) | +| fastly | Multi (KV store) | Multi (config store) | Multi (secret store) | +| spin | Multi (KV label) | Multi (KV label) | Single (flat variables) | + +- **Multi**: each logical id resolves to its own platform store. +- **Single**: every logical id maps to the same flat store; per-id + `NAME` variables are ignored. Declaring more than one id for a + `Single` (adapter, kind) pair is caught by `config validate` (§10). + +### Runtime environment variables + +`__` (double underscore) separates segments. Absent variables fall +back to their listed defaults. + +| Variable | Role | Default | +| --------------------------------------- | ---------------------------------------------------------- | --------------- | +| `EDGEZERO__STORES______NAME` | platform name for logical store `` | the logical id | +| `EDGEZERO__STORES______` | free-form per-adapter tuning (e.g. spin's `MAX_LIST_KEYS`) | — | +| `EDGEZERO__ADAPTER__HOST` | bind host (axum) | `127.0.0.1` | +| `EDGEZERO__ADAPTER__PORT` | bind port (axum) | `8787` | +| `EDGEZERO__LOGGING__LEVEL` | log level | adapter default | + +`` ∈ `KV` / `CONFIG` / `SECRETS`; `` is the upper-case logical id. + +## What this means for handler code + +`Hooks::config_store()` is gone; the `app!` macro now bakes the +portable store registry into `Hooks::stores()` for all three kinds. + +The `Kv` / `Secrets` / `Config` extractors are id-keyed: + +```rust +#[action] +pub async fn handler(kv: Kv, secrets: Secrets) -> Result { + let sessions = kv.named("sessions") + .ok_or_else(|| EdgeError::service_unavailable("no `sessions` kv"))?; + let default_secrets = secrets.default() + .ok_or_else(|| EdgeError::service_unavailable("no default secrets"))?; + // … +} +``` + +`RequestContext` mirrors the same shape: +`ctx.kv_store(id)` / `ctx.kv_store_default()` (and the same for +`config_store` / `secret_store`). The pre-rewrite no-arg accessors +(`ctx.kv_handle()`, `ctx.config_handle()`, `ctx.secret_handle()`) +are **gone** — Stage 10.1 enforced the spec's "no backward +compatibility with the pre-rewrite runtime store API" promise. +Migrating handler code is mechanical: replace each +`ctx.kv_handle()` with `ctx.kv_store_default()`, +`ctx.config_handle()` with `ctx.config_store_default()`, and +`ctx.secret_handle()` with `ctx.secret_store_default()` (the +last one returns a `BoundSecretStore` whose `get_bytes(key)` is +single-arg — the platform store name is bound by the +dispatcher, not passed at the call site). + +Adapter setup code still has `with_*_handle` / +`dispatch_with_*_handle` convenience constructors that take a +single bare handle. Internally each dispatcher synthesises a +one-id `KvRegistry` / `ConfigRegistry` / `SecretRegistry` +under the conventional `"default"` id from that handle before +the request reaches the router — so the registry-aware +accessors and the `Kv` / `Config` / `Secrets` extractors +resolve uniformly regardless of which constructor wired the +store. + +## What about local config-store seeding? + +The pre-rewrite `[stores.config.defaults]` table seeded the axum +config store from the manifest. That table is gone. The axum config +store now reads `.edgezero/local-config-.json` (one file per +declared config id). Use the `edgezero config push --adapter axum` +command (spec §13, [CLI reference](./cli-reference#edgezero-config-push)) +to write that file from your typed `.toml` app-config — or +hand-edit the JSON directly when you just need a quick fixture for +local testing. + +## Cloudflare config store: `[vars]` → KV namespace + +The Cloudflare config store used to read one `[vars]` string binding +containing a JSON object. It now reads from a **KV namespace** binding +asynchronously. To migrate, replace each `[vars] app_config = '{ … }'` +entry with a KV namespace binding: + +```toml +# wrangler.toml — before +[vars] +app_config = '{"greeting":"hello","feature.new_checkout":"false"}' + +# wrangler.toml — after +[[kv_namespaces]] +binding = "app_config" +id = "abc123…" +``` + +Populate the namespace via `wrangler kv:key put`. The binding name +becomes the platform name resolved by +`EDGEZERO__STORES__CONFIG__APP_CONFIG__NAME` (with the default being +the literal id `app_config`). diff --git a/docs/guide/roadmap.md b/docs/guide/roadmap.md index e5ce1b8f..6b92ac1f 100644 --- a/docs/guide/roadmap.md +++ b/docs/guide/roadmap.md @@ -8,7 +8,9 @@ shift as the roadmap evolves. - Tooling parity: extend `edgezero-cli` with template/plugin style commands (similar to Spin templates) to streamline new app scaffolds and provider-specific wiring. - CLI parity backlog: add `edgezero --list-adapters`, standardize exit codes, search up for - `edgezero.toml`, respect `RUST_LOG` for dev output, and bake in hot reload for `edgezero dev`. + `edgezero.toml`, respect `RUST_LOG` for dev output, and bake in hot reload for + `edgezero serve --adapter axum` (the local dev path; the standalone `dev` subcommand was + reserved for a future dev-workflow command, see [CLI reference](./cli-reference#edgezero-demo)). - Adapter behavior matrix: document which adapters buffer bodies, which preserve streaming, and where proxy headers/automatic decompression apply so expectations match runtime behavior. - Example coverage: add focused guides for `axum.toml`, manifest `description` fields, logging diff --git a/docs/package-lock.json b/docs/package-lock.json index 9bd28d8d..1586e9ce 100644 --- a/docs/package-lock.json +++ b/docs/package-lock.json @@ -18,16 +18,16 @@ } }, "node_modules/@algolia/abtesting": { - "version": "1.16.2", - "resolved": "https://registry.npmjs.org/@algolia/abtesting/-/abtesting-1.16.2.tgz", - "integrity": "sha512-n9s6bEV6imdtIEd+BGP7WkA4pEZ5YTdgQ05JQhHwWawHg3hyjpNwC0TShGz6zWhv+jfLDGA/6FFNbySFS0P9cw==", + "version": "1.18.1", + "resolved": "https://registry.npmjs.org/@algolia/abtesting/-/abtesting-1.18.1.tgz", + "integrity": "sha512-aehCadlWOGvrT91KUIZpC0MbB8KBW9yUuvTJFd2xesR7le/IsT4nJUnjCCZ4ZqZCeTcPHPV5mo//fZ5oxcSVYw==", "dev": true, "license": "MIT", "dependencies": { - "@algolia/client-common": "5.50.2", - "@algolia/requester-browser-xhr": "5.50.2", - "@algolia/requester-fetch": "5.50.2", - "@algolia/requester-node-http": "5.50.2" + "@algolia/client-common": "5.52.1", + "@algolia/requester-browser-xhr": "5.52.1", + "@algolia/requester-fetch": "5.52.1", + "@algolia/requester-node-http": "5.52.1" }, "engines": { "node": ">= 14.0.0" @@ -83,41 +83,41 @@ } }, "node_modules/@algolia/client-abtesting": { - "version": "5.50.2", - "resolved": "https://registry.npmjs.org/@algolia/client-abtesting/-/client-abtesting-5.50.2.tgz", - "integrity": "sha512-52iq0vHy1sphgnwoZyx5PmbEt8hsh+m7jD123LmBs6qy4GK7LbYZIeKd+nSnSipN2zvKRZ2zScS6h9PW3J7SXg==", + "version": "5.52.1", + "resolved": "https://registry.npmjs.org/@algolia/client-abtesting/-/client-abtesting-5.52.1.tgz", + "integrity": "sha512-HmXOGBOAOJPounpBzBpuY0zDYeiCpxgHnQmuA7JO6ScukcBdGp3/XM9zJk5pJx/xNGD68mbPGXWpDxGtl6BwDQ==", "dev": true, "license": "MIT", "dependencies": { - "@algolia/client-common": "5.50.2", - "@algolia/requester-browser-xhr": "5.50.2", - "@algolia/requester-fetch": "5.50.2", - "@algolia/requester-node-http": "5.50.2" + "@algolia/client-common": "5.52.1", + "@algolia/requester-browser-xhr": "5.52.1", + "@algolia/requester-fetch": "5.52.1", + "@algolia/requester-node-http": "5.52.1" }, "engines": { "node": ">= 14.0.0" } }, "node_modules/@algolia/client-analytics": { - "version": "5.50.2", - "resolved": "https://registry.npmjs.org/@algolia/client-analytics/-/client-analytics-5.50.2.tgz", - "integrity": "sha512-WpPIUg+cSG2aPUG0gS8Ko9DwRgbRPUZxJkolhL2aCsmSlcEEZT65dILrfg5ovcxtx0Kvr+xtBVsTMtsQWRtPDQ==", + "version": "5.52.1", + "resolved": "https://registry.npmjs.org/@algolia/client-analytics/-/client-analytics-5.52.1.tgz", + "integrity": "sha512-5oo4+I8iixie9vXhCyNFCzeIr8pqA3FQ//VsLHTDvZAV4ttYOPGvYHGQq5NSalrLx5Jc3dRro/5uDOlnUMcBJg==", "dev": true, "license": "MIT", "dependencies": { - "@algolia/client-common": "5.50.2", - "@algolia/requester-browser-xhr": "5.50.2", - "@algolia/requester-fetch": "5.50.2", - "@algolia/requester-node-http": "5.50.2" + "@algolia/client-common": "5.52.1", + "@algolia/requester-browser-xhr": "5.52.1", + "@algolia/requester-fetch": "5.52.1", + "@algolia/requester-node-http": "5.52.1" }, "engines": { "node": ">= 14.0.0" } }, "node_modules/@algolia/client-common": { - "version": "5.50.2", - "resolved": "https://registry.npmjs.org/@algolia/client-common/-/client-common-5.50.2.tgz", - "integrity": "sha512-Gj2MgtArGcsr82kIqRlo6/dCAFjrs2gLByEqyRENuT7ugrSMFuqg1vDzeBjRL1t3EJEJCFtT0PLX3gB8A6Hq4Q==", + "version": "5.52.1", + "resolved": "https://registry.npmjs.org/@algolia/client-common/-/client-common-5.52.1.tgz", + "integrity": "sha512-qCDoZfx5MpX7XQzvQ3bC4tSEMkQWQMaF/ABtLuoze03Y/flR563CCSws02qIJ23oX7lxl92LsilZjINVyTdtLw==", "dev": true, "license": "MIT", "engines": { @@ -125,152 +125,152 @@ } }, "node_modules/@algolia/client-insights": { - "version": "5.50.2", - "resolved": "https://registry.npmjs.org/@algolia/client-insights/-/client-insights-5.50.2.tgz", - "integrity": "sha512-CUqoid5jDpmrc0oK3/xuZXFt6kwT0P9Lw7/nsM14YTr6puvmi+OUKmURpmebQF22S2vCG8L1DAoXXujxQUi/ug==", + "version": "5.52.1", + "resolved": "https://registry.npmjs.org/@algolia/client-insights/-/client-insights-5.52.1.tgz", + "integrity": "sha512-hnGs0/lsFJ2PWDxNBz7pxreXo/Xz7gxYRcfePBUjsH26ad0kU/sgnVZd9LwWBpsQv65z2jlb5dkyaB9WE9M9FQ==", "dev": true, "license": "MIT", "dependencies": { - "@algolia/client-common": "5.50.2", - "@algolia/requester-browser-xhr": "5.50.2", - "@algolia/requester-fetch": "5.50.2", - "@algolia/requester-node-http": "5.50.2" + "@algolia/client-common": "5.52.1", + "@algolia/requester-browser-xhr": "5.52.1", + "@algolia/requester-fetch": "5.52.1", + "@algolia/requester-node-http": "5.52.1" }, "engines": { "node": ">= 14.0.0" } }, "node_modules/@algolia/client-personalization": { - "version": "5.50.2", - "resolved": "https://registry.npmjs.org/@algolia/client-personalization/-/client-personalization-5.50.2.tgz", - "integrity": "sha512-AndZWFoc0gbP5901OeQJ73BazgGgSGiBEba4ohdoJuZwHTO2Gio8Q4L1VLmytMBYcviVigB0iICToMvEJxI4ug==", + "version": "5.52.1", + "resolved": "https://registry.npmjs.org/@algolia/client-personalization/-/client-personalization-5.52.1.tgz", + "integrity": "sha512-2VxxNc/uBysyKvGeBdSM5n9eIDKH8kWD7wd9/yqbJAiVwU4Yv6tU1LSJusHKrXV/aCu1KW7t9Gug9QyeEmtn/Q==", "dev": true, "license": "MIT", "dependencies": { - "@algolia/client-common": "5.50.2", - "@algolia/requester-browser-xhr": "5.50.2", - "@algolia/requester-fetch": "5.50.2", - "@algolia/requester-node-http": "5.50.2" + "@algolia/client-common": "5.52.1", + "@algolia/requester-browser-xhr": "5.52.1", + "@algolia/requester-fetch": "5.52.1", + "@algolia/requester-node-http": "5.52.1" }, "engines": { "node": ">= 14.0.0" } }, "node_modules/@algolia/client-query-suggestions": { - "version": "5.50.2", - "resolved": "https://registry.npmjs.org/@algolia/client-query-suggestions/-/client-query-suggestions-5.50.2.tgz", - "integrity": "sha512-NWoL+psEkz5dIzweaByVXuEB45wS8/rk0E0AhMMnaVJdVs7TcACPH2/OURm+N0xRDITkTHqCna823rd6Uqntdg==", + "version": "5.52.1", + "resolved": "https://registry.npmjs.org/@algolia/client-query-suggestions/-/client-query-suggestions-5.52.1.tgz", + "integrity": "sha512-O6mPtsw3xEfNOe6gWFpYLeAZAIljNa4Hgna3bq15PwyN7nbjTY0wXJFRbzs/0YVf75Br+SbOQUmjKxXYjDiSiQ==", "dev": true, "license": "MIT", "dependencies": { - "@algolia/client-common": "5.50.2", - "@algolia/requester-browser-xhr": "5.50.2", - "@algolia/requester-fetch": "5.50.2", - "@algolia/requester-node-http": "5.50.2" + "@algolia/client-common": "5.52.1", + "@algolia/requester-browser-xhr": "5.52.1", + "@algolia/requester-fetch": "5.52.1", + "@algolia/requester-node-http": "5.52.1" }, "engines": { "node": ">= 14.0.0" } }, "node_modules/@algolia/client-search": { - "version": "5.50.2", - "resolved": "https://registry.npmjs.org/@algolia/client-search/-/client-search-5.50.2.tgz", - "integrity": "sha512-ypSboUJ3XJoQz5DeDo82hCnrRuwq3q9ZdFhVKAik9TnZh1DvLqoQsrbBjXg7C7zQOtV/Qbge/HmyoV6V5L7MhQ==", + "version": "5.52.1", + "resolved": "https://registry.npmjs.org/@algolia/client-search/-/client-search-5.52.1.tgz", + "integrity": "sha512-gA8oJOV1LnQQkDf91iebNnFInHuW0gRPEgLSOQ7EfipCEjYTHm5swm1DlH9H5RaRw4RrHuzHBegnlzc0MAstcg==", "dev": true, "license": "MIT", "peer": true, "dependencies": { - "@algolia/client-common": "5.50.2", - "@algolia/requester-browser-xhr": "5.50.2", - "@algolia/requester-fetch": "5.50.2", - "@algolia/requester-node-http": "5.50.2" + "@algolia/client-common": "5.52.1", + "@algolia/requester-browser-xhr": "5.52.1", + "@algolia/requester-fetch": "5.52.1", + "@algolia/requester-node-http": "5.52.1" }, "engines": { "node": ">= 14.0.0" } }, "node_modules/@algolia/ingestion": { - "version": "1.50.2", - "resolved": "https://registry.npmjs.org/@algolia/ingestion/-/ingestion-1.50.2.tgz", - "integrity": "sha512-VlR2FRXLw2bCB94SQo6zxg/Qi+547aOji6Pb+dKE7h1DMCCY317St+OpjpmgzE+bT2O9ALIc0V4nVIBOd7Gy+Q==", + "version": "1.52.1", + "resolved": "https://registry.npmjs.org/@algolia/ingestion/-/ingestion-1.52.1.tgz", + "integrity": "sha512-U9zZfc5xIu9wRxZkt+HceJUAD4VKHKbAyLSloJdEyMRmphXeibfrY9cxqIXBcmPeZzGhn3Imb35Dq8l19PkJhw==", "dev": true, "license": "MIT", "dependencies": { - "@algolia/client-common": "5.50.2", - "@algolia/requester-browser-xhr": "5.50.2", - "@algolia/requester-fetch": "5.50.2", - "@algolia/requester-node-http": "5.50.2" + "@algolia/client-common": "5.52.1", + "@algolia/requester-browser-xhr": "5.52.1", + "@algolia/requester-fetch": "5.52.1", + "@algolia/requester-node-http": "5.52.1" }, "engines": { "node": ">= 14.0.0" } }, "node_modules/@algolia/monitoring": { - "version": "1.50.2", - "resolved": "https://registry.npmjs.org/@algolia/monitoring/-/monitoring-1.50.2.tgz", - "integrity": "sha512-Cmvfp2+qopzQt8OilU97rhLhosq7ZrB6uieok3EwFUqG/aalPg6DgfCmu0yJMrYe+KMC1qRVt1MTRAUwLknUMQ==", + "version": "1.52.1", + "resolved": "https://registry.npmjs.org/@algolia/monitoring/-/monitoring-1.52.1.tgz", + "integrity": "sha512-a3SGNceHmkQfq77iG8Ka+w1pvwfZa/0lzEIgse30fL0kD+yKnd/dg0dQvSfFPAEt2f21DMcGkDSSeJlO3KdQjQ==", "dev": true, "license": "MIT", "dependencies": { - "@algolia/client-common": "5.50.2", - "@algolia/requester-browser-xhr": "5.50.2", - "@algolia/requester-fetch": "5.50.2", - "@algolia/requester-node-http": "5.50.2" + "@algolia/client-common": "5.52.1", + "@algolia/requester-browser-xhr": "5.52.1", + "@algolia/requester-fetch": "5.52.1", + "@algolia/requester-node-http": "5.52.1" }, "engines": { "node": ">= 14.0.0" } }, "node_modules/@algolia/recommend": { - "version": "5.50.2", - "resolved": "https://registry.npmjs.org/@algolia/recommend/-/recommend-5.50.2.tgz", - "integrity": "sha512-jrkuyKoOM7dFWQ/6Y4hQAse2SC3L/RldG6GnPjMvAj65h+7Ubb51S0pKk4ofSStF0xm4LCNe0C4T6XX4nOFDiQ==", + "version": "5.52.1", + "resolved": "https://registry.npmjs.org/@algolia/recommend/-/recommend-5.52.1.tgz", + "integrity": "sha512-z98QEguCFDpxb4S/PyrUK1igqF8tPsdbqOUUO6ON91vJ58w+Gwa6ncrI0oNXSFcrkxA5EqPKPQ2A1PBCn08TYQ==", "dev": true, "license": "MIT", "dependencies": { - "@algolia/client-common": "5.50.2", - "@algolia/requester-browser-xhr": "5.50.2", - "@algolia/requester-fetch": "5.50.2", - "@algolia/requester-node-http": "5.50.2" + "@algolia/client-common": "5.52.1", + "@algolia/requester-browser-xhr": "5.52.1", + "@algolia/requester-fetch": "5.52.1", + "@algolia/requester-node-http": "5.52.1" }, "engines": { "node": ">= 14.0.0" } }, "node_modules/@algolia/requester-browser-xhr": { - "version": "5.50.2", - "resolved": "https://registry.npmjs.org/@algolia/requester-browser-xhr/-/requester-browser-xhr-5.50.2.tgz", - "integrity": "sha512-4107YLJqCudPiBUlwnk6oTSUVwU7ab+qL1SfQGEDYI8DZH5gsf1ekPt9JykXRKYXf2IfouFL5GiCY/PHTFIjYw==", + "version": "5.52.1", + "resolved": "https://registry.npmjs.org/@algolia/requester-browser-xhr/-/requester-browser-xhr-5.52.1.tgz", + "integrity": "sha512-CI7+/0I11QeZM59Uc8whd2or0kqzFVjpaPn9Qpwll/krHcBAxk24WkAQ6WX+IwDVMfpont4YGbKwAmCre3vE8Q==", "dev": true, "license": "MIT", "dependencies": { - "@algolia/client-common": "5.50.2" + "@algolia/client-common": "5.52.1" }, "engines": { "node": ">= 14.0.0" } }, "node_modules/@algolia/requester-fetch": { - "version": "5.50.2", - "resolved": "https://registry.npmjs.org/@algolia/requester-fetch/-/requester-fetch-5.50.2.tgz", - "integrity": "sha512-vOrd3MQpLgmf6wXAueTuZ/cA0W4uRwIHHaxNy3h+a6YcNn6bCV/gFdZuv3F13v593zRU2k5R75NmvRWLenvMrw==", + "version": "5.52.1", + "resolved": "https://registry.npmjs.org/@algolia/requester-fetch/-/requester-fetch-5.52.1.tgz", + "integrity": "sha512-S6bDuw9byfOvm3T71cgdoZgrgnZq6hpdMLkx52Louh57nUAmvGQESz2aojOynQHjbTiV55smvAFbgn0qT4tJrg==", "dev": true, "license": "MIT", "dependencies": { - "@algolia/client-common": "5.50.2" + "@algolia/client-common": "5.52.1" }, "engines": { "node": ">= 14.0.0" } }, "node_modules/@algolia/requester-node-http": { - "version": "5.50.2", - "resolved": "https://registry.npmjs.org/@algolia/requester-node-http/-/requester-node-http-5.50.2.tgz", - "integrity": "sha512-Mu9BFtgzGqDUy5Bcs2nMyoILIFSN13GKQaklKAFIsd0K3/9CpNyfeBc+/+Qs6mFZLlxG9qzullO7h+bjcTBuGQ==", + "version": "5.52.1", + "resolved": "https://registry.npmjs.org/@algolia/requester-node-http/-/requester-node-http-5.52.1.tgz", + "integrity": "sha512-tqZXM+54rWo4mk5jL5Z/flE11nPmNEdXwFBM5py9DkOmbjeCNemfVd45FyM97XdzfZ0dl9uOJC6PYn1FpkeyQg==", "dev": true, "license": "MIT", "dependencies": { - "@algolia/client-common": "5.50.2" + "@algolia/client-common": "5.52.1" }, "engines": { "node": ">= 14.0.0" @@ -297,9 +297,9 @@ } }, "node_modules/@babel/parser": { - "version": "7.29.2", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz", - "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==", + "version": "7.29.3", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.3.tgz", + "integrity": "sha512-b3ctpQwp+PROvU/cttc4OYl4MzfJUWy6FZg+PMXfzmt/+39iHVF0sDfqay8TQM3JA2EUOyKcFZt75jWriQijsA==", "dev": true, "license": "MIT", "dependencies": { @@ -826,9 +826,9 @@ } }, "node_modules/@eslint/config-helpers": { - "version": "0.5.5", - "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.5.5.tgz", - "integrity": "sha512-eIJYKTCECbP/nsKaaruF6LW967mtbQbsw4JTtSVkUQc9MneSkbrgPJAbKl9nWr0ZeowV8BfsarBmPpBzGelA2w==", + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.6.0.tgz", + "integrity": "sha512-ii6Bw9jJ2zi2cWA2Z+9/QZ/+3DX6kwaV5Q986D/CdP3Lap3w/pgQZ373FV7byY/i7L4IRH/G43I5dz1ClsCbpA==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -897,29 +897,43 @@ } }, "node_modules/@humanfs/core": { - "version": "0.19.1", - "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", - "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", + "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", "dev": true, "license": "Apache-2.0", + "dependencies": { + "@humanfs/types": "^0.15.0" + }, "engines": { "node": ">=18.18.0" } }, "node_modules/@humanfs/node": { - "version": "0.16.7", - "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.7.tgz", - "integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==", + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", + "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@humanfs/core": "^0.19.1", + "@humanfs/core": "^0.19.2", + "@humanfs/types": "^0.15.0", "@humanwhocodes/retry": "^0.4.0" }, "engines": { "node": ">=18.18.0" } }, + "node_modules/@humanfs/types": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", + "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, "node_modules/@humanwhocodes/module-importer": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", @@ -949,9 +963,9 @@ } }, "node_modules/@iconify-json/simple-icons": { - "version": "1.2.78", - "resolved": "https://registry.npmjs.org/@iconify-json/simple-icons/-/simple-icons-1.2.78.tgz", - "integrity": "sha512-I3lkNp0Qu7q2iZWkdcf/I2hqGhzK6qxdILh9T7XqowQrnpmG/BayDsiCf6PktDoWlW0U971xA5g+panm+NFrfQ==", + "version": "1.2.82", + "resolved": "https://registry.npmjs.org/@iconify-json/simple-icons/-/simple-icons-1.2.82.tgz", + "integrity": "sha512-4p978qHx8eD/QBOhgBzp/p7uS3OO2KCnVpFPJTUvuhuDXv1Hr4RcxcZ5MWc6ptkf/3Dlb1xb23068OtPyx10mA==", "dev": true, "license": "CC0-1.0", "dependencies": { @@ -973,9 +987,9 @@ "license": "MIT" }, "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.1.tgz", - "integrity": "sha512-d6FinEBLdIiK+1uACUttJKfgZREXrF0Qc2SmLII7W2AD8FfiZ9Wjd+rD/iRuf5s5dWrr1GgwXCvPqOuDquOowA==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.4.tgz", + "integrity": "sha512-F5QXMSiFebS9hKZj02XhWLLnRpJ3B3AROP0tWbFBSj+6kCbg5m9j5JoHKd4mmSVy5mS/IMQloYgYxCuJC0fxEQ==", "cpu": [ "arm" ], @@ -987,9 +1001,9 @@ ] }, "node_modules/@rollup/rollup-android-arm64": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.1.tgz", - "integrity": "sha512-YjG/EwIDvvYI1YvYbHvDz/BYHtkY4ygUIXHnTdLhG+hKIQFBiosfWiACWortsKPKU/+dUwQQCKQM3qrDe8c9BA==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.4.tgz", + "integrity": "sha512-GxxTKApUpzRhof7poWvCJHRF51C67u1R7D6DiluBE8wKU1u5GWE8t+v81JvJYtbawoBFX1hLv5Ei4eVjkWokaw==", "cpu": [ "arm64" ], @@ -1001,9 +1015,9 @@ ] }, "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.1.tgz", - "integrity": "sha512-mjCpF7GmkRtSJwon+Rq1N8+pI+8l7w5g9Z3vWj4T7abguC4Czwi3Yu/pFaLvA3TTeMVjnu3ctigusqWUfjZzvw==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.4.tgz", + "integrity": "sha512-tua0TaJxMOB1R0V0RS1jFZ/RpURFDJIOR2A6jWwQeawuFyS4gBW+rntLRaQd0EQ4bd6Vp44Z2rXW+YYDBsj6IA==", "cpu": [ "arm64" ], @@ -1015,9 +1029,9 @@ ] }, "node_modules/@rollup/rollup-darwin-x64": { - "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==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.4.tgz", + "integrity": "sha512-CSKq7MsP+5PFIcydhAiR1K0UhEI1A2jWXVKHPCBZ151yOutENwvnPocgVHkivu2kviURtCEB6zUQw0vs8RrhMg==", "cpu": [ "x64" ], @@ -1029,9 +1043,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.1.tgz", - "integrity": "sha512-czw90wpQq3ZsAVBlinZjAYTKduOjTywlG7fEeWKUA7oCmpA8xdTkxZZlwNJKWqILlq0wehoZcJYfBvOyhPTQ6w==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.4.tgz", + "integrity": "sha512-+O8OkVdyvXMtJEciu2wS/pzm1IxntEEQx3z5TAVy4l32G0etZn+RsA48ARRrFm6Ri8fvqPQfgrvNxSjKAbnd3g==", "cpu": [ "arm64" ], @@ -1043,9 +1057,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.1.tgz", - "integrity": "sha512-KVB2rqsxTHuBtfOeySEyzEOB7ltlB/ux38iu2rBQzkjbwRVlkhAGIEDiiYnO2kFOkJp+Z7pUXKyrRRFuFUKt+g==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.4.tgz", + "integrity": "sha512-Iw3oMskH3AfNuhU0MSN7vNbdi4me/NiYo2azqPz/Le16zHSa+3RRmliCMWWQmh4lcndccU40xcJuTYJZxNo/lw==", "cpu": [ "x64" ], @@ -1057,9 +1071,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.1.tgz", - "integrity": "sha512-L+34Qqil+v5uC0zEubW7uByo78WOCIrBvci69E7sFASRl0X7b/MB6Cqd1lky/CtcSVTydWa2WZwFuWexjS5o6g==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.4.tgz", + "integrity": "sha512-EIPRXTVQpHyF8WOo219AD2yEltPehLTcTMz2fn6JsatLYSzQf00hj3rulF+yauOlF9/FtM2WpkT/hJh/KJFGhA==", "cpu": [ "arm" ], @@ -1071,9 +1085,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.1.tgz", - "integrity": "sha512-n83O8rt4v34hgFzlkb1ycniJh7IR5RCIqt6mz1VRJD6pmhRi0CXdmfnLu9dIUS6buzh60IvACM842Ffb3xd6Gg==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.4.tgz", + "integrity": "sha512-J3Yh9PzzF1Ovah2At+lHiGQdsYgArxBbXv/zHfSyaiFQEqvNv7DcW98pCrmdjCZBrqBiKrKKe2V+aaSGWuBe/w==", "cpu": [ "arm" ], @@ -1085,9 +1099,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.1.tgz", - "integrity": "sha512-Nql7sTeAzhTAja3QXeAI48+/+GjBJ+QmAH13snn0AJSNL50JsDqotyudHyMbO2RbJkskbMbFJfIJKWA6R1LCJQ==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.4.tgz", + "integrity": "sha512-BFDEZMYfUvLn37ONE1yMBojPxnMlTFsdyNoqncT0qFq1mAfllL+ATMMJd8TeuVMiX84s1KbcxcZbXInmcO2mRg==", "cpu": [ "arm64" ], @@ -1099,9 +1113,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.1.tgz", - "integrity": "sha512-+pUymDhd0ys9GcKZPPWlFiZ67sTWV5UU6zOJat02M1+PiuSGDziyRuI/pPue3hoUwm2uGfxdL+trT6Z9rxnlMA==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.4.tgz", + "integrity": "sha512-pc9EYOSlOgdQ2uPl1o9PF6/kLSgaUosia7gOuS8mB69IxJvlclko1MECXysjs5ryez1/5zjYqx3+xYU0TU6R1A==", "cpu": [ "arm64" ], @@ -1113,9 +1127,9 @@ ] }, "node_modules/@rollup/rollup-linux-loong64-gnu": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.1.tgz", - "integrity": "sha512-VSvgvQeIcsEvY4bKDHEDWcpW4Yw7BtlKG1GUT4FzBUlEKQK0rWHYBqQt6Fm2taXS+1bXvJT6kICu5ZwqKCnvlQ==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.4.tgz", + "integrity": "sha512-NxnomyxYerDh5n4iLrNa+sH+Z+U4BMEE46V2PgQ/hoB909i8gV1M5wPojWg9fk1jWpO3IQnOs20K4wyZuFLEFQ==", "cpu": [ "loong64" ], @@ -1127,9 +1141,9 @@ ] }, "node_modules/@rollup/rollup-linux-loong64-musl": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.1.tgz", - "integrity": "sha512-4LqhUomJqwe641gsPp6xLfhqWMbQV04KtPp7/dIp0nzPxAkNY1AbwL5W0MQpcalLYk07vaW9Kp1PBhdpZYYcEw==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.4.tgz", + "integrity": "sha512-nbJnQ8a3z1mtmrwImCYhc6BGpThAyYVRQxw9uKSKG4wR6aAYno9sVjJ0zaZcW9BPJX1GbrDPf+SvdWjgTuDmnw==", "cpu": [ "loong64" ], @@ -1141,9 +1155,9 @@ ] }, "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.1.tgz", - "integrity": "sha512-tLQQ9aPvkBxOc/EUT6j3pyeMD6Hb8QF2BTBnCQWP/uu1lhc9AIrIjKnLYMEroIz/JvtGYgI9dF3AxHZNaEH0rw==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.4.tgz", + "integrity": "sha512-2EU6acNrQLd8tYvo/LXW535wupT3m6fo7HKo6lr7ktQoItxTyOL1ZCR/GfGCuXl2vR+zmfI6eRXkSemafv+iVg==", "cpu": [ "ppc64" ], @@ -1155,9 +1169,9 @@ ] }, "node_modules/@rollup/rollup-linux-ppc64-musl": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.1.tgz", - "integrity": "sha512-RMxFhJwc9fSXP6PqmAz4cbv3kAyvD1etJFjTx4ONqFP9DkTkXsAMU4v3Vyc5BgzC+anz7nS/9tp4obsKfqkDHg==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.4.tgz", + "integrity": "sha512-WeBtoMuaMxiiIrO2IYP3xs6GMWkJP2C0EoT8beTLkUPmzV1i/UcOSVw1d5r9KBODtHKilG5yFxsGRnBbK3wJ4A==", "cpu": [ "ppc64" ], @@ -1169,9 +1183,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.1.tgz", - "integrity": "sha512-QKgFl+Yc1eEk6MmOBfRHYF6lTxiiiV3/z/BRrbSiW2I7AFTXoBFvdMEyglohPj//2mZS4hDOqeB0H1ACh3sBbg==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.4.tgz", + "integrity": "sha512-FJHFfqpKUI3A10WrWKiFbBZ7yVbGT4q4B5o1qKFFojqpaYoh9LrQgqWCmmcxQzVSXYtyB5bzkXrYzlHTs21MYA==", "cpu": [ "riscv64" ], @@ -1183,9 +1197,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.1.tgz", - "integrity": "sha512-RAjXjP/8c6ZtzatZcA1RaQr6O1TRhzC+adn8YZDnChliZHviqIjmvFwHcxi4JKPSDAt6Uhf/7vqcBzQJy0PDJg==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.4.tgz", + "integrity": "sha512-mcEl6CUT5IAUmQf1m9FYSmVqCJlpQ8r8eyftFUHG8i9OhY7BkBXSUdnLH5DOf0wCOjcP9v/QO93zpmF1SptCCw==", "cpu": [ "riscv64" ], @@ -1197,9 +1211,9 @@ ] }, "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.1.tgz", - "integrity": "sha512-wcuocpaOlaL1COBYiA89O6yfjlp3RwKDeTIA0hM7OpmhR1Bjo9j31G1uQVpDlTvwxGn2nQs65fBFL5UFd76FcQ==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.4.tgz", + "integrity": "sha512-ynt3JxVd2w2buzoKDWIyiV1pJW93xlQic1THVLXilz429oijRpSHivZAgp65KBu+cMcgf1eVVjdnTLvPxgCuoQ==", "cpu": [ "s390x" ], @@ -1211,9 +1225,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.1.tgz", - "integrity": "sha512-77PpsFQUCOiZR9+LQEFg9GClyfkNXj1MP6wRnzYs0EeWbPcHs02AXu4xuUbM1zhwn3wqaizle3AEYg5aeoohhg==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.4.tgz", + "integrity": "sha512-Boiz5+MsaROEWDf+GGEwF8VMHGhlUoQMtIPjOgA5fv4osupqTVnJteQNKJwUcnUog2G55jYXH7KZFFiJe0TEzQ==", "cpu": [ "x64" ], @@ -1225,9 +1239,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.1.tgz", - "integrity": "sha512-5cIATbk5vynAjqqmyBjlciMJl1+R/CwX9oLk/EyiFXDWd95KpHdrOJT//rnUl4cUcskrd0jCCw3wpZnhIHdD9w==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.4.tgz", + "integrity": "sha512-+qfSY27qIrFfI/Hom04KYFw3GKZSGU4lXus51wsb5EuySfFlWRwjkKWoE9emgRw/ukoT4Udsj4W/+xxG8VbPKg==", "cpu": [ "x64" ], @@ -1239,9 +1253,9 @@ ] }, "node_modules/@rollup/rollup-openbsd-x64": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.1.tgz", - "integrity": "sha512-cl0w09WsCi17mcmWqqglez9Gk8isgeWvoUZ3WiJFYSR3zjBQc2J5/ihSjpl+VLjPqjQ/1hJRcqBfLjssREQILw==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.4.tgz", + "integrity": "sha512-VpTfOPHgVXEBeeR8hZ2O0F3aSso+JDWqTWmTmzcQKted54IAdUVbxE+j/MVxUsKa8L20HJhv3vUezVPoquqWjA==", "cpu": [ "x64" ], @@ -1253,9 +1267,9 @@ ] }, "node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.1.tgz", - "integrity": "sha512-4Cv23ZrONRbNtbZa37mLSueXUCtN7MXccChtKpUnQNgF010rjrjfHx3QxkS2PI7LqGT5xXyYs1a7LbzAwT0iCA==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.4.tgz", + "integrity": "sha512-IPOsh5aRYuLv/nkU51X10Bf75Bsf6+gZdx1X+QP5QM6lIJFHHqbHLG0uJn/hWthzo13UAc2umiUorqZy3axoZg==", "cpu": [ "arm64" ], @@ -1267,9 +1281,9 @@ ] }, "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.1.tgz", - "integrity": "sha512-i1okWYkA4FJICtr7KpYzFpRTHgy5jdDbZiWfvny21iIKky5YExiDXP+zbXzm3dUcFpkEeYNHgQ5fuG236JPq0g==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.4.tgz", + "integrity": "sha512-4QzE9E81OohJ/HKzHhsqU+zcYYojVOXlFMs1DdyMT6qXl/niOH7AVElmmEdUNHHS/oRkc++d5k6Vy85zFs0DEw==", "cpu": [ "arm64" ], @@ -1281,9 +1295,9 @@ ] }, "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.1.tgz", - "integrity": "sha512-u09m3CuwLzShA0EYKMNiFgcjjzwqtUMLmuCJLeZWjjOYA3IT2Di09KaxGBTP9xVztWyIWjVdsB2E9goMjZvTQg==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.4.tgz", + "integrity": "sha512-zTPgT1YuHHcd+Tmx7h8aml0FWFVelV5N54oHow9SLj+GfoDy/huQ+UV396N/C7KpMDMiPspRktzM1/0r1usYEA==", "cpu": [ "ia32" ], @@ -1295,9 +1309,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-gnu": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.1.tgz", - "integrity": "sha512-k+600V9Zl1CM7eZxJgMyTUzmrmhB/0XZnF4pRypKAlAgxmedUA+1v9R+XOFv56W4SlHEzfeMtzujLJD22Uz5zg==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.4.tgz", + "integrity": "sha512-DRS4G7mi9lJxqEDezIkKCaUIKCrLUUDCUaCsTPCi/rtqaC6D/jjwslMQyiDU50Ka0JKpeXeRBFBAXwArY52vBw==", "cpu": [ "x64" ], @@ -1309,9 +1323,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.1.tgz", - "integrity": "sha512-lWMnixq/QzxyhTV6NjQJ4SFo1J6PvOX8vUx5Wb4bBPsEb+8xZ89Bz6kOXpfXj9ak9AHTQVQzlgzBEc1SyM27xQ==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.4.tgz", + "integrity": "sha512-QVTUovf40zgTqlFVrKA1uXMVvU2QWEFWfAH8Wdc48IxLvrJMQVMBRjuQyUpzZCDkakImib9eVazbWlC6ksWtJw==", "cpu": [ "x64" ], @@ -1417,9 +1431,9 @@ "license": "MIT" }, "node_modules/@types/estree": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", - "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", "dev": true, "license": "MIT" }, @@ -1476,9 +1490,9 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "24.12.2", - "resolved": "https://registry.npmjs.org/@types/node/-/node-24.12.2.tgz", - "integrity": "sha512-A1sre26ke7HDIuY/M23nd9gfB+nrmhtYyMINbjI1zHJxYteKR6qSMX56FsmjMcDb3SMcjJg5BiRRgOCC/yBD0g==", + "version": "24.12.4", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.12.4.tgz", + "integrity": "sha512-GUUEShf+PBCGW2KaXwcIt3Yk+e3pkKwWKb9GSyM9WQVE+ep2jzmHdGsHzu4wgcZy5fN9FBdVzjpBQsYlpfpgLA==", "dev": true, "license": "MIT", "peer": true, @@ -1501,17 +1515,17 @@ "license": "MIT" }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.58.2", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.58.2.tgz", - "integrity": "sha512-aC2qc5thQahutKjP+cl8cgN9DWe3ZUqVko30CMSZHnFEHyhOYoZSzkGtAI2mcwZ38xeImDucI4dnqsHiOYuuCw==", + "version": "8.59.4", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.59.4.tgz", + "integrity": "sha512-PegsU+XfyJJNjd4+u/k6f9yTyp0lEXXiPopUNobZcIAUJFGICFLN+sP0Rb3JehVmiij1Ph0dFGYqODoRo/2+6A==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/regexpp": "^4.12.2", - "@typescript-eslint/scope-manager": "8.58.2", - "@typescript-eslint/type-utils": "8.58.2", - "@typescript-eslint/utils": "8.58.2", - "@typescript-eslint/visitor-keys": "8.58.2", + "@typescript-eslint/scope-manager": "8.59.4", + "@typescript-eslint/type-utils": "8.59.4", + "@typescript-eslint/utils": "8.59.4", + "@typescript-eslint/visitor-keys": "8.59.4", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.5.0" @@ -1524,7 +1538,7 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "@typescript-eslint/parser": "^8.58.2", + "@typescript-eslint/parser": "^8.59.4", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } @@ -1540,17 +1554,17 @@ } }, "node_modules/@typescript-eslint/parser": { - "version": "8.58.2", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.58.2.tgz", - "integrity": "sha512-/Zb/xaIDfxeJnvishjGdcR4jmr7S+bda8PKNhRGdljDM+elXhlvN0FyPSsMnLmJUrVG9aPO6dof80wjMawsASg==", + "version": "8.59.4", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.59.4.tgz", + "integrity": "sha512-zORHqO/tuhxY1zWuTvMUqddRxpiFJ72xVfcNoWpqdLjs6lfPbuQBJuW4pk+49/uBMy7Ssr4bzgjiKmmDB1UbZQ==", "dev": true, "license": "MIT", "peer": true, "dependencies": { - "@typescript-eslint/scope-manager": "8.58.2", - "@typescript-eslint/types": "8.58.2", - "@typescript-eslint/typescript-estree": "8.58.2", - "@typescript-eslint/visitor-keys": "8.58.2", + "@typescript-eslint/scope-manager": "8.59.4", + "@typescript-eslint/types": "8.59.4", + "@typescript-eslint/typescript-estree": "8.59.4", + "@typescript-eslint/visitor-keys": "8.59.4", "debug": "^4.4.3" }, "engines": { @@ -1566,14 +1580,14 @@ } }, "node_modules/@typescript-eslint/project-service": { - "version": "8.58.2", - "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.58.2.tgz", - "integrity": "sha512-Cq6UfpZZk15+r87BkIh5rDpi38W4b+Sjnb8wQCPPDDweS/LRCFjCyViEbzHk5Ck3f2QDfgmlxqSa7S7clDtlfg==", + "version": "8.59.4", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.59.4.tgz", + "integrity": "sha512-Ly00Vu4oAacfDeHp2Zg85ioNG6l8HG+tN1D7J+xTHSxu9y0awYKJ2zH1rFBn8ZSfuGK+7FxK3Cgl3uAz0aZZLg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.58.2", - "@typescript-eslint/types": "^8.58.2", + "@typescript-eslint/tsconfig-utils": "^8.59.4", + "@typescript-eslint/types": "^8.59.4", "debug": "^4.4.3" }, "engines": { @@ -1588,14 +1602,14 @@ } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "8.58.2", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.58.2.tgz", - "integrity": "sha512-SgmyvDPexWETQek+qzZnrG6844IaO02UVyOLhI4wpo82dpZJY9+6YZCKAMFzXb7qhx37mFK1QcPQ18tud+vo6Q==", + "version": "8.59.4", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.59.4.tgz", + "integrity": "sha512-mUeR/3H1WrTAddJrwut8OoPjfauaztMQmRwV5fQTUyNVJCLiUXXe4lGEyYIL2oFDpP7UtgbGJXCt72wT0z2S3Q==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.58.2", - "@typescript-eslint/visitor-keys": "8.58.2" + "@typescript-eslint/types": "8.59.4", + "@typescript-eslint/visitor-keys": "8.59.4" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1606,9 +1620,9 @@ } }, "node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.58.2", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.58.2.tgz", - "integrity": "sha512-3SR+RukipDvkkKp/d0jP0dyzuls3DbGmwDpVEc5wqk5f38KFThakqAAO0XMirWAE+kT00oTauTbzMFGPoAzB0A==", + "version": "8.59.4", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.59.4.tgz", + "integrity": "sha512-DLCpnKgD4alVxTBSKulK+gU1KCqOgUXfDRDXh2mZgzokQKa/70ax93I2uVO3m/LLvIAtWZIFoiifudmIqAxpMA==", "dev": true, "license": "MIT", "engines": { @@ -1623,15 +1637,15 @@ } }, "node_modules/@typescript-eslint/type-utils": { - "version": "8.58.2", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.58.2.tgz", - "integrity": "sha512-Z7EloNR/B389FvabdGeTo2XMs4W9TjtPiO9DAsmT0yom0bwlPyRjkJ1uCdW1DvrrrYP50AJZ9Xc3sByZA9+dcg==", + "version": "8.59.4", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.59.4.tgz", + "integrity": "sha512-uonTuPAAKr9XaBGqJ3LjYTh72zy5DyGesljO9gtmk/eFW0W1fRHjnwVYKB35Lm8d5Q5CluEW3gPHjTvZTmgrfA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.58.2", - "@typescript-eslint/typescript-estree": "8.58.2", - "@typescript-eslint/utils": "8.58.2", + "@typescript-eslint/types": "8.59.4", + "@typescript-eslint/typescript-estree": "8.59.4", + "@typescript-eslint/utils": "8.59.4", "debug": "^4.4.3", "ts-api-utils": "^2.5.0" }, @@ -1648,9 +1662,9 @@ } }, "node_modules/@typescript-eslint/types": { - "version": "8.58.2", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.58.2.tgz", - "integrity": "sha512-9TukXyATBQf/Jq9AMQXfvurk+G5R2MwfqQGDR2GzGz28HvY/lXNKGhkY+6IOubwcquikWk5cjlgPvD2uAA7htQ==", + "version": "8.59.4", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.59.4.tgz", + "integrity": "sha512-F1o7WJcCq+bc8dwcO/YsSEOudAH8RDtaOhM6wcAQhcUsFhnWQl81JKy48q1hoxAU0qrzM89+31GYh1515Zde3Q==", "dev": true, "license": "MIT", "engines": { @@ -1662,16 +1676,16 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.58.2", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.58.2.tgz", - "integrity": "sha512-ELGuoofuhhoCvNbQjFFiobFcGgcDCEm0ThWdmO4Z0UzLqPXS3KFvnEZ+SHewwOYHjM09tkzOWXNTv9u6Gqtyuw==", + "version": "8.59.4", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.59.4.tgz", + "integrity": "sha512-F+RuOmcDXo4+TPdfd/TCLS3m2nw8gE9XXyZLrA3JBfaA5tz9TtdkyD3YJFmPxulyc2cKbEok/CvFE3MgSLWnag==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/project-service": "8.58.2", - "@typescript-eslint/tsconfig-utils": "8.58.2", - "@typescript-eslint/types": "8.58.2", - "@typescript-eslint/visitor-keys": "8.58.2", + "@typescript-eslint/project-service": "8.59.4", + "@typescript-eslint/tsconfig-utils": "8.59.4", + "@typescript-eslint/types": "8.59.4", + "@typescript-eslint/visitor-keys": "8.59.4", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", @@ -1690,16 +1704,16 @@ } }, "node_modules/@typescript-eslint/utils": { - "version": "8.58.2", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.58.2.tgz", - "integrity": "sha512-QZfjHNEzPY8+l0+fIXMvuQ2sJlplB4zgDZvA+NmvZsZv3EQwOcc1DuIU1VJUTWZ/RKouBMhDyNaBMx4sWvrzRA==", + "version": "8.59.4", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.59.4.tgz", + "integrity": "sha512-cYXeNAUsG4lJo5dbc1FcKm+JwIWrj1/UpTORsC6tGMjEZ81DYcvIr9/ueikhMa/Y/gDQYGp+YX9/xQrXje5BJw==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", - "@typescript-eslint/scope-manager": "8.58.2", - "@typescript-eslint/types": "8.58.2", - "@typescript-eslint/typescript-estree": "8.58.2" + "@typescript-eslint/scope-manager": "8.59.4", + "@typescript-eslint/types": "8.59.4", + "@typescript-eslint/typescript-estree": "8.59.4" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1714,13 +1728,13 @@ } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.58.2", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.58.2.tgz", - "integrity": "sha512-f1WO2Lx8a9t8DARmcWAUPJbu0G20bJlj8L4z72K00TMeJAoyLr/tHhI/pzYBLrR4dXWkcxO1cWYZEOX8DKHTqA==", + "version": "8.59.4", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.59.4.tgz", + "integrity": "sha512-U3gxVaDVnuZKhSspW/MzMxE1kq7zOdc072FcSNoqA1I9p8HyKbBFfEHoWckBAMgNMph4MamwS5iTVzFmrnt8TQ==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.58.2", + "@typescript-eslint/types": "8.59.4", "eslint-visitor-keys": "^5.0.0" }, "engines": { @@ -1732,9 +1746,9 @@ } }, "node_modules/@ungap/structured-clone": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz", - "integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==", + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.1.tgz", + "integrity": "sha512-mUFwbeTqrVgDQxFveS+df2yfap6iuP20NAKAsBt5jDEoOTDew+zwLAOilHCeQJOVSvmgCX4ogqIrA0mnyr08yQ==", "dev": true, "license": "ISC" }, @@ -1753,57 +1767,57 @@ } }, "node_modules/@vue/compiler-core": { - "version": "3.5.32", - "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.32.tgz", - "integrity": "sha512-4x74Tbtqnda8s/NSD6e1Dr5p1c8HdMU5RWSjMSUzb8RTcUQqevDCxVAitcLBKT+ie3o0Dl9crc/S/opJM7qBGQ==", + "version": "3.5.34", + "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.34.tgz", + "integrity": "sha512-s9cLyK5mLcvZ4Agva5QgRsQyLKvts9WbU9DB6NqiZkkGEdwmcEiylj5Jbwkp680drF/NNCV8OlAJSe+yMLxaJw==", "dev": true, "license": "MIT", "dependencies": { - "@babel/parser": "^7.29.2", - "@vue/shared": "3.5.32", + "@babel/parser": "^7.29.3", + "@vue/shared": "3.5.34", "entities": "^7.0.1", "estree-walker": "^2.0.2", "source-map-js": "^1.2.1" } }, "node_modules/@vue/compiler-dom": { - "version": "3.5.32", - "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.32.tgz", - "integrity": "sha512-ybHAu70NtiEI1fvAUz3oXZqkUYEe5J98GjMDpTGl5iHb0T15wQYLR4wE3h9xfuTNA+Cm2f4czfe8B4s+CCH57Q==", + "version": "3.5.34", + "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.34.tgz", + "integrity": "sha512-EbF/T++k0e2MMZlJsBhzK8Sgwt0HcIPOhzn1CTB/lv6sQcyk+OWf8YeiLxZp3ro7MbbLcAfAJ6sEvjFWuNgUCw==", "dev": true, "license": "MIT", "dependencies": { - "@vue/compiler-core": "3.5.32", - "@vue/shared": "3.5.32" + "@vue/compiler-core": "3.5.34", + "@vue/shared": "3.5.34" } }, "node_modules/@vue/compiler-sfc": { - "version": "3.5.32", - "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.32.tgz", - "integrity": "sha512-8UYUYo71cP/0YHMO814TRZlPuUUw3oifHuMR7Wp9SNoRSrxRQnhMLNlCeaODNn6kNTJsjFoQ/kqIj4qGvya4Xg==", + "version": "3.5.34", + "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.34.tgz", + "integrity": "sha512-D/ihr6uZeIt6r+pVZf46RWT1fAsLFMbUP7k8G1VkiiWexriED9GrX3echHd4Abbt17zjlfiFJ8z7a3BxZOPNjg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/parser": "^7.29.2", - "@vue/compiler-core": "3.5.32", - "@vue/compiler-dom": "3.5.32", - "@vue/compiler-ssr": "3.5.32", - "@vue/shared": "3.5.32", + "@babel/parser": "^7.29.3", + "@vue/compiler-core": "3.5.34", + "@vue/compiler-dom": "3.5.34", + "@vue/compiler-ssr": "3.5.34", + "@vue/shared": "3.5.34", "estree-walker": "^2.0.2", "magic-string": "^0.30.21", - "postcss": "^8.5.8", + "postcss": "^8.5.14", "source-map-js": "^1.2.1" } }, "node_modules/@vue/compiler-ssr": { - "version": "3.5.32", - "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.32.tgz", - "integrity": "sha512-Gp4gTs22T3DgRotZ8aA/6m2jMR+GMztvBXUBEUOYOcST+giyGWJ4WvFd7QLHBkzTxkfOt8IELKNdpzITLbA2rw==", + "version": "3.5.34", + "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.34.tgz", + "integrity": "sha512-cDtTHKibkThKGHH1SP+WdccquNRYQDFH6rRjQCqT9G2ltFAfoR5pUftpab/z+aM5mW9HLLVQW7hfKKQe/1GBeQ==", "dev": true, "license": "MIT", "dependencies": { - "@vue/compiler-dom": "3.5.32", - "@vue/shared": "3.5.32" + "@vue/compiler-dom": "3.5.34", + "@vue/shared": "3.5.34" } }, "node_modules/@vue/devtools-api": { @@ -1843,57 +1857,57 @@ } }, "node_modules/@vue/reactivity": { - "version": "3.5.32", - "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.32.tgz", - "integrity": "sha512-/ORasxSGvZ6MN5gc+uE364SxFdJ0+WqVG0CENXaGW58TOCdrAW76WWaplDtECeS1qphvtBZtR+3/o1g1zL4xPQ==", + "version": "3.5.34", + "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.34.tgz", + "integrity": "sha512-y9XDjCEuBp+98k+UL5dbYkh57AHU4o6cxZedOPXw3bmrZZYLQsVHguGurq7hVrPCSrQtrnz1f9dssyFr+dMXfQ==", "dev": true, "license": "MIT", "dependencies": { - "@vue/shared": "3.5.32" + "@vue/shared": "3.5.34" } }, "node_modules/@vue/runtime-core": { - "version": "3.5.32", - "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.32.tgz", - "integrity": "sha512-pDrXCejn4UpFDFmMd27AcJEbHaLemaE5o4pbb7sLk79SRIhc6/t34BQA7SGNgYtbMnvbF/HHOftYBgFJtUoJUQ==", + "version": "3.5.34", + "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.34.tgz", + "integrity": "sha512-mKeBYvu8tcMSLhypAHBmriUFfWXKTCF/23Z4jiCoYK3UtWepkliViNLuR90V9XOyD62mUxs9p1jsrpK3CCGIzw==", "dev": true, "license": "MIT", "dependencies": { - "@vue/reactivity": "3.5.32", - "@vue/shared": "3.5.32" + "@vue/reactivity": "3.5.34", + "@vue/shared": "3.5.34" } }, "node_modules/@vue/runtime-dom": { - "version": "3.5.32", - "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.32.tgz", - "integrity": "sha512-1CDVv7tv/IV13V8Nip1k/aaObVbWqRlVCVezTwx3K07p7Vxossp5JU1dcPNhJk3w347gonIUT9jQOGutyJrSVQ==", + "version": "3.5.34", + "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.34.tgz", + "integrity": "sha512-e8kZzERmCwUnBRVsgSQlAfrfU2rGoy0FFKPBXSlfEjc/O3KfA7QP0t1/2ZylrbchjmIKB4dPTd07A6WPr0eOrg==", "dev": true, "license": "MIT", "dependencies": { - "@vue/reactivity": "3.5.32", - "@vue/runtime-core": "3.5.32", - "@vue/shared": "3.5.32", + "@vue/reactivity": "3.5.34", + "@vue/runtime-core": "3.5.34", + "@vue/shared": "3.5.34", "csstype": "^3.2.3" } }, "node_modules/@vue/server-renderer": { - "version": "3.5.32", - "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.32.tgz", - "integrity": "sha512-IOjm2+JQwRFS7W28HNuJeXQle9KdZbODFY7hFGVtnnghF51ta20EWAZJHX+zLGtsHhaU6uC9BGPV52KVpYryMQ==", + "version": "3.5.34", + "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.34.tgz", + "integrity": "sha512-nHxmJoTrKsmrkbILRhkC9gY1G3moZbJTqCzDd7DOOzG5KH9oeJ0Unqrff5f9v0pW//jES05ZkJcNtfE8JjOIew==", "dev": true, "license": "MIT", "dependencies": { - "@vue/compiler-ssr": "3.5.32", - "@vue/shared": "3.5.32" + "@vue/compiler-ssr": "3.5.34", + "@vue/shared": "3.5.34" }, "peerDependencies": { - "vue": "3.5.32" + "vue": "3.5.34" } }, "node_modules/@vue/shared": { - "version": "3.5.32", - "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.32.tgz", - "integrity": "sha512-ksNyrmRQzWJJ8n3cRDuSF7zNNontuJg1YHnmWRJd2AMu8Ij2bqwiiri2lH5rHtYPZjj4STkNcgcmiQqlOjiYGg==", + "version": "3.5.34", + "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.34.tgz", + "integrity": "sha512-24uqU4OIiX29ryC3MeWid/Xf2fa2EFRUVLb77nRhk+UrTVrh/XiGtFAFmJBAtBRbjwNdsPRP+jj/OL27Eg1NDA==", "dev": true, "license": "MIT" }, @@ -2028,9 +2042,9 @@ } }, "node_modules/ajv": { - "version": "6.14.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", - "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", "dev": true, "license": "MIT", "dependencies": { @@ -2045,27 +2059,27 @@ } }, "node_modules/algoliasearch": { - "version": "5.50.2", - "resolved": "https://registry.npmjs.org/algoliasearch/-/algoliasearch-5.50.2.tgz", - "integrity": "sha512-Tfp26yoNWurUjfgK4GOrVJQhSNXu9tJtHfFFNosgT2YClG+vPyUjX/gbC8rG39qLncnZg8Fj34iarQWpMkqefw==", + "version": "5.52.1", + "resolved": "https://registry.npmjs.org/algoliasearch/-/algoliasearch-5.52.1.tgz", + "integrity": "sha512-fHA8+kXTbjagw3jkLiaS7KKrH8qe2DyOsiUhGlN4cdT77PEsfqXZl7ewDk1hsg+pJnPlnE50XtLxjR91iJOpmg==", "dev": true, "license": "MIT", "peer": true, "dependencies": { - "@algolia/abtesting": "1.16.2", - "@algolia/client-abtesting": "5.50.2", - "@algolia/client-analytics": "5.50.2", - "@algolia/client-common": "5.50.2", - "@algolia/client-insights": "5.50.2", - "@algolia/client-personalization": "5.50.2", - "@algolia/client-query-suggestions": "5.50.2", - "@algolia/client-search": "5.50.2", - "@algolia/ingestion": "1.50.2", - "@algolia/monitoring": "1.50.2", - "@algolia/recommend": "5.50.2", - "@algolia/requester-browser-xhr": "5.50.2", - "@algolia/requester-fetch": "5.50.2", - "@algolia/requester-node-http": "5.50.2" + "@algolia/abtesting": "1.18.1", + "@algolia/client-abtesting": "5.52.1", + "@algolia/client-analytics": "5.52.1", + "@algolia/client-common": "5.52.1", + "@algolia/client-insights": "5.52.1", + "@algolia/client-personalization": "5.52.1", + "@algolia/client-query-suggestions": "5.52.1", + "@algolia/client-search": "5.52.1", + "@algolia/ingestion": "1.52.1", + "@algolia/monitoring": "1.52.1", + "@algolia/recommend": "5.52.1", + "@algolia/requester-browser-xhr": "5.52.1", + "@algolia/requester-fetch": "5.52.1", + "@algolia/requester-node-http": "5.52.1" }, "engines": { "node": ">= 14.0.0" @@ -2092,9 +2106,9 @@ } }, "node_modules/brace-expansion": { - "version": "5.0.5", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", - "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", + "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", "dev": true, "license": "MIT", "dependencies": { @@ -2308,19 +2322,19 @@ } }, "node_modules/eslint": { - "version": "10.2.0", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.2.0.tgz", - "integrity": "sha512-+L0vBFYGIpSNIt/KWTpFonPrqYvgKw1eUI5Vn7mEogrQcWtWYtNQ7dNqC+px/J0idT3BAkiWrhfS7k+Tum8TUA==", + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.4.0.tgz", + "integrity": "sha512-loXy6bWOoP3EP6JA7jo6p5jMpBJmHmsNZM5SFRHLdh1MGOPurMnNBj4ZlAbaqUAaQWbCr7jHV4P7gzAyryZWkQ==", "dev": true, "license": "MIT", "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.2", - "@eslint/config-array": "^0.23.4", - "@eslint/config-helpers": "^0.5.4", - "@eslint/core": "^1.2.0", - "@eslint/plugin-kit": "^0.7.0", + "@eslint/config-array": "^0.23.5", + "@eslint/config-helpers": "^0.6.0", + "@eslint/core": "^1.2.1", + "@eslint/plugin-kit": "^0.7.1", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", @@ -2947,9 +2961,9 @@ "license": "MIT" }, "node_modules/nanoid": { - "version": "3.3.11", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", - "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "version": "3.3.12", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", + "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", "dev": true, "funding": [ { @@ -3083,9 +3097,9 @@ } }, "node_modules/postcss": { - "version": "8.5.9", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.9.tgz", - "integrity": "sha512-7a70Nsot+EMX9fFU3064K/kdHWZqGVY+BADLyXc8Dfv+mTLLVl6JzJpPaCZ2kQL9gIJvKXSLMHhqdRRjwQeFtw==", + "version": "8.5.14", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz", + "integrity": "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==", "dev": true, "funding": [ { @@ -3112,9 +3126,9 @@ } }, "node_modules/preact": { - "version": "10.29.1", - "resolved": "https://registry.npmjs.org/preact/-/preact-10.29.1.tgz", - "integrity": "sha512-gQCLc/vWroE8lIpleXtdJhTFDogTdZG9AjMUpVkDf2iTCNwYNWA+u16dL41TqUDJO4gm2IgrcMv3uTpjd4Pwmg==", + "version": "10.29.2", + "resolved": "https://registry.npmjs.org/preact/-/preact-10.29.2.tgz", + "integrity": "sha512-7tNmwg/7mzzAoB/8kSg6Hl37JraAZw3Z3A0JSY7VXlZwo82Xn0G7wKbNNs2qoF4ZEEsQGTwDAroNdqKs1ofJxQ==", "dev": true, "license": "MIT", "funding": { @@ -3133,9 +3147,9 @@ } }, "node_modules/prettier": { - "version": "3.8.2", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.2.tgz", - "integrity": "sha512-8c3mgTe0ASwWAJK+78dpviD+A8EqhndQPUBpNUIPt6+xWlIigCwfN01lWr9MAede4uqXGTEKeQWTvzb3vjia0Q==", + "version": "3.8.3", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.3.tgz", + "integrity": "sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw==", "dev": true, "license": "MIT", "bin": { @@ -3204,9 +3218,9 @@ "license": "MIT" }, "node_modules/rollup": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.1.tgz", - "integrity": "sha512-VmtB2rFU/GroZ4oL8+ZqXgSA38O6GR8KSIvWmEFv63pQ0G6KaBH9s07PO8XTXP4vI+3UJUEypOfjkGfmSBBR0w==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.4.tgz", + "integrity": "sha512-WHeFSbZYsPu3+bLoNRUuAO+wavNlocOPf3wSHTP7hcFKVnJeWsYlCDbr3mTS14FCizf9ccIxXA8sGL8zKeQN3g==", "dev": true, "license": "MIT", "dependencies": { @@ -3220,34 +3234,41 @@ "npm": ">=8.0.0" }, "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.60.1", - "@rollup/rollup-android-arm64": "4.60.1", - "@rollup/rollup-darwin-arm64": "4.60.1", - "@rollup/rollup-darwin-x64": "4.60.1", - "@rollup/rollup-freebsd-arm64": "4.60.1", - "@rollup/rollup-freebsd-x64": "4.60.1", - "@rollup/rollup-linux-arm-gnueabihf": "4.60.1", - "@rollup/rollup-linux-arm-musleabihf": "4.60.1", - "@rollup/rollup-linux-arm64-gnu": "4.60.1", - "@rollup/rollup-linux-arm64-musl": "4.60.1", - "@rollup/rollup-linux-loong64-gnu": "4.60.1", - "@rollup/rollup-linux-loong64-musl": "4.60.1", - "@rollup/rollup-linux-ppc64-gnu": "4.60.1", - "@rollup/rollup-linux-ppc64-musl": "4.60.1", - "@rollup/rollup-linux-riscv64-gnu": "4.60.1", - "@rollup/rollup-linux-riscv64-musl": "4.60.1", - "@rollup/rollup-linux-s390x-gnu": "4.60.1", - "@rollup/rollup-linux-x64-gnu": "4.60.1", - "@rollup/rollup-linux-x64-musl": "4.60.1", - "@rollup/rollup-openbsd-x64": "4.60.1", - "@rollup/rollup-openharmony-arm64": "4.60.1", - "@rollup/rollup-win32-arm64-msvc": "4.60.1", - "@rollup/rollup-win32-ia32-msvc": "4.60.1", - "@rollup/rollup-win32-x64-gnu": "4.60.1", - "@rollup/rollup-win32-x64-msvc": "4.60.1", + "@rollup/rollup-android-arm-eabi": "4.60.4", + "@rollup/rollup-android-arm64": "4.60.4", + "@rollup/rollup-darwin-arm64": "4.60.4", + "@rollup/rollup-darwin-x64": "4.60.4", + "@rollup/rollup-freebsd-arm64": "4.60.4", + "@rollup/rollup-freebsd-x64": "4.60.4", + "@rollup/rollup-linux-arm-gnueabihf": "4.60.4", + "@rollup/rollup-linux-arm-musleabihf": "4.60.4", + "@rollup/rollup-linux-arm64-gnu": "4.60.4", + "@rollup/rollup-linux-arm64-musl": "4.60.4", + "@rollup/rollup-linux-loong64-gnu": "4.60.4", + "@rollup/rollup-linux-loong64-musl": "4.60.4", + "@rollup/rollup-linux-ppc64-gnu": "4.60.4", + "@rollup/rollup-linux-ppc64-musl": "4.60.4", + "@rollup/rollup-linux-riscv64-gnu": "4.60.4", + "@rollup/rollup-linux-riscv64-musl": "4.60.4", + "@rollup/rollup-linux-s390x-gnu": "4.60.4", + "@rollup/rollup-linux-x64-gnu": "4.60.4", + "@rollup/rollup-linux-x64-musl": "4.60.4", + "@rollup/rollup-openbsd-x64": "4.60.4", + "@rollup/rollup-openharmony-arm64": "4.60.4", + "@rollup/rollup-win32-arm64-msvc": "4.60.4", + "@rollup/rollup-win32-ia32-msvc": "4.60.4", + "@rollup/rollup-win32-x64-gnu": "4.60.4", + "@rollup/rollup-win32-x64-msvc": "4.60.4", "fsevents": "~2.3.2" } }, + "node_modules/rollup/node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, "node_modules/search-insights": { "version": "2.17.3", "resolved": "https://registry.npmjs.org/search-insights/-/search-insights-2.17.3.tgz", @@ -3257,9 +3278,9 @@ "peer": true }, "node_modules/semver": { - "version": "7.7.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", - "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.0.tgz", + "integrity": "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==", "dev": true, "license": "ISC", "bin": { @@ -3430,9 +3451,9 @@ } }, "node_modules/typescript": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.2.tgz", - "integrity": "sha512-bGdAIrZ0wiGDo5l8c++HWtbaNCWTS4UTv7RaTH/ThVIgjkveJt83m74bBHMJkuCbslY8ixgLBVZJIOiQlQTjfQ==", + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", "dev": true, "license": "Apache-2.0", "peer": true, @@ -3445,16 +3466,16 @@ } }, "node_modules/typescript-eslint": { - "version": "8.58.2", - "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.58.2.tgz", - "integrity": "sha512-V8iSng9mRbdZjl54VJ9NKr6ZB+dW0J3TzRXRGcSbLIej9jV86ZRtlYeTKDR/QLxXykocJ5icNzbsl2+5TzIvcQ==", + "version": "8.59.4", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.59.4.tgz", + "integrity": "sha512-Rw6+44QNFaXtgHSjPy+Kw8hrJniMYzR85E9yLmOLcfZ91/rz+JXQbDTCmc6ccxMPY6K6PgAq26f0JCBfR7LIPQ==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/eslint-plugin": "8.58.2", - "@typescript-eslint/parser": "8.58.2", - "@typescript-eslint/typescript-estree": "8.58.2", - "@typescript-eslint/utils": "8.58.2" + "@typescript-eslint/eslint-plugin": "8.59.4", + "@typescript-eslint/parser": "8.59.4", + "@typescript-eslint/typescript-estree": "8.59.4", + "@typescript-eslint/utils": "8.59.4" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -3692,18 +3713,18 @@ } }, "node_modules/vue": { - "version": "3.5.32", - "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.32.tgz", - "integrity": "sha512-vM4z4Q9tTafVfMAK7IVzmxg34rSzTFMyIe0UUEijUCkn9+23lj0WRfA83dg7eQZIUlgOSGrkViIaCfqSAUXsMw==", + "version": "3.5.34", + "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.34.tgz", + "integrity": "sha512-WdLBG9gm02OgJIG9axd5Hpx0TFLdzVgfG2evFFu8Rur5O/IoGc5cMjnjh3tPL6GnRGsYvUhBSKVPYVcxRKpMCA==", "dev": true, "license": "MIT", "peer": true, "dependencies": { - "@vue/compiler-dom": "3.5.32", - "@vue/compiler-sfc": "3.5.32", - "@vue/runtime-dom": "3.5.32", - "@vue/server-renderer": "3.5.32", - "@vue/shared": "3.5.32" + "@vue/compiler-dom": "3.5.34", + "@vue/compiler-sfc": "3.5.34", + "@vue/runtime-dom": "3.5.34", + "@vue/server-renderer": "3.5.34", + "@vue/shared": "3.5.34" }, "peerDependencies": { "typescript": "*" diff --git a/docs/superpowers/plans/2026-05-20-cli-extensions.md b/docs/superpowers/plans/2026-05-20-cli-extensions.md new file mode 100644 index 00000000..1e172bd3 --- /dev/null +++ b/docs/superpowers/plans/2026-05-20-cli-extensions.md @@ -0,0 +1,963 @@ +# EdgeZero CLI Extensions 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:** Turn `edgezero-cli` into an extensible library, rewrite the manifest store schema and runtime to a multi-store model, add `auth` / `provision` / `config validate` / `config push` commands, and update `app-demo` to exercise it all across axum / cloudflare / fastly / spin. + +**Architecture:** One PR, eight sequential stages. Stage 1 extracts the CLI library substrate. Stage 2 is an atomic manifest + runtime rewrite (hard cutoff — no backward compatibility). Stages 3–7 add app-config and the four commands. Stage 8 makes `app-demo` the full-capability showcase and audits docs. + +**Tech Stack:** Rust 1.95 (edition 2021), `clap` (derive), `serde` / `toml` / `serde_json`, `validator`, `async-trait` (`?Send`, WASM-safe), `handlebars` (templates), proc-macros (`edgezero-macros`), VitePress docs. + +**Spec:** `docs/superpowers/specs/2026-05-19-cli-extensions-design.md` — read it first. Section references (§) below point into it. + +--- + +## Preconditions (do before stage 2) + +- [x] **PR #253 (`feat/spin-store-support`) is merged into the working branch.** Landed via the `chore/strict-clippy` merge — `crates/edgezero-adapter-spin/src/` now has `config_store.rs` / `key_value_store.rs` / `secret_store.rs`. Stage 2 wires `SpinKvStore` / `SpinConfigStore` / `SpinSecretStore` into the multi-store runtime. +- [ ] Working on branch `feature/extensible-cli` (stacked on `chore/strict-clippy` / PR #257). The spec and plan live in `docs/superpowers/`, which is gitignored — keep using `git add -f` for spec/plan files only. + +## Status + +- **Stage 1 — DONE.** Landed as `1d582dd` (extensible `edgezero-cli` + library + generator + `app-demo-cli`) plus follow-up `06f4b72` + (`demo` is example-only; `serve --adapter axum` runs the axum + adapter). §7 below is kept for reference — do **not** re-do it. +- **Stage 2 — DONE on `feature/extensible-cli`.** Landed across the + commit chain rooted at `f5bd432` (Task 2.1, portable store schema) + through the post-review fixes at `8942ec2` (Spin component field, + bind-vs-environment precedence, axum doc API drift). Substrate + shipped: portable `[stores.]` schema + hard-cutoff for the + legacy `[stores.] name` / `[adapters..stores.*]` / + `[adapters..adapter] ` fields (§§ 8.1, 8.3, plus + follow-ups); `EDGEZERO__*` env-config layer; `app!` macro bakes + portable store metadata into `Hooks::stores()`; `run_app` drops + `manifest_src` on all four adapters; async `ConfigStore`; `KvError` + gains `Unsupported` / `LimitExceeded` with `EdgeError` mappings; + per-id `KvRegistry` / `ConfigRegistry` / `SecretRegistry`; `Kv` / + `Secrets` / `Config` extractors reshape to `default()` / `named()`; + `BoundSecretStore` captures the per-id platform store name (Fastly + multi-secret wired end-to-end); axum config store reads + `.edgezero/local-config-.json` per id; Spin KV pagination and + dotted-key translation; cloudflare config-store rewrite from `[vars]` + JSON-string to KV namespace; `app-demo` and the generator template + ship matching manifests + per-platform bindings; manifest-store + migration guide published; all five CI gates + the opt-in + generated-project compile check + docs lint/format/build green. +- **Stages 3 + 4 — shipped** on `feature/extensible-cli`. Typed + `.toml` app-config + `#[derive(AppConfig)]` + env-var + overlay land in Stage 3; `config validate` (raw + typed flavours + dispatched via an `AdapterCheck` trait) lands in Stage 4. The + reference `app-demo-cli config validate --strict` and raw + `edgezero config validate --strict` both exit 0 against the + in-tree fixture. +- **Stage 5 — shipped.** `auth login/logout/status --adapter ` + dispatches via `AdapterAction::Auth{Login,Logout,Status}`; each + adapter crate owns its implementation in `Adapter::execute`. + Per-project overrides via + `[adapters..commands].auth-{login,logout,status}` in + `edgezero.toml`. Earlier `CommandRunner`/`MockCommandRunner` + sketch retired (see Stage 5 below). +- **Stage 6 — shipped.** `provision --adapter ` dispatches + via a new `Adapter::provision` trait method (NOT + `AdapterAction` — the surface needs typed `ProvisionStores` and + a paths/dry-run signature that doesn't fit `AdapterAction`'s + `&[String]` shape). Landed as one-adapter-per-commit: + `9a0369b` (trait + axum no-op + CLI delegate + stubs for the + other three), `d905e42` (cloudflare `wrangler kv namespace + create` + `wrangler.toml` `[[kv_namespaces]]` writeback), + `79a54b6` (fastly `fastly -store create` + + `[setup.*]`/`[local_server.*]` `fastly.toml` writeback), + `0933440` (spin pure `spin.toml` editing — appends KV labels + to the resolved `[component.].key_value_stores` array). +- **Stage 7 — shipped.** `config push` adds the symmetric + write-side counterpart to `config validate`, also dispatched + via a new `Adapter::push_config_entries` trait method (same + rationale as `provision`). Landed as one-adapter-per-commit: + `bc0a705` (trait + axum impl + CLI raw + typed entry points + + stubs), `74d596a` (cloudflare `wrangler kv bulk put` against + the namespace id read from `wrangler.toml`), `d852f3f` (fastly + `fastly config-store list --json` then + `fastly config-store-entry create` per entry), `57c7eb3` (spin + pure `spin.toml` editing — writes both `[variables].` and + `[component..variables].` with `.→__` lowercase key + translation). The typed flow strips both `#[secret]` and + `#[secret(store_ref)]` top-level fields before pushing (spec + §13). +- **Stage 8 — shipped.** Plan task 8.1 split across three + commits (`3d3f87c`, `9fdd1f4`, `26fddcc`) — manual Spin + secret-variable declarations in + `app-demo-adapter-spin/spin.toml`, three typed-CLI + integration tests in `app-demo-cli/tests/config_flow.rs`, + and the handler rewiring to `Kv::named("sessions")` / + `Kv::named("cache")` with a registry-aware + `context_with_kv` test helper. Plan task 8.2 (generator + CLI template emits the full seven-command Cmd enum) shipped + as `a4f7c81`. The e2e roundtrip + (push → `AxumConfigStore::from_path` → handler) shipped as + `45aef3d`; full HTTP-subprocess lifecycle is intentionally + deferred — the data-contract roundtrip covers what app-demo + needs without the subprocess machinery. Plan task 8.3 (CI + wiring for `cd examples/app-demo && cargo test` plus + fmt/clippy gates) shipped as `7d01061`. Plan task 8.4 + (`cli-walkthrough.md` + doc audit + sidebar update + a + silent-broken VitePress build fix for the `{{ }}` + interpolation in cli-reference.md) shipped as `a3b7a89`. +- **Stage 9 — shipped (review followups).** A staff-engineer + review of the post-Stage-8 branch found five gaps; each + landed as its own commit so review traceability stays + linear. `55fe91b` (Stage 9.1) wired `run_shared_checks` into + both raw and typed `config push` with `strict: true` + synthesised on the validate args — the typed push had been + loading config, running typed secret checks, and dispatching + without running the shared adapter / capability-completeness + / handler-path checks the spec promised. `b531f5a` (Stage + 9.2) fixed Spin secret-value validation: the runtime + `SpinSecretStore::get_bytes` lowercases keys before + `variables::get`, but the validator was case-preserving, so + `#[secret]` value `"GREETING"` against config key `greeting` + silently passed and dashed values like `"api-token"` were + caught only at runtime — `validate_typed_secrets` now mirrors + the runtime canonicalisation exactly and also runs + `is_valid_spin_key` on each secret value. `2cc85d1` (Stage + 9.3) introduced the runtime store-API hard-cutoff at the + fallback layer: `StoreRegistry::single_id` helper + dispatch- + boundary synthesis in all four adapters + extractor and + `RequestContext::*_store*` fallback removal. `6592918` + (Stage 9.4) hardened Spin dry-run assertions to verify the + translated keys, both spin.toml tables, and that + `SECRET_FIELDS` stripping reaches the adapter preview. + `8ad9040` (Stage 9.5) refreshed the PR template, run_tests.sh, + and the migration guide for the new gates and the shipped + `config push` command. +- **Stage 10 — shipped (second-review followups).** A second + pass flagged that Stage 9.3 had only closed the + silent-masking fallback; the public legacy + `RequestContext::config_handle()` / `kv_handle()` / + `secret_handle()` accessors plus the bare-handle insertion + into request extensions still existed, contradicting the + spec's "no backward compatibility" promise. + `b1b5dca` (Stage 10.1) removed those three methods, stopped + inserting bare handles into request extensions in all four + dispatchers, and migrated 9 dev-server callers + 3 axum + service tests + 4 contract-test handlers (cloudflare / + fastly / spin) to the registry-aware accessors. The + axum `with_*_handle` setup APIs stay public but route + through the one-id-registry synthesis path internally. + + Subsequent dispatch-API consolidation: the per-store + `dispatch_with_*` variant fan-out on fastly + cloudflare + collapsed into a single `FastlyService` / `CloudflareService` + builder. Per-request store wiring uses the fluent form + `Service::new(&app).with_kv("name").require_kv() + .with_config("name").with_secrets().dispatch(req[, env, ctx])`. + The manifest-driven `run_app` remains the recommended + entrypoint and now internally builds a Service. + +## Codebase facts this plan relies on + +(Reflects branch state after Stage 2 shipped on +`feature/extensible-cli`. The pre-Stage-1 / pre-Stage-2 shape that +earlier revisions of this plan referenced is gone — code below is the +substrate Stage 3 builds on.) + +- `edgezero-cli` is a **library + binary**: + - `crates/edgezero-cli/src/lib.rs` is the public API; downstream + binaries depend on it. Each command is exposed as a + `(Args, run_)` pair (`BuildArgs` / `run_build`, etc.). + - `*Args` structs derive `clap::Args` + `Default` and are + `#[non_exhaustive]`; live under `edgezero_cli::args`. + - The `edgezero` binary is a thin wrapper that delegates to those + `run_*` functions; the `cli` feature gates the binary build (deps + on `clap`). + - Adapter discovery is link-time via the `edgezero-adapter` registry; + `build.rs` reads `Cargo.toml` to figure out which optional + `edgezero-adapter-*` deps are enabled and emits + `linked_adapters.rs`. +- `ConfigStore::get` is **async** (`#[async_trait(?Send)]`), with all + four adapter impls — `AxumConfigStore` (local-file backed), + `FastlyConfigStore`, `CloudflareConfigStore` (KV-namespace backed, + was `[vars]` JSON-string), `SpinConfigStore`. `KvStore` and + `SecretStore` are already async. +- `KvError` carries `Unsupported { operation }` and + `LimitExceeded { message }` variants in addition to the legacy + `Internal` / `NotFound` / `Serialization` / `Unavailable` / + `Validation`. Both new variants map to 5xx-class `EdgeError`s. +- Handle types remain `KvHandle` / `ConfigStoreHandle` / `SecretHandle`. + Stage 2 added `BoundKvStore = KvHandle` and + `BoundConfigStore = ConfigStoreHandle` aliases, plus a real + `BoundSecretStore { handle: SecretHandle, store_name: String }` + that captures the per-id platform store name (so the registry's + `EDGEZERO__STORES__SECRETS____NAME` binding actually flows + through to lookups). +- `StoreRegistry { by_id: BTreeMap, default_id: String }` + lives at `crates/edgezero-core/src/store_registry.rs` with + `KvRegistry` / `ConfigRegistry` / `SecretRegistry` aliases. `new` + panics in both debug and release when `default_id` is missing; + builders that skip failed-to-open backends use the safe + `from_parts(by_id, default_id) -> Option`. +- `RequestContext` accessors are **id-keyed**: + `kv_store(id)` / `kv_store_default()`, + `config_store(id)` / `config_store_default()`, + `secret_store(id)` / `secret_store_default()`. The pre-rewrite + singular accessors (`kv_handle()` / `config_handle()` / + `secret_handle()`) are GONE (Stage 10.1 hard-cutoff). The + setup APIs (`with_kv_handle`, etc.) still accept a single + handle but synthesise a one-id `StoreRegistry` keyed under + `"default"` at the dispatch boundary -- the id-keyed accessors + only consult registries, never a bare handle in extensions. +- `Kv` / `Secrets` / `Config` extractors expose `.default()` / + `.named(id)` returning the matching `Bound*Store`. The legacy + destructure pattern (`Kv(store): Kv`) is gone. +- The portable manifest model (`crates/edgezero-core/src/manifest.rs`): + - `[stores.]` carries only `ids` + `default`; pre-rewrite + fields (`name`, `enabled`, `[stores..adapters.*]`, + `[stores.config.defaults]`) are a hard load error pointing at + `docs/guide/manifest-store-migration.md`. + - `[adapters.]` retains `adapter` / `build` / `commands` / + `logging`; any other sub-table is a hard load error. + `[adapters..adapter]` declares `component` / `crate` / `host` / + `manifest` / `port`; any other field is a hard load error. + - `app!` macro bakes the portable store registry into + `Hooks::stores()` at compile time (no runtime manifest load). +- `run_app::()` takes **no `manifest_src`** on any adapter + (axum / fastly / cloudflare / spin). Adapter-specific runtime + config — bind host/port, store platform names, store tuning, log + level — comes from `EDGEZERO__*` env vars + (`crates/edgezero-core/src/env_config.rs`). The Stage 2 CLI + translates `[adapters..adapter] host`/`port` into + `EDGEZERO__ADAPTER__HOST/PORT` on the subprocess env (with the + documented precedence parent env > manifest `[environment.variables]` + > `[adapters..adapter]` bind hint). +- Axum KV is `PersistentKvStore` (redb-backed). Each declared + `[stores.kv]` id resolves to its own file: the default id keeps + `.edgezero/kv.redb`; other ids get `.edgezero/kv--.redb` + where the file name is derived from the platform name from + `EDGEZERO__STORES__KV____NAME` (or the id default). +- Axum config is `AxumConfigStore::from_local_file(id)` reading + `.edgezero/local-config-.json` per declared id (a flat + `string -> string` JSON object). Missing file → empty store + (permissive); malformed → `ConfigStoreError::Unavailable` and the + id is dropped from the registry with a warn log. `config push` + (Stage 7) will write that file; Stage 3 / typed app config feed + into the same path. +- Axum secrets is `EnvSecretStore` (env-var lookup). `Single` for + secrets, so every declared id maps to the same env-backed store. +- Spin KV is `SpinKvStore` (`max_list_keys` cap honored; + `put_bytes_with_ttl` returns `KvError::Unsupported`; listing past + the cap returns `KvError::LimitExceeded`). Spin config is + `SpinConfigStore` (single flat-variable store; `.`→`__` key + translation). Spin secrets is `SpinSecretStore` (single flat- + variable store). +- Cloudflare config is **KV-namespace backed**, not `[vars]` + JSON-string — `CloudflareConfigStore::from_env(&worker::Env, binding_name)` + opens a KV namespace and `get(key)` is async. +- `examples/app-demo` is a **separate workspace**, excluded from the + root workspace. CI now runs `cd examples/app-demo && cargo test + --workspace --all-targets` as a dedicated job (see `format.yml` / + `test.yml`); previous revisions of this plan noted it was uncovered, + which is no longer true. The opt-in + `cargo test -p edgezero-cli --test generated_project_builds -- --ignored` + scaffolds a new workspace from the templates and runs `cargo check` + on it — Stage 3's generator-template changes must keep that test + green. +- CI: `.github/workflows/test.yml` and `format.yml` plus the docs + ESLint/Prettier job. The exact gate commands are the five below. + +## The full gate + +Wherever a task says **"run the full gate"**, it means these exact +commands — the project's documented CI gates (`CLAUDE.md` "CI Gates" + +`.github/workflows/`). Do not substitute `--all-features` for the +feature list, or drop `--all-targets`; match CI exactly so the plan +validates the same surface CI does. + +```sh +cargo fmt --all -- --check +cargo clippy --workspace --all-targets --all-features -- -D warnings +cargo test --workspace --all-targets +cargo check --workspace --all-targets --features "fastly cloudflare spin" +cargo check -p edgezero-adapter-spin --target wasm32-wasip2 --features spin +``` + +Plus, where the task touches adapter runtime or `app-demo`: the +per-adapter wasm `--test contract` runs (Task 2.6), +`cd examples/app-demo && cargo test`, and — for doc changes — the docs +ESLint/Prettier job. Each stage's final task runs the full gate before +its `git commit`. + +## File structure (created / modified across the 8 stages) + +``` +crates/edgezero-cli/ + Cargo.toml # M: lib target implicit via src/lib.rs; new deps + src/lib.rs # C (stage 1): public API + src/main.rs # M (stage 1): thin wrapper; M (4-7): dispatch arms for new commands + src/args.rs # M: standalone *Args structs; M (4-7): new *Args + Command enum variants + src/demo_server.rs # M (stage 1): renamed from dev_server.rs + # (stage 5 originally planned a `src/runner.rs` — retired in + # favour of per-adapter `Adapter::execute` dispatch.) + src/auth.rs # C (stage 5) + src/provision.rs # C (stage 6) + src/config.rs # C (stage 7): validate + push + src/generator.rs # M (stages 1, 3): scaffold -cli, .toml + src/templates/cli/ # C (stage 1); M (stage 8): full command set + src/templates/app/ # C (stage 3) + src/templates/root/edgezero.toml.hbs # M (stage 2): new store schema + src/templates/core/src/config.rs.hbs # C (stage 3) + tests/lib_consumer.rs # C (stage 1) +crates/edgezero-core/src/ + manifest.rs # M (stage 2): store schema rewrite + capability rules + config_store.rs # M (stage 2): async trait + key_value_store.rs # M (stage 2): KvError::Unsupported + LimitExceeded + secret_store.rs # M (stage 2): bound-handle wrapper + context.rs # M (stage 2): id-keyed Bound*Store accessors + extractor.rs # M (stage 2): Kv/Secrets/Config default()/named() + app.rs # M (stage 2): Hooks + id-keyed ConfigStoreMetadata (Hooks lives in app.rs, no separate hooks.rs) + app_config.rs # C (stage 3) +crates/edgezero-macros/src/ + lib.rs # M (stage 3): AppConfig derive export + app_config.rs # C (stage 3): derive impl + app.rs # M (stage 2): emit id-keyed metadata +crates/edgezero-adapter-{axum,cloudflare,fastly,spin}/src/ + {config_store,key_value_store,secret_store}.rs # M (stage 2): multi-store registries +examples/app-demo/ + Cargo.toml # M (stage 1): add app-demo-cli member + edgezero.toml # M (stage 2): new schema + app-demo.toml # C (stage 3) + crates/app-demo-cli/ # C (stage 1, extended 4-8) + crates/app-demo-core/src/config.rs # C (stage 3) + crates/app-demo-core/src/handlers.rs # M (stages 2, 8) +docs/guide/ # M: many pages per §6.12 +docs/guide/manifest-store-migration.md # C (stage 2) +docs/guide/cli-walkthrough.md # C (stage 8) +docs/.vitepress/config.mts # M (stages 2, 8): sidebar +``` + +--- + +# Stage 1 — Extensible `edgezero-cli` library + generator + `app-demo-cli` skeleton ✅ DONE (`1d582dd`, `06f4b72`) + +Spec §7. No PR #253 dependency. Goal: `edgezero-cli` becomes lib + bin; the `demo` subcommand replaces `dev`; the generator scaffolds `-cli`; a handwritten `app-demo-cli` exists. + +### Task 1.1: Promote `Command` variant fields into standalone `*Args` structs + +**Files:** + +- Modify: `crates/edgezero-cli/src/args.rs` + +- [ ] **Step 1: Write failing test** in `args.rs` `#[cfg(test)] mod tests` — assert `BuildArgs`, `DeployArgs`, `ServeArgs` exist, are `Default`, and parse: + +```rust +#[test] +fn build_args_default_and_mutate() { + let mut a = BuildArgs::default(); + a.adapter = "fastly".to_string(); + assert_eq!(a.adapter, "fastly"); +} +``` + +- [ ] **Step 2: Run** `cargo test -p edgezero-cli args::tests::build_args_default_and_mutate` — expect FAIL (`BuildArgs` not found). + +- [ ] **Step 3: Implement.** Add `#[derive(clap::Args, Debug, Default)] #[non_exhaustive]` structs `BuildArgs { adapter: String, adapter_args: Vec }`, `DeployArgs { adapter: String, adapter_args: Vec }`, `ServeArgs { adapter: String }` carrying the exact `#[arg(...)]` attributes currently inline in the `Command` enum variants. Keep `NewArgs` as-is (already standalone). Rewrite `Command` to: `Build(BuildArgs)`, `Deploy(DeployArgs)`, `Demo`, `New(NewArgs)`, `Serve(ServeArgs)`. Note: `Demo` is the renamed `Dev` (see Task 1.3). + +- [ ] **Step 4: Run** `cargo test -p edgezero-cli args::` — expect PASS. Update the existing `parses_build_command_with_passthrough_args` test to destructure `Command::Build(BuildArgs { adapter, adapter_args })`. + +- [ ] **Step 5: Commit** is deferred — stage 1 lands as one commit after Task 1.7. Stage progress only. + +### Task 1.2: Create `lib.rs`, move handlers, rewrite `main.rs` + +**Files:** + +- Create: `crates/edgezero-cli/src/lib.rs` +- Modify: `crates/edgezero-cli/src/main.rs` + +- [ ] **Step 1:** Create `lib.rs` under `#![cfg(feature = "cli")]`-style gating consistent with the crate. Declare the private modules (`mod adapter; mod args; mod generator; mod scaffold; #[cfg(feature = "edgezero-adapter-axum")] mod demo_server;`). Move `init_cli_logger`, `load_manifest_optional`, `ensure_adapter_defined`, `store_bindings_message`, `log_store_bindings`, and the handler bodies from `main.rs`. Rename `handle_build`→`run_build`, `handle_deploy`→`run_deploy`, `handle_serve`→`run_serve`; add `run_new` wrapping `generator::generate_new`; `run_demo` (Task 1.3). `pub use args::{Args, BuildArgs, Command, DeployArgs, NewArgs, ServeArgs};`. Public signatures: `pub fn run_build(args: &BuildArgs) -> Result<(), String>` etc. + +- [ ] **Step 2:** Move the `#[cfg(test)] mod tests` from `main.rs` into `lib.rs` unchanged (they test the moved fns). + +- [ ] **Step 3:** Rewrite `main.rs` to ~25 lines: `use edgezero_cli::{...}; fn main() { edgezero_cli::init_cli_logger(); match Args::parse().cmd { Command::Build(a) => exit_on_err(edgezero_cli::run_build(&a)), ... Command::Demo => exit_on_err(edgezero_cli::run_demo()), ... } }`. Keep the `#[cfg(not(feature = "cli"))]` fallback `main`. + +- [ ] **Step 4: Run** `cargo test -p edgezero-cli` — expect PASS (all relocated tests green). + +- [ ] **Step 5: Run** `cargo build -p edgezero-cli` and `./target/debug/edgezero --help` — expect four subcommands (`build`, `deploy`, `new`, `serve`); `demo` is gated behind the `demo-example` feature. + +### Task 1.3: Rename `dev` → `demo` + +**Files:** + +- Modify: `crates/edgezero-cli/src/args.rs`, `crates/edgezero-cli/src/main.rs`, `crates/edgezero-cli/src/lib.rs` +- Rename: `crates/edgezero-cli/src/dev_server.rs` → `crates/edgezero-cli/src/demo_server.rs` + +- [ ] **Step 1:** `git mv crates/edgezero-cli/src/dev_server.rs crates/edgezero-cli/src/demo_server.rs`. Inside it, rename `pub fn run_dev()` → `pub fn run_demo() -> Result<(), String>` — change the return type: `Ok(())` on graceful shutdown, `Err(String)` on bind failure. Update internal references. + +- [ ] **Step 2:** In `args.rs`, the `Command` enum variant is `Demo` (done in Task 1.1). In `lib.rs` declare `#[cfg(feature = "edgezero-adapter-axum")] mod demo_server;` and `pub use demo_server::run_demo;` (feature-gated). Add the non-axum fallback: `run_demo` errors "built without edgezero-adapter-axum". + +- [ ] **Step 3:** Update `CLAUDE.md`'s `cargo run -p edgezero-cli --features dev-example -- dev` reference is doc-only — leave the `dev-example` feature name as-is (out of scope) but the invocation becomes `-- demo`. (Doc fix happens in Task 1.7.) + +- [ ] **Step 4: Run** `cargo test -p edgezero-cli && cargo build -p edgezero-cli` — expect PASS; with `--features demo-example` built in, `./target/debug/edgezero demo --help` works. + +### Task 1.4: Extend the generator to scaffold `-cli` + +**Files:** + +- Modify: `crates/edgezero-cli/src/generator.rs`, `crates/edgezero-cli/src/scaffold.rs` +- Create: `crates/edgezero-cli/src/templates/cli/Cargo.toml.hbs`, `crates/edgezero-cli/src/templates/cli/src/main.rs.hbs` +- Modify: `crates/edgezero-cli/src/templates/root/Cargo.toml.hbs` + +- [ ] **Step 1: Write failing test** in `generator.rs` tests: `generate_new` into a `tempfile::TempDir` produces `crates/-cli/Cargo.toml` and `crates/-cli/src/main.rs`, and the root `Cargo.toml` `members` list contains `crates/-cli`. + +- [ ] **Step 2: Run** the test — expect FAIL. + +- [ ] **Step 3: Implement.** Add `templates/cli/Cargo.toml.hbs` (package `{{name}}-cli`, depends on `edgezero-cli` with default features, `clap` derive, `log`). Add `templates/cli/src/main.rs.hbs` — the canonical downstream pattern: a `clap::Parser` `Args` with a `Cmd` `Subcommand` enum listing the four downstream built-ins (`Build(BuildArgs)`, `Deploy(DeployArgs)`, `New(NewArgs)`, `Serve(ServeArgs)`), `main` dispatching to `edgezero_cli::run_*`. Register the new templates in `scaffold.rs::register_templates`. In `generator.rs`, render the cli crate and append `crates/{{name}}-cli` to the root `Cargo.toml` members. + +- [ ] **Step 4: Run** the generator test — expect PASS. + +- [ ] **Step 5: Manual check:** generate into an explicit fresh temp dir and build it — do **not** assume the project lands in CWD. Example: + +```bash +TMP="$(mktemp -d)" +cargo run -p edgezero-cli -- new throwaway --dir "$TMP" +# cd into the generated project root (confirm the exact path the generator +# prints — `--dir` is "the directory to create the app in"): +cd "$TMP"/* 2>/dev/null || cd "$TMP" +cargo check --workspace +cd - && rm -rf "$TMP" +``` + +Expected: `cargo check --workspace` in the generated project succeeds. + +### Task 1.5: Add the handwritten `app-demo-cli` crate + +**Files:** + +- Create: `examples/app-demo/crates/app-demo-cli/Cargo.toml`, `examples/app-demo/crates/app-demo-cli/src/main.rs`, `examples/app-demo/crates/app-demo-cli/tests/help.rs` +- Modify: `examples/app-demo/Cargo.toml` + +- [ ] **Step 1:** Add `"crates/app-demo-cli"` to `examples/app-demo/Cargo.toml` `members`. Add `edgezero-cli = { path = "../../crates/edgezero-cli" }` to that workspace's `[workspace.dependencies]` — the path is relative to the workspace manifest (`examples/app-demo/Cargo.toml`), matching the existing `edgezero-core = { path = "../../crates/edgezero-core" }` line. + +- [ ] **Step 2:** Write `app-demo-cli/Cargo.toml` — `name = "app-demo-cli"`, `publish = false`, `[lints] workspace = true`, deps `edgezero-cli = { workspace = true }`, `clap = { version = "4", features = ["derive"] }`, `log = { workspace = true }`. + +- [ ] **Step 3:** Write `app-demo-cli/src/main.rs` mirroring the generated `templates/cli/src/main.rs.hbs` pattern — the four downstream built-ins, no custom subcommands yet. `#[command(name = "app-demo-cli", about = "app-demo edge CLI")]`. + +- [ ] **Step 4:** Write `tests/help.rs`: `Args::try_parse_from(["app-demo-cli", "--help"])` returns the clap help error (not a panic). Since `Args` is private to `main.rs`, instead spawn the built binary: `assert_cmd`-style or `std::process::Command::new(env!("CARGO_BIN_EXE_app-demo-cli")).arg("--help")` exits 0 and stdout contains `build`, `deploy`, `new`, `serve`. + +- [ ] **Step 5: Run** `cd examples/app-demo && cargo test -p app-demo-cli` — expect PASS. + +### Task 1.6: External-consumer integration test + +**Files:** + +- Create: `crates/edgezero-cli/tests/lib_consumer.rs` + +- [ ] **Step 1: Write the test:** `use edgezero_cli::{BuildArgs, run_build};` — construct `let mut a = BuildArgs::default(); a.adapter = "fastly".into();`, write a minimal `edgezero.toml` into a `tempfile::TempDir`, set `EDGEZERO_MANIFEST`, call `run_build(&a)`, assert `Ok` (mirror the existing `handle_build_executes_manifest_command` test's manifest fixture). + + **Env-mutation guard (required).** `EDGEZERO_MANIFEST` is process-global; concurrent tests mutating it flake. Two rules: (a) restore the variable with an RAII guard — copy the `EnvOverride` struct from `edgezero-cli`'s existing `main.rs`/`lib.rs` tests (it saves the prior value in `new` and restores it in `Drop`); (b) keep `tests/lib_consumer.rs` to **exactly one** `#[test]`, so there is no in-binary parallelism on the env var. If a second env-touching test is ever added to this file, gate both with a shared `std::sync::Mutex` guard (the same `manifest_guard()` pattern the crate's unit tests use) — do not rely on `--test-threads=1`. + +- [ ] **Step 2: Run** `cargo test -p edgezero-cli --test lib_consumer` — expect PASS. This proves the public API is usable from outside the crate. + +### Task 1.7: Stage-1 documentation + commit + +**Files:** + +- Modify: `docs/guide/cli-reference.md`, `docs/guide/getting-started.md`, `CLAUDE.md` + +- [ ] **Step 1:** In `cli-reference.md` rename `dev` → `demo` and add a short "Building your own CLI" section pointing at the `edgezero-cli` library + the `-cli` scaffold. In `getting-started.md` note that `edgezero new` now also scaffolds `-cli`. In `CLAUDE.md` change the `dev` invocation example to `demo`. + +- [ ] **Step 2: Run the full gate** (the five commands in "The full gate" above) plus `cd examples/app-demo && cargo test`. All green. + +- [ ] **Step 3: Commit:** + +```bash +git add crates/edgezero-cli examples/app-demo docs/guide/cli-reference.md docs/guide/getting-started.md CLAUDE.md +git commit -m "Extensible edgezero-cli library + generator + app-demo-cli; rename dev->demo" +``` + +--- + +# Stage 2 — Manifest + runtime rewrite (atomic, all four adapters) + +Spec §8, §6.6, §6.7, §6.9. This is the largest stage and the review hotspot. Hard cutoff — legacy store schema is removed outright. + +## Design inputs added post-review — resolve in the Stage 2 design pass + +Two requirements surfaced after Stage 1 review. They revise the manifest +model and **must be reconciled with the §8 multi-store design before +implementing** — do not bolt them on piecemeal: + +- **A downstream binary must build without an `edgezero.toml` present.** + Manifest/store config reaches the runtime through the `App` / `Hooks` + type — macro-baked when `app!` is used, programmatic defaults otherwise — + never a runtime `include_str!` of a manifest file. `run_app` must not + hard-require a manifest file to exist at compile time. (Today every + adapter entrypoint does `include_str!("../../../edgezero.toml")`, which + breaks any downstream project that builds its `App` without a manifest.) +- **`edgezero.toml` defines only non-adapter-specific (portable) config.** + Routes, app metadata, logical store declarations, and env-var + declarations live in `edgezero.toml`; adapter-specific config lives in + the adapter layer (per-adapter manifests / adapter crate config), not the + shared manifest. + +### Task 2.1: Portable manifest schema + +**Files:** `crates/edgezero-core/src/manifest.rs` (+ `manifest_definitions.rs`) + +Rewrite `ManifestStores` to the §6.6 portable schema: `[stores.]` +carries only `ids` (non-empty) and `default` (required when +`ids.len() > 1`, else `ids[0]`). Remove the `[adapters.*]` store and +runtime tables from the manifest model. Pre-rewrite fields +(`[stores.] name`, `[stores.config.defaults]`, +`[adapters.*.stores.*]`) → hard load error pointing at +`docs/guide/manifest-store-migration.md`. + +- [ ] Tests: round-trip; non-empty ids; default required when >1 id; + legacy manifest → hard error with migration message. +- [ ] Full gate. + +### Task 2.2: `EDGEZERO__*` environment-config layer + +**Files:** `crates/edgezero-core/src/env_config.rs` (new) + +Parse `EDGEZERO__`-prefixed env vars (`__` = key-path separator) into an +adapter runtime-config value: per-store `NAME` + free-form tuning, bind +host/port, logging level. Absent vars resolve to the §6.6 defaults (a +store's platform name defaults to its logical id). + +- [ ] Tests: nesting, defaults, store-name resolution; zero-env case. +- [ ] Full gate. + +### Task 2.3: `app!` macro bakes portable config into `Hooks` + +**Files:** `crates/edgezero-macros/src/app.rs`, `crates/edgezero-core/src/app.rs` + +The `app!` macro reads `edgezero.toml` at compile time and codegens the +logical store registry + id-keyed `ConfigStoreMetadata` into the +generated `App` / `Hooks` type, alongside routing. `Hooks` exposes the +portable store config. The macro and manifest stay optional — an `App` +built without the macro supplies empty defaults, so a downstream binary +compiles with no `edgezero.toml`. + +- [ ] Tests: `app!` macro metadata-registry test. +- [ ] Full gate. + +### Task 2.4: `run_app::()` drops `manifest_src` (all four adapters) + +**Files:** `run_app` in each adapter crate; the four entrypoint templates; `edgezero-cli/src/demo_server.rs` + +`run_app` takes no manifest string. It reads portable config from `A` +and layers `EDGEZERO__*` env config (Task 2.2) for adapter-specific +values. Remove every `include_str!("edgezero.toml")`; update the four +adapter entrypoint templates and `demo_server.rs`. + +- [ ] Tests: `run_app` builds and runs with no manifest file / zero env. +- [ ] Full gate. + +### Task 2.5: Async `ConfigStore`, `KvError` variants, bound handles, id-keyed context + +**Files:** `config_store.rs`, `key_value_store.rs`, `secret_store.rs`, `context.rs`, `error.rs` + +`ConfigStore::get` → `async` (`#[async_trait(?Send)]`). Add +`KvError::Unsupported` and `KvError::LimitExceeded` with 5xx-class +`EdgeError` mappings. Add `BoundKvStore` / `BoundConfigStore` / +`BoundSecretStore` and a `StoreRegistry`; `RequestContext` accessors +become id-keyed with `_default()` helpers. + +- [ ] Tests: async config round-trip; new `KvError` mappings; registry. +- [ ] Full gate. + +### Task 2.6: Adapter store registries — all four adapters + +**Files:** `{config_store,key_value_store,secret_store}.rs` in each adapter crate + +Each adapter builds a `StoreRegistry` keyed by logical id, platform +names from `EDGEZERO__STORES__*`. axum: local KV + local-file config + +env secrets. cloudflare: KV registry, config `[vars]`→KV async, worker +secrets. fastly: KV / config / secret registries. spin: `SpinKvStore` +(labels from env, `max_list_keys`), `SpinConfigStore` (`.`→`__`), +`SpinSecretStore`. + +- [ ] Tests: id-keyed contract factories ×4; cross-adapter named KV; + cloudflare config-from-KV; spin `.`→`__`; spin TTL → `Unsupported`; + spin listing-cap pagination. +- [ ] Full gate incl. per-adapter wasm `--test contract`. + +### Task 2.7: `Kv` / `Secrets` / `Config` extractors + +**Files:** `crates/edgezero-core/src/extractor.rs` + +Refactor `Kv` / `Secrets` to `default()` / `named()`; add the `Config` +extractor (§6.9). + +- [ ] Tests: extractor tests for all three. +- [ ] Full gate. + +### Task 2.8: Migrate `app-demo`, templates, docs + +**Files:** `examples/app-demo/edgezero.toml` + handlers + adapter run config; `templates/root/edgezero.toml.hbs`; `docs/guide/manifest-store-migration.md`; affected `docs/guide/` pages + +Rewrite `examples/app-demo/edgezero.toml` and +`templates/root/edgezero.toml.hbs` to the portable schema (≥2 KV ids, +one config id, one secrets id). Migrate app-demo handlers for the +store-accessor change only. Publish `manifest-store-migration.md`; +update affected `docs/guide/` pages. + +- [ ] Full gate + `cd examples/app-demo && cargo test` + docs CI. + +### Task 2.9: Stage-2 ship gate + commit + +- [ ] Run the full gate (all five CI gates + per-adapter wasm contract + tests + `examples/app-demo` + the `generated_project_builds` + opt-in test). +- [ ] Verify an adapter binary builds and runs with no `edgezero.toml` + and zero env vars (defaults). +- [ ] Commit. + +--- + +# Stage 3 — App-config schema, derive macro, env-overlay loader + +Spec §9, §6.7, §6.8, §6.10. + +### Task 3.1: `edgezero-core::app_config` module + +**Files:** + +- Create: `crates/edgezero-core/src/app_config.rs`; Modify: `crates/edgezero-core/src/lib.rs` + +- [ ] **Step 1: Write failing tests:** valid `.toml` loads; missing file, bad TOML, validator failure each produce a distinct `AppConfigError`. + +- [ ] **Step 2: Run** — FAIL. + +- [ ] **Step 3: Implement** per §4. Types: `AppConfigMeta` trait with `const SECRET_FIELDS: &'static [SecretField]`; `SecretField { name, kind }`; `SecretKind { KeyInDefault, StoreRef }`; `AppConfigError`; `AppConfigLoadOptions { env_overlay: bool }` with `Default` = `{ env_overlay: true }`. + + Loader API — **one consistent shape, no hidden bool param.** The simple functions apply the env overlay (the default); the `_with_options` variants take `AppConfigLoadOptions` explicitly: + - `load_app_config(path, app_name) -> Result` — overlay on. + - `load_app_config_with_options(path, app_name, opts: &AppConfigLoadOptions) -> Result`. + - `load_app_config_raw(path, app_name) -> Result` — overlay on. + - `load_app_config_raw_with_options(path, app_name, opts: &AppConfigLoadOptions) -> Result`. + + The simple functions delegate to the `_with_options` form with `AppConfigLoadOptions::default()`. `--no-env` (Tasks 4.1 / 7.1) calls the `_with_options` variant with `env_overlay: false`. `load_app_config*` parses the file's top-level table, applies the env overlay when `opts.env_overlay`, then (typed) deserializes + `validate()`. `pub mod app_config;` in `lib.rs`. + +- [ ] **Step 4: Run** — PASS. + +### Task 3.2: `AppConfig` derive macro + +**Files:** + +- Create: `crates/edgezero-macros/src/app_config.rs`; Modify: `crates/edgezero-macros/src/lib.rs`, `crates/edgezero-core/src/lib.rs` + +**Macro availability — chosen route: re-export through `edgezero-core`.** +`edgezero-core` already re-exports the `action` and `app` proc-macros +from `edgezero-macros` (handlers do `use edgezero_core::action`). +`AppConfig` follows the _same_ route: the derive is defined in +`edgezero-macros` and **re-exported from `edgezero-core`** so consumers +write `use edgezero_core::AppConfig`. Consequence: a crate that derives +`AppConfig` needs **only `edgezero-core`** as a dependency for the +macro — no direct `edgezero-macros` dependency. (`#[derive(Validate)]` +and `#[validate(...)]` still need the `validator` crate directly — see +Task 3.4 / 3.5.) + +- [ ] **Step 1a: Add the `trybuild` dev-dependency.** Compile-fail tests need `trybuild`; `crates/edgezero-macros/Cargo.toml` currently has only `tempfile` under `[dev-dependencies]`. Add `trybuild = "1"` to `[dev-dependencies]` there (and to `[workspace.dependencies]` in the root `Cargo.toml` if the workspace pins dev-deps centrally — check first and follow the existing convention). + +- [ ] **Step 1b: Write macro tests** in `crates/edgezero-macros/tests/app_config_derive.rs`: empty `SECRET_FIELDS` with no annotation; one `KeyInDefault` from `#[secret]`; one `StoreRef` from `#[secret(store_ref)]`; both kinds. Add a `trybuild` compile-fail harness — `let t = trybuild::TestCases::new(); t.compile_fail("tests/ui/*.rs");` — with one `tests/ui/*.rs` fixture per rejected case: `#[secret]` + `#[serde(flatten)]`, `#[secret]` + `#[serde(rename)]`, `#[secret(bogus)]`, `#[secret]` on a non-scalar field. Each fixture has a matching `.stderr` golden file (generate with `TRYBUILD=overwrite` once the `compile_error!` messages are final). + +- [ ] **Step 2: Run** — FAIL. + +- [ ] **Step 3: Implement.** `#[proc_macro_derive(AppConfig, attributes(secret))]` in `edgezero-macros/src/lib.rs` delegating to `app_config::derive`. The impl scans fields for `#[secret]` / `#[secret(store_ref)]`, enforces the §6.7 constraints with `compile_error!`, and emits `impl ::edgezero_core::app_config::AppConfigMeta` with the `SECRET_FIELDS` array (Rust field name verbatim). **Also re-export it from `edgezero-core/src/lib.rs`** — `pub use edgezero_macros::AppConfig;` — next to the existing `action` / `app` re-exports, so downstream code uses `edgezero_core::AppConfig`. + +- [ ] **Step 4: Run** — PASS. + +### Task 3.3: Env-overlay resolution + +**Files:** + +- Modify: `crates/edgezero-core/src/app_config.rs` + +- [ ] **Step 1: Write tests:** `APP_DEMO__GREETING` overrides a top-level key; `APP_DEMO__SERVICE__TIMEOUT_MS` overrides a nested key; type coercion against the existing TOML value; a non-parseable value errors; two sibling keys mapping to the same env segment errors; `load_app_config_with_options` with `AppConfigLoadOptions { env_overlay: false }` skips the overlay entirely. + +- [ ] **Step 2: Run** — FAIL. + +- [ ] **Step 3: Implement** per §6.10: walk the parsed root table; for each existing key compute `__
__…__` (uppercase, `-`→`_`, `__` separators); look up the env var; coerce to the existing value's type; reject ambiguous sibling mappings. + +- [ ] **Step 4: Run** — PASS. + +### Task 3.4: Generator templates for app-config + +**Files:** + +- Create: `crates/edgezero-cli/src/templates/app/.toml.hbs`, `crates/edgezero-cli/src/templates/core/src/config.rs.hbs` +- Modify: `crates/edgezero-cli/src/templates/core/Cargo.toml.hbs`, `crates/edgezero-cli/src/generator.rs`, `scaffold.rs` + +- [ ] **Step 1: Add a `NameUpperCamel` key to the generator Handlebars context.** The config templates name the struct `{{NameUpperCamel}}Config` (e.g. `my-app` → `MyAppConfig`), and the CLI template in stage 8 reuses the same key. The generator's Handlebars data today exposes only `name`, `proj_core`, `proj_core_mod`, `proj_mod` (`generator.rs`). + + Derivation — **must yield a valid Rust type identifier** (the result is used as `{{NameUpperCamel}}Config`, a `struct` name): + 1. Start from the **sanitized** crate name (reuse `sanitize_crate_name` from `scaffold.rs`, so it stays consistent with the crate name). + 2. Split on `-` and `_`; drop empty segments (this naturally absorbs a leading `_` that `sanitize_crate_name` may have inserted). + 3. Upper-case the first character of each segment, lower-case the rest; join. + 4. **If the result is empty, or its first character is not an ASCII letter** (e.g. the project name started with a digit, giving something like `123App`), prefix it with `App`. A Rust type name cannot begin with a digit. + + Insert the result under the context key `NameUpperCamel`. Add a unit test covering: `my-app` → `MyApp`; `foo` → `Foo`; `a_b-c` → `ABC`; `_foo` → `Foo` (empty leading segment dropped); `123-app` → `App123App` (digit-leading → `App` prefix). This key lands here in stage 3 because `config.rs.hbs` is its first consumer; stage 8's `templates/cli/` reuses it. + +- [ ] **Step 2:** `app/.toml.hbs` — top-level keys (`greeting`, `api_token`, etc.) and a nested `[service]` table; no `[config]` wrapper. `core/src/config.rs.hbs` — `{{NameUpperCamel}}Config` with `#[derive(serde::Deserialize, serde::Serialize, validator::Validate, edgezero_core::AppConfig)]` + `#[serde(deny_unknown_fields)]`, a `greeting` field, a nested `service: ServiceConfig` field carrying `#[validate(nested)]`, **one plain `#[secret]` field**, and a commented-out `#[secret(store_ref)]` example (§6.8 — the generated template does not include `store_ref` live). + +- [ ] **Step 3: Update `templates/core/Cargo.toml.hbs` deps + the workspace-dep seed.** The generated config struct needs `validator` (for `#[derive(Validate)]` / `#[validate(...)]`) and `serde` with the `derive` feature. The `AppConfig` derive comes via the `edgezero-core` re-export (Task 3.2) — the core template already depends on `edgezero-core`, so **no `edgezero-macros` dependency is added**. Add `validator = { workspace = true }` to `templates/core/Cargo.toml.hbs` (it currently lacks it); confirm `serde` is present with `features = ["derive"]`. Because the generated project is itself a workspace, a `workspace = true` dep only resolves if the generated **root** `Cargo.toml` lists it: add `validator` to the generator's workspace-dependency seed (the `seed_workspace_dependencies` function / data in `generator.rs` — confirm the exact name by reading the file; it seeds the generated root `[workspace.dependencies]` and does **not** include `validator` today). Match whatever version-pin the seed already uses for `serde` etc. + +- [ ] **Step 4:** Render both new templates in `generate_new`; register them in `scaffold.rs`. + +- [ ] **Step 5: Write/extend the generator test** to assert `.toml` and `-core/src/config.rs` are produced, the struct name is `{{NameUpperCamel}}Config` for the test project name, **and** that the generated `-core` builds (the seeded `validator` dep resolves and `edgezero_core::AppConfig` is in scope) — `cargo check -p -core` in the scaffolded project. + +- [ ] **Step 6: Run** the generator test — PASS. + +### Task 3.5: `app-demo` app-config + commit + +**Files:** + +- Create: `examples/app-demo/app-demo.toml`, `examples/app-demo/crates/app-demo-core/src/config.rs` +- Modify: `examples/app-demo/crates/app-demo-core/src/lib.rs`, `examples/app-demo/crates/app-demo-core/Cargo.toml` (verify deps), `docs/guide/configuration.md`, `getting-started.md` + +- [ ] **Step 1:** Write `app-demo.toml` — top-level `greeting`, `api_token` (a `#[secret]` value), `vault` (a `#[secret(store_ref)]` value = the single secrets id); a `[feature]` sub-table containing `new_checkout` (mirrors the dotted config-store key `feature.new_checkout` the handler reads, and the per-adapter `feature__new_checkout` Spin seed); a `[service]` table with `timeout_ms`. No `[config]` wrapper. Write `app-demo-core/src/config.rs` — `AppDemoConfig` with the §6.8 shape (nested `FeatureConfig` + `ServiceConfig` carrying `#[validate(nested)]`, one `#[secret]`, one `#[secret(store_ref)]`), deriving `serde::{Deserialize, Serialize}`, `validator::Validate`, `edgezero_core::AppConfig`. Export it from `lib.rs`. **Verify `app-demo-core/Cargo.toml` deps:** it must have `edgezero-core` (for the `AppConfig` re-export), `validator`, and `serde` with `derive`. `app-demo-core` already depends on all three today — confirm and add any that are somehow missing. No `edgezero-macros` dependency is needed (macro comes via the `edgezero-core` re-export, Task 3.2). + +- [ ] **Step 2: Write a round-trip test** in `app-demo-core`: `load_app_config::` against `app-demo.toml` succeeds; `AppDemoConfig::SECRET_FIELDS` has the expected two entries; an env var overrides the nested value. + +- [ ] **Step 3:** Update `configuration.md` (app-config file + env overlay) and `getting-started.md` (generator now emits `.toml`). + +- [ ] **Step 4: Run** the full gate. **Commit:** `git commit -m "App-config schema, #[derive(AppConfig)] macro, env-overlay loader"` + +--- + +# Stage 4 — `config validate` command + +Spec §10. New: `ConfigValidateArgs`, `run_config_validate`, `run_config_validate_typed`. + +### Task 4.1: `config validate` implementation + +**Files:** + +- Modify: `crates/edgezero-cli/src/args.rs` (add `ConfigValidateArgs` + a `ConfigCmd` subcommand enum), `crates/edgezero-cli/src/lib.rs` +- Create: `crates/edgezero-cli/src/config.rs` + +- [ ] **Step 1: Write failing tests** with fixtures for each failure mode (§10): valid passes; bad TOML; unknown field (struct with `deny_unknown_fields`); type mismatch; validator-rule failure; empty `#[secret]`; `#[secret(store_ref)]` value not in `[stores.secrets].ids`; missing per-adapter mapping; the three Spin checks (key syntax, collision — typed-only, component discovery). + +- [ ] **Step 2: Run** — FAIL. + +- [ ] **Step 3: Implement** `ConfigValidateArgs { manifest, app_config, strict, no_env }` (`#[derive(clap::Args, Default, Debug)] #[non_exhaustive]`). `run_config_validate` (raw) and `run_config_validate_typed` in `config.rs`. Raw does TOML + manifest checks + Spin key-syntax + component discovery; typed adds deserialize + `validate()` + secret checks + the collision check. Both run manifest `ManifestLoader` validation; `--strict` adds capability completeness + handler-path checks. + +- [ ] **Step 4: Run** — PASS. + +### Task 4.2: Wire `config` into the default `edgezero` binary + +**Files:** + +- Modify: `crates/edgezero-cli/src/args.rs` (`Command` enum), `crates/edgezero-cli/src/main.rs` + +The spec (§1, §8) requires the new subcommands to be available on the +**default `edgezero` binary**, not only on `app-demo-cli`. The default +binary has no app-config struct, so it uses the **raw** functions. + +- [ ] **Step 1:** Add `Config(ConfigCmd)` to the default `edgezero-cli` `Command` enum in `args.rs` (the same `ConfigCmd` subcommand enum from Task 4.1; `ConfigCmd::Validate(ConfigValidateArgs)` for now, `Push` added in stage 7). + +- [ ] **Step 2:** Add the dispatch arm in `main.rs`: `Command::Config(ConfigCmd::Validate(a)) => exit_on_err(edgezero_cli::run_config_validate(&a))` — the **raw** validator (the default binary has no `C`). + +- [ ] **Step 3: Write a test** (in `args.rs` or an integration test): `Args::try_parse_from(["edgezero", "config", "validate", "--strict"])` parses to `Command::Config(ConfigCmd::Validate(_))`; and `cargo run -p edgezero-cli -- --help` lists `config`. + +- [ ] **Step 4: Run** `cargo test -p edgezero-cli && cargo build -p edgezero-cli && ./target/debug/edgezero config validate --help` — expect PASS / the subcommand help. + +### Task 4.3: Wire `app-demo-cli config validate` + docs + commit + +**Files:** + +- Modify: `examples/app-demo/crates/app-demo-cli/Cargo.toml`, `examples/app-demo/crates/app-demo-cli/src/main.rs`, `docs/guide/cli-reference.md` + +- [ ] **Step 1: Add the `app-demo-core` dependency.** `app-demo-cli` is about to reference `AppDemoConfig`, which lives in `app-demo-core` (created in stage 3, Task 3.5). Its `Cargo.toml` so far has only `edgezero-cli` / `clap` / `log` (Task 1.5). Add `app-demo-core = { path = "../app-demo-core" }` to `app-demo-cli/Cargo.toml` (path dep within the `examples/app-demo` workspace). + +- [ ] **Step 2:** Add a `Config(ConfigCmd)` arm to `app-demo-cli`'s `Cmd` enum with `ConfigCmd { Validate(ConfigValidateArgs) }` (push added in stage 7). `use app_demo_core::AppDemoConfig;` and dispatch `Validate` to `edgezero_cli::run_config_validate_typed::` — the **typed** validator (`app-demo-cli` knows `AppDemoConfig`). + +- [ ] **Step 3:** Document `config validate` in `cli-reference.md` — note the default `edgezero` binary runs the raw validator, downstream CLIs the typed one. + +- [ ] **Step 4: Run** the full gate; `cd examples/app-demo && cargo run -p app-demo-cli -- config validate --strict` exits 0; `./target/debug/edgezero config validate --strict` (raw path) also exits 0 against a fixture. **Commit:** `git commit -m "config validate command (raw + typed)"` + +--- + +# Stage 5 — `auth` command (adapter-trait dispatch) + +Spec §11, §6.1. + +### Task 5.1: Extend `AdapterAction` with the auth variants + +The original sketch placed a `CommandRunner` indirection inside +`edgezero-cli`. That duplicated the adapter-name knowledge `build` / +`deploy` / `serve` deliberately keep out of the CLI — they read +commands from the manifest first, then fall back to the adapter +crate's `Adapter::execute`. Auth follows the same path. + +**Files:** + +- Modify: `crates/edgezero-adapter/src/registry.rs` (`AdapterAction` enum) +- Modify: each `crates/edgezero-adapter-*/src/cli.rs` (`Adapter::execute` match) +- Modify: `crates/edgezero-core/src/manifest.rs` (`ManifestAdapterCommands` fields) +- Modify: `crates/edgezero-cli/src/adapter.rs` (`Action` enum + `manifest_command` lookup) + +- [ ] **Step 1:** Extend `AdapterAction` with `AuthLogin` / `AuthLogout` / `AuthStatus`. +- [ ] **Step 2:** Each `edgezero-adapter-*/src/cli.rs` adds match arms for the new variants and implements its own dispatch (cloudflare shells to `wrangler login/logout/whoami`, fastly to `fastly profile create/delete/list`, spin to `spin cloud login/logout/info`, axum no-ops). +- [ ] **Step 3:** Extend `ManifestAdapterCommands` with `auth_login` / `auth_logout` / `auth_status` (serde-renamed to `auth-login` / `auth-logout` / `auth-status` on disk), and `edgezero-cli/src/adapter.rs::manifest_command` to look them up. +- [ ] **Step 4: Run** — workspace compiles, no auth dispatch yet. + +### Task 5.2: `auth` command + docs + commit + +**Files:** + +- Modify: `crates/edgezero-cli/src/args.rs` (`AuthArgs`, `AuthSub`), `lib.rs` +- Create: `crates/edgezero-cli/src/auth.rs` +- Modify: `examples/app-demo/crates/app-demo-cli/src/main.rs`, `docs/guide/cli-reference.md` + +- [ ] **Step 1: Write tests** mirroring the existing `run_build_executes_manifest_command` pattern: configure `[adapters.fastly.commands].auth-login = "echo logged in"` (etc.) in a fixture manifest, call `run_auth(&AuthArgs { sub: AuthSub::Login { adapter: "fastly" } })`, assert success. Add an "unknown adapter errors" case. + +- [ ] **Step 2: Run** — FAIL (no `run_auth` yet). + +- [ ] **Step 3: Implement.** `AuthArgs { sub: AuthSub }` — `#[derive(clap::Args, Debug)] #[non_exhaustive]`, **no `Default`** (§6.11). `AuthSub { Login{adapter}, Logout{adapter}, Status{adapter} }`. `crates/edgezero-cli/src/auth.rs::run_auth` is a five-line delegate to `adapter::execute(name, Action::Auth{Login,Logout,Status}, manifest, &[])`. No `CommandRunner`; no `MockCommandRunner`; no hard-coded `(adapter, sub) → (program, args)` table in the CLI crate. + +- [ ] **Step 4: Run** — PASS. Document `auth` in `cli-reference.md` (built-ins + per-project override via `[adapters..commands].auth-{login,logout,status}`). + +- [ ] **Step 5: Wire both binaries.** Add `Auth(AuthArgs)` to the **default `edgezero-cli` `Command` enum** (`args.rs`) and a dispatch arm in `main.rs`: `Command::Auth(a) => exit_on_err(edgezero_cli::run_auth(&a))`. Also add `Auth(AuthArgs)` to `app-demo-cli`'s `Cmd` enum and dispatch it to `run_auth`. Write a test that `Args::try_parse_from(["edgezero", "auth", "login", "--adapter", "cloudflare"])` parses and that `edgezero --help` lists `auth`. + +- [ ] **Step 6: Run** the full gate; `./target/debug/edgezero auth --help` shows the `login`/`logout`/`status` subcommands. **Commit:** `git commit -m "auth command (adapter-trait dispatch, no hardcoded table)"` + +--- + +# Stage 6 — `provision` command + +Spec §12, §13 (Fastly contract). + +### Task 6.1: `provision` implementation + +**Files:** + +- Modify: `crates/edgezero-cli/src/args.rs` (`ProvisionArgs`), `lib.rs` +- Create: `crates/edgezero-cli/src/provision.rs` + +- [ ] **Step 1: Write tests** following Stage 5's pattern: each adapter crate's tests own the per-(adapter, kind) writeback assertions (temp-fixture writeback for `wrangler.toml`, `fastly.toml`, and the Spin `key_value_stores` array in `spin.toml`; axum no-op). The CLI test asserts `run_provision` dispatches to the right adapter and that `--dry-run` short-circuits without spawning. + +- [ ] **Step 2: Run** — FAIL. + +- [ ] **Step 3: Implement** `ProvisionArgs { manifest, adapter, dry_run }`. Extend `AdapterAction` with a `Provision` variant (or a small `ProvisionKind` payload if per-store-kind dispatch is needed). Each adapter crate's `Adapter::execute` implements its own §12 behaviour: axum no-op; cloudflare `wrangler kv namespace create` + `wrangler.toml` `[[kv_namespaces]]` writeback; fastly `fastly -store create` + `[setup.*]`/`[local_server.*]` `fastly.toml` writeback; spin KV-label `spin.toml` writeback only (component resolved per §6.7). CLI's `provision.rs` is a thin args→action delegate to `adapter::execute`, same shape as `auth.rs`. + +- [ ] **Step 4: Run** — PASS. Document `provision` in `cli-reference.md`. + +- [ ] **Step 5: Wire both binaries.** Add `Provision(ProvisionArgs)` to the **default `edgezero-cli` `Command` enum** (`args.rs`) and a dispatch arm in `main.rs`: `Command::Provision(a) => exit_on_err(edgezero_cli::run_provision(&a))`. Also add `Provision(ProvisionArgs)` to `app-demo-cli`'s `Cmd` enum, dispatched to `run_provision`. Write a test that `Args::try_parse_from(["edgezero", "provision", "--adapter", "cloudflare", "--dry-run"])` parses and that `edgezero --help` lists `provision`. + +- [ ] **Step 6: Run** the full gate; `./target/debug/edgezero provision --adapter cloudflare --dry-run` runs. **Commit:** `git commit -m "provision command (cloudflare/fastly/spin writeback, axum no-op)"` + +--- + +# Stage 7 — `config push` command + +Spec §13, §6.4, §6.5. + +### Task 7.1: `config push` implementation + +**Files:** + +- Modify: `crates/edgezero-cli/src/args.rs` (`ConfigPushArgs`, extend `ConfigCmd`), `lib.rs`, `crates/edgezero-cli/src/config.rs` + +- [ ] **Step 1: Write tests:** typed + raw; per-adapter mock-runner/fixture with golden payloads; secret fields absent; missing native-manifest id (cloudflare) → clear error; Spin `.`→`__` translation; Spin writes both `spin.toml` tables; Spin component-resolution failure errors; `--store` selection; `--dry-run` invokes nothing; the §13 "validate passes, push serialization fails" cases; the Spin `spin.toml` golden test (strongest-first validation ladder, §13). + +- [ ] **Step 2: Run** — FAIL. + +- [ ] **Step 3: Implement** `ConfigPushArgs { manifest, adapter, store, app_config, no_env, dry_run }`. `run_config_push` / `run_config_push_typed`: strict pre-flight validation, load app-config, flatten + serialize per §6.4/§6.5 (skip `SECRET_FIELDS`), resolve target id, push per the §13 per-adapter table (axum local JSON file; cloudflare `wrangler kv bulk put`; fastly `config-store-entry create`; spin both `spin.toml` tables). + +- [ ] **Step 4: Run** — PASS. + +### Task 7.2: Wire `config push` into both binaries + docs + commit + +**Files:** + +- Modify: `crates/edgezero-cli/src/args.rs` (`ConfigCmd`), `crates/edgezero-cli/src/main.rs`, `examples/app-demo/crates/app-demo-cli/src/main.rs`, `docs/guide/cli-reference.md`, `configuration.md` + +- [ ] **Step 1: Default `edgezero` binary.** Extend the `ConfigCmd` enum (defined in Task 4.1, used by the default `Command::Config` arm from Task 4.2) with `Push(ConfigPushArgs)`. Add the dispatch arm in `main.rs`: `Command::Config(ConfigCmd::Push(a)) => exit_on_err(edgezero_cli::run_config_push(&a))` — the **raw** push. + +- [ ] **Step 2: `app-demo-cli`.** Extend `app-demo-cli`'s `ConfigCmd` with `Push(ConfigPushArgs)`; dispatch to `run_config_push_typed::` — the **typed** push. + +- [ ] **Step 3:** Write a test that `Args::try_parse_from(["edgezero", "config", "push", "--adapter", "axum"])` parses to `Command::Config(ConfigCmd::Push(_))` and that `edgezero config --help` lists both `validate` and `push`. + +- [ ] **Step 4:** Document `config push` in `cli-reference.md` (note raw vs typed per binary); cross-reference from `configuration.md`. + +- [ ] **Step 5: Run** the full gate. **Commit:** `git commit -m "config push command (per-adapter, secret-skipping, env overlay)"` + +--- + +# Stage 8 — `app-demo` integration polish + docs audit + +Spec §15, §6.12. + +### Task 8.1: Full `app-demo` capability exercise + +**Files:** + +- Modify: `examples/app-demo/crates/app-demo-cli/src/main.rs`, `examples/app-demo/crates/app-demo-core/src/handlers.rs`, `examples/app-demo/edgezero.toml`, `examples/app-demo/app-demo.toml`, `examples/app-demo/crates/app-demo-adapter-spin/spin.toml` + +- [ ] **Step 1:** Confirm `app-demo-cli`'s `Cmd` has the four downstream built-ins + `Auth` + `Provision` + `Config(Validate|Push)`. Ensure handlers exercise: two named KV ids (`sessions`, `cache`) via `Kv::named`; async `config_store_default().get("greeting")`; the nested `service.timeout_ms`; both secret forms. Add the manual Spin secret-variable declarations to `app-demo-adapter-spin/spin.toml` (`secret = true`, bound under `[component..variables]`). + +- [ ] **Step 2: Write integration tests** in `app-demo`: `config validate --strict` exits 0; `config push --adapter axum` writes `.edgezero/local-config-app_config.json` and a running demo server returns `greeting` on `/config/greeting`; `config push --adapter spin --dry-run` **prints** the would-be `__`-encoded keys and the would-be content of both `spin.toml` tables — and the test asserts the on-disk `spin.toml` is **unchanged** (dry-run never mutates); an env-override test asserts `APP_DEMO__SERVICE__TIMEOUT_MS` takes effect. + + **Demo-server lifecycle (required, to keep the e2e test non-flaky):** + - **Port:** do not hard-code `8787`. Bind an ephemeral port — either bind `127.0.0.1:0` and read back the assigned port, or pick a free port in the test and pass it to the server. Concurrent CI jobs must not collide. + - **Readiness:** after spawning the server, poll `GET /` (or a health route) with a short retry loop — e.g. up to ~50 attempts, 100ms apart (~5s budget) — and only proceed once a request succeeds. Never use a bare `sleep`. + - **Teardown:** spawn the server as a child process and kill it in an RAII guard (a struct that holds the `Child` and calls `.kill()` + `.wait()` in `Drop`), so it is reaped even when an assertion fails or panics. Also clean up the `.edgezero/local-config-*.json` files the test wrote. + +- [ ] **Step 3: Run** `cd examples/app-demo && cargo test` — PASS. + +### Task 8.2: Upgrade the generated `-cli` template to the full command set + +**Files:** + +- Modify: `crates/edgezero-cli/src/templates/cli/Cargo.toml.hbs`, `crates/edgezero-cli/src/templates/cli/src/main.rs.hbs`, `crates/edgezero-cli/src/generator.rs` (tests) + +Stage 1 created the `-cli` template with only the four downstream +built-ins (`auth` / `provision` / `config` did not exist yet). Now that +stages 4–7 have landed them, a freshly-scaffolded project must expose +the full command surface (spec §1: downstream CLIs reuse the +post-effort built-ins). + +- [ ] **Step 1: Add the core-crate dependency to the CLI template.** The full-command template references the typed config functions with `{{NameUpperCamel}}Config`, which lives in the generated `{{name}}-core` crate. The `templates/cli/Cargo.toml.hbs` from stage 1 depends only on `edgezero-cli` / `clap` / `log` — add `{{name}}-core = { path = "../{{name}}-core" }` (path dep within the generated workspace). Without this the scaffolded CLI will not compile. + +- [ ] **Step 2:** Update `templates/cli/src/main.rs.hbs` so the generated `Cmd` enum lists **all seven** commands: `Build`, `Deploy`, `New`, `Serve`, `Auth`, `Provision`, `Config(ConfigCmd { Validate, Push })`. Dispatch `build/deploy/new/serve/auth/provision` to the raw `edgezero_cli::run_*`. The `use` statement must reference the core crate's **Rust module name**, not the package name — use `use {{proj_core_mod}}::{{NameUpperCamel}}Config;` (the generator already exposes `proj_core_mod`, the hyphen-to-underscore module form; `{{name}}_core` would render `my-app_core` for `my-app`, which is invalid Rust). Dispatch the `Config` arm to the **typed** `run_config_validate_typed::<{{NameUpperCamel}}Config>` / `run_config_push_typed::<{{NameUpperCamel}}Config>` — a generated project has its own core config struct (from the Task 3.4 `config.rs.hbs` template), so the scaffold wires the typed path, matching how `app-demo-cli` does it. + +- [ ] **Step 3:** Extend the generator structure test (from Task 1.4 / 3.4): the scaffolded `-cli/Cargo.toml` depends on `-core`; `-cli/src/main.rs` contains `Auth`, `Provision`, and `Config` variants and references the typed config functions with the project's config type. + +- [ ] **Step 4: Run** the generator tests, then `cargo run -p edgezero-cli -- new --dir …` and `cargo check --workspace` in the generated project — the scaffolded CLI builds with all seven commands **and** resolves `{{NameUpperCamel}}Config` from its core crate. + +### Task 8.3: CI wiring for the `app-demo` loop + +**Files:** + +- Modify: `.github/workflows/test.yml` (or `scripts/run_tests.sh`) + +- [x] **Step 1:** CI now builds `app-demo` via a dedicated `cd examples/app-demo && cargo test --workspace --all-targets` step in `test.yml`, plus a parallel `cargo fmt`/`cargo clippy` pass in `format.yml`. The end-to-end axum loop is expressed **as a Rust integration test inside `app-demo`** (Task 8.1 `app-demo` integration test) rather than as raw shell in the workflow — the Rust test already owns ephemeral-port binding, the readiness poll, and RAII teardown (Task 8.1 step 2). The CI job then just needs `cargo test`; it does not hand-roll `start server / curl / kill` in YAML, which is where shell-based e2e steps go flaky. Kept off the wasm matrix — axum only, no live external calls. + +- [ ] **Step 2:** If any loop step must stay as a shell step in the workflow (e.g. invoking the built `app-demo-cli` binary), it must still: select a free port (not a hard-coded one), poll readiness before curl-ing, and `kill` the server in a `trap`/`always()` cleanup so a failed assertion never leaves an orphan process. Mirror the Task 8.1 lifecycle rules. + +- [ ] **Step 3: Run** the workflow logic locally to confirm the loop passes and leaves no orphan processes or `.edgezero/` artifacts. + +### Task 8.4: Walkthrough doc + documentation audit + commit + +**Files:** + +- Create: `docs/guide/cli-walkthrough.md`; Modify: `docs/.vitepress/config.mts`, any pages still stale + +- [ ] **Step 1:** Write `docs/guide/cli-walkthrough.md` — the full `myapp` loop (`new`, `auth`, `provision`, `config validate`, `config push`, `deploy`, `demo`), an env-override example, all four adapters, the manual Spin secret-variable `spin.toml` entries, the explicit `[adapters.spin.adapter].component` form. Add it + `manifest-store-migration.md` to the `config.mts` sidebar. + +- [ ] **Step 2: Documentation audit** (§6.12): `grep -rn` the `docs/` tree for stale references — old `[stores.*]` keys (`stores.config.defaults`, `[stores.kv] name`), the `dev` subcommand, the old singular store API (`config_store()` with no arg, `kv_handle`, `secret_handle`). Fix every hit. Confirm every page in the §6.12 table was updated and every page is in the sidebar. + +- [ ] **Step 3: Run the full gate** (the five commands in "The full gate" above), plus all three per-adapter wasm `--test contract` runs (Task 2.7 step 6), `cd examples/app-demo && cargo test`, and the docs ESLint/Prettier job. All green. + +- [ ] **Step 4: Commit:** `git commit -m "app-demo full-capability showcase + documentation audit"` + +--- + +## Self-review notes + +- **Spec coverage:** §7→C1, §8/§6.6/§6.7/§6.9→C2, §9/§6.8/§6.10→C3, §10→C4, §11/§6.1→C5, §12→C6, §13/§6.4/§6.5→C7, §15/§6.12→C8. §6.3 (feature gates) is honored throughout. §6.11 (`Default` on `*Args`) is in Tasks 1.1, 4.1, 5.2, 6.1, 7.1. §6.12 docs are in every stage's final task. +- **Precondition:** PR #253 is a hard precondition for stage 2 — called out at the top and in the stage-2 header. +- **Bisectability:** each stage ends with a green-gate step before its commit step; stage 1 needs no PR #253; stage 2's axum config tests seed the JSON fixture directly (Task 2.7 step 1 — "absent ⇒ empty"; tests write the file). +- **Known drift risk:** stages 3–8's exact code depends on the `Bound*Store` / `StoreRegistry` shapes finalized in stage 2. Re-read stage 2's actual output before executing each later stage; adjust signatures to match. +- **`app-demo` in CI:** Task 8.3 adds the missing CI wiring — the spec's §15 ship gate assumed CI exercises `app-demo`, which it does not today. diff --git a/docs/superpowers/plans/2026-06-01-spin-kv-backed-config.md b/docs/superpowers/plans/2026-06-01-spin-kv-backed-config.md new file mode 100644 index 00000000..1e69d796 --- /dev/null +++ b/docs/superpowers/plans/2026-06-01-spin-kv-backed-config.md @@ -0,0 +1,1632 @@ +# Plan: Move Spin Config Store onto KV + +**Status:** v12 — REVISION after tenth reviewer pass. Ready for +execution. **Reviewer green-lighted start.** + +**Goal:** Back `SpinConfigStore` with the Spin KV API (`spin_sdk::key_value`) +instead of Spin variables (`spin_sdk::variables`). Bring Spin's config +surface into structural parity with Cloudflare (KV-backed) and Fastly +(Config Store-backed), so `config push` writes through a real per-store +backend on all three cloud adapters. + +## v12 changelog + +Round-10 reviewer gave the verdict "yes, we can start" and +flagged 1 Low + 1 Nit. Both fixed: + +- **L1 (Stage 4/5 should explicitly REPLACE stale Spin-variable + tests)** — fixed. The current tests assert translated keys + - `[variables]` + `[component..variables]` writes at + `examples/app-demo/crates/app-demo-cli/tests/config_flow.rs:257` + and `crates/edgezero-adapter-spin/src/cli.rs:1846`. The plan + implied replacement via Task 4.6 (dry-run shape) and + Task 5.1 (drop variables writes) but didn't say so explicitly. + Added Task 4.7 and Task 5.5 to spell out the test rewrite: + delete the translated-key / two-table assertions; add seed + URL / JSON-body / no-POST-on-dry-run / status-code coverage. +- **Nit (reworded "backward-compatible" around run_app return)** — + fixed. The migration is hard-cutoff; "backward-compatible" + wording suggested legacy Spin-variable support was being + preserved (it isn't). Reworded throughout to + "source-compatible with the generated scaffold handler + signature" — narrower, accurate. + +## v11 changelog + +Round-9 reviewer flagged 1 Medium + 1 Low against v10. Both real +and fixed: + +- **M1 (unused `IntoResponse` import after run_app signature change)** + — fixed. Today `crates/edgezero-adapter-spin/src/lib.rs` imports + `spin_sdk::http::{IntoResponse, Request as SpinRequest, Response +as SpinResponse}` because `run_app` returns + `impl spin_sdk::http::IntoResponse`. After Task 3.5 changes the + return to `SpinFullResponse`, `IntoResponse` is no longer + referenced and the wasm-clippy `-D warnings` gate would fail on + `unused_imports`. Added an explicit substep to Task 3.5: drop + `IntoResponse` from the import line. Documented in the Scope + section under `src/lib.rs` too. +- **L1 (Stage 8 smoke test not executable as written)** — fixed. + `spin up` is foreground/long-running; the v10 step list + couldn't be pasted into a script. Stage 8 now provides a real + shell snippet that backgrounds `spin up`, polls + `127.0.0.1:3000` with `curl --silent --fail` until ready (5s + timeout, fails the test cleanly), runs `config push --local`, + asserts the curl, and cleans up the spin process in a `trap` + so a failed assertion never leaves an orphan listener on + port 3000. + +## v10 changelog + +Round-8 reviewer flagged 1 High against v9. Real and fixed: + +- **H1 (seed branch Result type mismatch)** — fixed. In v9, + `handle_seed_request_spin` returned bare `SpinFullResponse` but + `run_app_with_seeder`'s seed branch was returning that value + while the fall-through `run_app::(req).await` returns + `anyhow::Result`. Mismatched arm types in + the `if/else` would not compile. + + **Resolution**: change `handle_seed_request_spin` to return + `anyhow::Result` so both arms produce the + same type. As a side benefit this drops the `.expect("static- +shaped seed response")` from v9's D10 example, which was a + latent panic in a request handler. Internal failures + (`into_core_request`, `from_core_response`) now propagate via + `?` and surface as runtime errors instead of panics. Updated + in D10, Scope (lib.rs), and Task 3.5. + +## v9 changelog + +Round-7 reviewer flagged 2 High + 1 Medium against v8. All three +are real and fixed: + +- **H1 (`#[non_exhaustive]` + struct-literal across crates)** — + settled in [D8 update](#d8-push-context-schema). Rust rejects + struct-literal construction of a `#[non_exhaustive]` type from + outside its defining crate. Added a builder API: + `AdapterPushContext::new()` (returns the default), plus + `with_seed_url` / `with_seed_token` / `with_local` chained + setters. The CLI's `dispatch_push` builds via the builder + pattern, never the struct literal. `#[non_exhaustive]` stays so + future field additions don't break out-of-tree adapter + implementers (who only RECEIVE it via the trait method anyway). +- **H2 (`run_app_with_seeder` return-type mismatch with `run_app`)** — + settled. Today `run_app` returns + `anyhow::Result`; the opaque return type + can't be implicitly converted to a concrete `SpinFullResponse`, + so `run_app_with_seeder`'s fallthrough `run_app::(req).await` + wouldn't compile. **Resolution: change `run_app` to return + `anyhow::Result`** (the concrete type already + publicly aliased in `lib.rs`). This is **source-compatible with + the generated scaffold handler signature** (NOT a legacy-Spin- + variable carve-out — this migration is still hard-cutoff). The + existing template handler signature + `async fn handle(req: Request) -> anyhow::Result` + keeps compiling because `SpinFullResponse: IntoResponse`, so the + scaffold doesn't need re-running. Both `run_app` and + `run_app_with_seeder` now return the same concrete type, and + the fallthrough is a direct return. + Documented in D9 + Scope + Task 3.5. +- **M1 (D12 401 message omits short-token case)** — settled in + [D12 update](#d12-blocking-http-client). The 401 arm's message + now spells out all four fail-closed reasons (unset / blank / + whitespace-only / shorter than 16 bytes) so an operator who + set a 4-character placeholder doesn't waste time debugging the + wrong side. + +## v8 changelog + +Round-7 reviewer flagged 1 High + 1 Medium + 1 Low against v7. +Triage: + +- **H1 (D1 `label` field unused)** — **already fixed in v7 on + disk.** The reviewer was reading a stale snapshot. Line 329 of + the v7 file matches `SpinConfigBackend::Spin { label, store }` + and the error messages include `store \`{label}\`:`. No change + in v8. +- **M1 (Stage 3.5 stale)** — **already fixed in v7 on disk.** + Same stale-snapshot issue. Task 3.5 in v7 spells out + `anyhow::Result`, the template body swap, and + "unset / blank / shorter than 16 bytes" fail-closed behavior. + No change in v8. +- **L1 (D10 prose test list out-of-sync with Task 3.2)** — + **real.** Fixed in v8. D10's narrative list expanded to match + Task 3.2's full row set, grouped by surface (auth / + request-shape / store-resolution / write). Added a + "keep-in-sync" note so the two lists can't drift again. + +## v7 changelog + +Round-6 reviewer flagged 1 High + 3 Medium against v6. All addressed: + +- **H1 (Stage 8 smoke test would 401 itself)** — fixed. `test-token` + is 10 bytes and falls below v6's 16-byte floor, so the smoke test + would hit the fail-closed 401 path before any real KV write + happens. Replaced with `test-token-1234567890` (21 bytes) in both + the `spin up` env and the `app-demo-cli config push` env. +- **M1 (Stage 3 doesn't pin the 16-byte rule with a test)** — + fixed. Added explicit test rows to Task 3.2 covering + short-server-token paths: token unset → 401; token blank / + whitespace-only → 401; token 15 bytes → 401 (just under the + floor); token 16 bytes (offered correct on the wire) → 204 (just + at the floor). Task 3.5 explicitly references the floor check + when resolving `EDGEZERO__ADAPTERS__SPIN__SEED_TOKEN`. +- **M2 (`run_app_with_seeder` return shape mismatch with template)** — + fixed. Spec'd as `anyhow::Result` to mirror + the existing `run_app` shape and the scaffold template handler. + Operators can switch from `run_app::(req).await` to + `run_app_with_seeder::(req).await` with no signature change + on the `#[http_service]` handler. +- **M/L (`label` unused in `SpinConfigBackend::Spin`)** — fixed. + D1's `get` impl now uses `&self.label` in the unavailable error + messages so the field is read (no `-D warnings` dead-code + failure) AND so error logs name which platform store fired the + error — useful when the operator has multiple config stores. + +## v6 changelog + +Round-5 reviewer flagged 2 Medium + 2 Low + 1 Medium/Low against v5. +All addressed: + +- **M1 (Stage 1 acceptance vs Task 2.5)** — fixed. The Stage 1 + acceptance line previously said `config_store_contract_tests!` + must pass on host + wasm32-wasip2. Task 2.5 (v4 fix) correctly + scoped wasm KV out. Stage 1 now matches: "host-side + `config_store_contract_tests!` against the `InMemory` backend; + real KV write/read coverage lives in the Stage 8 `spin up` smoke + test". +- **M2 (token min-length still open)** — settled. **Q2 closed YES: + enforce a 16-byte minimum token at handler startup.** Below 16 + bytes (or unset/blank/whitespace-only) → fail-closed; every + request to the seed route returns 401. Cheap to implement, + prevents the worst accidental misconfiguration. D9 status table + updated to spell this out. Removed from open questions. +- **M/L (Cargo.toml scope checklist stale)** — fixed. The scope + line previously listed only `reqwest`; updated to mirror D11's + full set: `reqwest` (optional under `cli`), and non-optional + `serde` / `serde_json` / `subtle`. +- **L1 (Task 4.4 stale status list)** — fixed. The "Surface 401 / + 403 / 404 / 422" wording is replaced with "surface every D9 + status (400 / 401 / 403 / 404 / 405 / 415 / 422)" matching D12. +- **L2 (test backend uses `from_utf8_lossy`)** — fixed. The + `InMemory` config-store backend now uses strict UTF-8 (matches + production behavior). Added a doc comment + a "non-utf8 value + → unavailable" test to the contract-test fixture so the + divergence couldn't reappear. + +## v5 changelog + +Round-4 reviewer flagged 1 High + 4 Medium + 1 Low against v4. All +addressed: + +- **H1 (stale `build_config_registry` snippet)** — settled in + [Scope: edgezero-adapter-spin](#cratesedgezero-adapter-spin-the-heavy-crate) + and [Stage 2 Task 2.4](#stage-2--runtime-backend-swap--registry-rewrite). + Updated to async/error-propagating signature: returns + `anyhow::Result>`, awaits + `SpinConfigStore::open(...).await?` per id. The + `dispatch_with_registries` snippet shows + `build_config_registry(config_meta, env).await?`. +- **M1 (`PushContext` naming collision)** — settled. The trait-level + type is now **`AdapterPushContext`**; the CLI's internal + `PushContext` (config.rs:42) keeps its name. Updated everywhere + the new type is mentioned (D8, D12, Scope, Stages). +- **M2 (dispatch_push signature gap)** — settled in + [D8 update](#d8-push-context-schema). `load_push_context` now + resolves the `AdapterPushContext` upstream (it already takes + `&ConfigPushArgs` and reads `env` for store resolution; adding + the seed_url/token/local resolution there is natural). The + resolved `AdapterPushContext` is stashed in the CLI's + internal `PushContext` and `dispatch_push` reads it from there — + no signature change required on `dispatch_push` itself. +- **M3 (stale D9 wording about `subtle` gating)** — fixed. D9's + "gated under the spin feature" line removed; cross-reference to + D11 ("non-optional dep") added. +- **M4 (in-memory store key shape)** — settled in + [D1](#d1-backend-spin-kv-via-spin_sdkkey_valuestore) and + [Scope](#cratesedgezero-adapter-spin-the-heavy-crate). The + `InMemory` test backend is keyed plain `String → Bytes`. Removed + the conflicting "(label, key)" mention in the Scope section and + Task 2.2. The contract-test macro exercises one store at a time, + so plain `key → bytes` is enough. The handler-side + `InMemorySeedWriter` (D10) is the only place that needs to + distinguish stores — that one stays keyed `(label, key)` because + it serves multi-store seed requests. +- **L1 (version labels stale)** — fixed throughout: Stage 1 task + text now says "Move this plan into specs"; the open-questions + header is "(round 5)"; the settled-section header keeps "round 2" + as the historical pointer for when those decisions were taken. + +## v4 changelog + +Round-3 reviewer flagged 4 High + 2 Medium + 1 Low against v3. All +addressed: + +- **H1 (SpinConfigStore won't host-compile)** — settled in + [D1 update](#d1-backend-spin-kv-via-spin_sdkkey_valuestore). + Restored the cfg-gated backend enum pattern (matching the existing + shape in `config_store.rs`). Wasm variant holds the opened + `key_value::Store`; `InMemory` test variant holds a `BTreeMap`. + Construction is async on wasm, sync in tests. The trait `get` + dispatches on the variant. +- **H2 (`subtle` can't be wasm-only if core is host-tested)** — + settled in [D11 update](#d11-dependency-gating). Move `subtle` + out of the `spin` feature into a non-optional dependency. It's + tiny and compiles on both host and wasm; the host tests can + reach `subtle::ConstantTimeEq` without enabling `spin`. +- **H3 (JSON deps missing from scope)** — settled in + [D11 update](#d11-dependency-gating). Add `serde` + `serde_json` + as non-optional dependencies on `edgezero-adapter-spin`. Both + are already workspace deps; both compile on host AND wasm. CLI + POST body, seed handler core parser, and the migration story + all need them. +- **H4 (`--local` could fall back to manifest prod URL)** — + settled in [D3 update](#d3-config-push---local-for-spin) and + [D8 update](#d8-push-context-schema). `--local` short-circuits + the manifest fallback completely. New `PushContext::local: bool` + field. Resolution chain when `local = true`: `--seed-url` CLI + flag → `EDGEZERO__ADAPTERS__SPIN__LOCAL_SEED_URL` env → builtin + default `http://127.0.0.1:3000/__edgezero/config/seed`. NEVER + reads the manifest's prod `seed_url`. +- **M1 (Stage 2.5 overclaims wasm contract)** — settled. CI's spin + wasm matrix runs `wasmtime run`, which doesn't host Spin KV. + Task 2.5 now: host-side `config_store_contract_tests!` against + the `InMemory` backend. Real KV write/read coverage moves to the + end-to-end smoke test in Stage 8 that requires `spin up`. +- **M2 (CLI error mapping incomplete)** — settled in + [D12 update](#d12-blocking-http-client). The CLI match now + covers every intentional status: 400, 401, 403, 404, 405, 415, 422. Each gets a specific message. +- **L1 (`cargo tree | grep '^reqwest'` may miss prefixed entries)** + — settled in [Stage 8 update](#stage-8--verify-gate). Replace + with `cargo tree -i reqwest -p edgezero-adapter-spin --features +spin --target wasm32-wasip2` which errors when `reqwest` is not + in the tree at all (the desired outcome). Pair check uses the + same form for `subtle` (which MUST resolve). + +## v3 changelog + +Round-2 reviewer flagged 4 High + 2 Medium + 1 Low against v2. All +addressed: + +- **H1 (sync trait vs async reqwest)** — settled in + [D12](#d12-blocking-http-client). Use `reqwest::blocking::Client` + so the existing sync `Adapter::push_config_entries*` trait shape + is preserved. Workspace `reqwest` gets the `blocking` + `json` + features added. No runtime needs to be threaded through the + dispatcher. +- **H2 (`subtle` gated to wrong feature)** — settled. The token + comparison runs in the wasm **seed handler**, not in the host + CLI. Move `subtle` from `cli` to the `spin` feature in + `edgezero-adapter-spin/Cargo.toml`. D9 updated to reflect. +- **H3 (store validation vs env-remapped platform names)** — + settled in [D9 update](#d9-seed-handler-security). The seed + handler validates the body's `store` field against the set of + env-resolved **platform** labels (computed from + `A::stores().config` × `EnvConfig::store_name("config", id)`), + not the logical ids. Operators can run with + `EDGEZERO__STORES__CONFIG__APP_CONFIG__NAME=prod-config` and + push a body `{"store": "prod-config", ...}` — the validation + passes because that's the correct platform label. +- **H4 (host-testable seed signature)** — settled in + [D10 update](#d10-testable-seed-writer). Split the handler into + two layers: a host-compilable `handle_seed_request_core` that + takes `edgezero_core::http::Request` / returns + `edgezero_core::http::Response`, and a thin wasm wrapper that + translates Spin types ↔ core types and lives under the wasm + cfg gate. Unit tests target the core layer. +- **M1 (open-on-every-get)** — settled in [D1 update](#d1-backend-spin-kv-via-spin_sdkkey_valuestore). + `SpinConfigStore` holds the opened `key_value::Store` handle. + Construction is async, so `build_config_registry` becomes async + too (called from `dispatch_with_registries`, already async). + Missing `key_value_stores` declaration surfaces at registry + build time, not on first config read. +- **M2 (manifest `seed_url` is open but assumed)** — settled. + `[adapters.spin.commands].seed_url` IS a supported source. + Moved from open questions to settled. Resolution order codified + in D8. +- **L1 (`cargo tree | grep reqwest` exit-code semantics)** — + fixed in Stage 8: use `! cargo tree … | grep -q reqwest` so + the step fails ONLY when reqwest leaks into the wasm tree. + +## v2 changelog + +Reviewer flagged 4 High + 3 Medium + 1 Low against v1. All addressed: + +- **H1 (per-id config registry)** — added Stage 2 Task 2.4: rewrite + `build_config_registry` in `request.rs` to open one + `spin_sdk::key_value::Store` per declared id using + `env.store_name("config", id)` — mirroring the existing + `build_kv_registry`. The old "one shared handle cloned for every id" + shape goes away with Single→Multi. +- **H2 (seed URL/token transport schema)** — settled in new + [D8](#d8-push-context-schema). Adds `PushContext` to the + `push_config_entries*` trait signature, threads adapter command + metadata through `dispatch_push`, and gives `ConfigPushArgs` two + new CLI args (`--seed-url`, `--seed-token`) plus env fallbacks. +- **H3 (config-key validation)** — settled in + [D1.5](#d15-validator-relaxation). `validate_app_config_keys` + becomes a no-op for spin (KV accepts arbitrary key bytes). Existing + uppercase / dash / start-char tests are deleted; new tests pin + "any UTF-8 key passes". +- **H4 (seed handler security spec)** — settled in + [D9](#d9-seed-handler-security). POST-only, fail-closed on missing + or blank token, explicit status code table, and scaffolding is + opt-in (`run_app_with_seeder` is what the scaffold uses; existing + `run_app` is unchanged so downstream apps can opt out). +- **M1 (scaffold spin.toml key_value_stores)** — Stage 5 Task 5.4 + added: generator `spin.toml.hbs` declares + `key_value_stores = ["app_config"]` by default. `provision` + remains the safe path for already-scaffolded projects. +- **M2 (testable seed handler)** — settled in + [D10](#d10-testable-seed-writer). Introduces `trait SeedWriter` so + unit tests inject a fake; production uses a `SpinKvSeedWriter` + that calls the hostcall. +- **M3 (HTTP client gating)** — settled in + [D11](#d11-http-client-feature-gating). `reqwest` becomes a + `cli`-feature-only dep on `edgezero-adapter-spin` (native-only); + confirmed not pulled into the wasm target. Plan lists the exact + Cargo.toml edits. +- **L1 (legacy flag)** — settled. **No `--legacy-spin-variables` + flag.** Hard-cutoff matches the rest of the rewrite's posture. + Removed from open questions. + +Three remaining open questions for round 2 — see [Open questions](#open-questions-round-2). + +## Why + +Today `SpinConfigStore` wraps `spin_sdk::variables`. That has four +practical costs: + +1. **No dynamic config.** Spin variables are baked into `spin.toml` + at build time and override-able only via `SPIN_VARIABLE_` + env vars or `spin up --env`. Pushing a new value mid-run requires + a redeploy. +2. **Shared namespace with secrets.** `SpinSecretStore::get_bytes` + ALSO reads `spin_sdk::variables`, so config keys and `#[secret]` + values share the same flat namespace. We carry an explicit + collision-check in `validate_typed_secrets` to compensate + (`cli.rs:425-449`). +3. **Single-capable.** Spin is forced into the `single_store_kinds` + spec axis for config (one flat variable namespace per app) while + Cloudflare and Fastly are Multi. Operators can't have e.g. + `app_config` + `tenant_overrides` as two separate Spin stores. +4. **No platform parity.** `config push --adapter spin` edits + `spin.toml`; the other two cloud adapters shell out to a + platform-native bulk-write CLI (`fastly config-store-entry create` + / `wrangler kv bulk put`). The mental model split is real. + +KV-backed config fixes all four. + +## Design decisions + +### D1. Backend: Spin KV via `spin_sdk::key_value::Store` + +Runtime change in `crates/edgezero-adapter-spin/src/config_store.rs`: + +**v4**: keep the existing **cfg-gated backend enum** pattern from +today's `config_store.rs` so the file compiles on host (for tests) +without dragging in `spin_sdk` types. The wasm variant holds the +opened `key_value::Store`; the `InMemory` test variant holds a +`BTreeMap` (was `HashMap` in the +variables-backed impl). Construction is async on wasm, sync in +tests; the trait method dispatches on the variant. + +```rust +pub struct SpinConfigStore { + inner: SpinConfigBackend, +} + +enum SpinConfigBackend { + #[cfg(all(feature = "spin", target_arch = "wasm32"))] + Spin { + label: String, + store: spin_sdk::key_value::Store, // opened ONCE at dispatch + }, + #[cfg(test)] + InMemory(BTreeMap), + /// Never constructed; keeps the enum inhabited outside production Spin and tests. + #[cfg(not(any(all(feature = "spin", target_arch = "wasm32"), test)))] + _Uninhabited(std::convert::Infallible), +} + +impl SpinConfigStore { + /// Open the platform store once. Called from + /// `build_config_registry` during dispatch setup. Wasm-only; + /// tests use `from_entries`. + #[cfg(all(feature = "spin", target_arch = "wasm32"))] + pub async fn open(label: String) -> Result { + let store = spin_sdk::key_value::Store::open(&label).await + .map_err(|err| ConfigStoreError::unavailable(format!("open `{label}`: {err}")))?; + Ok(Self { inner: SpinConfigBackend::Spin { label, store } }) + } + + #[cfg(test)] + fn from_entries(entries: impl IntoIterator) -> Self { + Self { inner: SpinConfigBackend::InMemory(entries.into_iter().collect()) } + } +} + +#[async_trait(?Send)] +impl ConfigStore for SpinConfigStore { + async fn get(&self, key: &str) -> Result, ConfigStoreError> { + match &self.inner { + #[cfg(all(feature = "spin", target_arch = "wasm32"))] + SpinConfigBackend::Spin { label, store } => { + // v7 (round-6 M/L): use `label` in error wording so + // (a) the field isn't dead-code under -D warnings, + // (b) the operator running multi-store sees which + // platform store fired the failure. + match store.get(key).await { + Ok(Some(bytes)) => String::from_utf8(bytes).map(Some).map_err(|err| { + ConfigStoreError::unavailable(format!( + "store `{label}`: non-utf8 value for `{key}`: {err}" + )) + }), + Ok(None) => Ok(None), + Err(err) => Err(ConfigStoreError::unavailable(format!( + "store `{label}`: {err}" + ))), + } + } + #[cfg(test)] + SpinConfigBackend::InMemory(map) => match map.get(key) { + Some(bytes) => String::from_utf8(bytes.to_vec()).map(Some).map_err(|err| { + // v6 fix (L2): strict UTF-8 to match the wasm + // backend's behaviour. `from_utf8_lossy` would + // hide a divergence between test and prod. + ConfigStoreError::unavailable(format!("non-utf8 value for `{key}`: {err}")) + }), + None => Ok(None), + }, + #[cfg(not(any(all(feature = "spin", target_arch = "wasm32"), test)))] + SpinConfigBackend::_Uninhabited(never) => match *never {}, + } + } +} +``` + +Drops the `.→__` translation (KV accepts arbitrary key bytes). + +### D1.5. Validator relaxation + +Reviewer (H3): the existing `validate_app_config_keys` enforces Spin +variable syntax (lowercase, `^[a-z][a-z0-9_]*$` after `.→__`). With +KV-backed config, none of that applies — KV stores accept arbitrary +key bytes. + +Concrete change in `crates/edgezero-adapter-spin/src/cli.rs`: + +- `validate_app_config_keys`: collapses to `Ok(())`. The function stays + in place (trait shape) but no longer rejects anything. +- `translate_key_for_spin`: deleted. Callers (push, validator) read + keys verbatim. +- `is_valid_spin_key` / `spin_key_rule_violation`: stay — still used + by `validate_typed_secrets` for `#[secret]` value validation + (secrets still live in variables; see D7). +- Tests deleted (Stage 6 Task 6.1): + - `validate_app_config_keys_*` tests covering uppercase rejection, + dash rejection, leading-digit rejection, etc. +- Tests added (Stage 6 Task 6.2): + - `validate_app_config_keys_accepts_any_utf8` (covers `Greeting`, + `feature-flag`, `1numeric_start`, `with.dots`, `with spaces`). + +### D2. Push: HTTP POST to a seeding handler + +Spin has no `spin kv put` CLI subcommand and no bulk-write hostcall +reachable from outside the wasm runtime. Two options ruled out: + +- **Write Spin's SQLite KV file directly** — Spin doesn't guarantee + schema stability across versions. Brittle. +- **Wait for upstream `spin kv` CLI** — months of latency at best. + +So: the adapter ships a small **seeding handler** that +`app-demo-cli config push --adapter spin` HTTP-POSTs. + +### D3. `config push --local` for Spin + +With D2, `--local` and the default push both HTTP-POST to the +seeding handler, but the URL resolution chains are **strictly +disjoint** — `--local` never falls back to the manifest's prod URL. +This protects an operator who forgets to start `spin up` locally +from accidentally pushing to production. + +**Without `--local`** (prod push), `seed_url` resolves in order: + +1. `--seed-url` CLI arg. +2. `EDGEZERO__ADAPTERS__SPIN__SEED_URL` env. +3. `[adapters.spin.commands].seed_url` in `edgezero.toml`. + +Errors with a clear message if none are set. + +**With `--local`** (local push), `seed_url` resolves in order: + +1. `--seed-url` CLI arg (explicit operator override always wins). +2. `EDGEZERO__ADAPTERS__SPIN__LOCAL_SEED_URL` env (separate from + the prod env var — operators who set both don't accidentally + leak prod URL into local pushes). +3. Builtin default `http://127.0.0.1:3000/__edgezero/config/seed`. + +The manifest's `[adapters.spin.commands].seed_url` is **never read** +when `--local` is set. The dispatcher needs to know about +`args.local` before building `AdapterPushContext` — see D8. + +### D4. Provision: declare the KV store in `spin.toml` + +`provision --adapter spin` already edits `spin.toml`. Extension: for +each declared `[stores.config].id`, append the env-resolved platform +name to the component's `key_value_stores = [...]` list. Idempotent +on existing entries. Same pattern as the existing KV provision flow. + +### D5. Capability: Spin becomes Multi for config + +Drop `"config"` from `Spin::single_store_kinds` (currently +`&["config", "secrets"]` → `&["secrets"]`). Strict validation no +longer rejects `[stores.config].ids.len() > 1` for spin. + +### D6. Collision check goes away + +`validate_typed_secrets` currently builds a Spin variable name set of +`{flattened config keys} ∪ {#[secret] values}` and errors on +duplicates. With config off the variables namespace, the +intersection is empty by construction. Delete the check + spec/doc +text that explains it. + +### D7. Secrets stay on variables (unchanged) + +`SpinSecretStore` continues to use `spin_sdk::variables`. The +single-flat-namespace constraint applies only to secrets now. +`#[secret]` values still get the lowercase-only translation; the +runtime check stays. + +### D8. Push context schema + +Reviewer (H2): the v1 plan said "no CLI-side changes" but then +required the Spin adapter to read seed URL/token from somewhere the +trait signature doesn't expose. Fixed by introducing +`AdapterPushContext` (v5: renamed from v4's `PushContext` to avoid +collision with the CLI's internal `PushContext` struct at +[config.rs:42]). + +Changes to `crates/edgezero-adapter/src/registry.rs`: + +```rust +#[derive(Debug, Clone, Default)] +#[non_exhaustive] +pub struct AdapterPushContext<'a> { + /// Already-resolved seed URL. Caller (CLI dispatch) follows the + /// resolution chain for prod or local per D3 and produces the + /// final string here. `None` means "no URL was set anywhere + /// in the resolution chain" -- the adapter errors loudly. + pub seed_url: Option<&'a str>, + /// Already-resolved seed token. + pub seed_token: Option<&'a str>, + /// `true` when the operator passed `--local`. Adapters that + /// have a separate local-emulator path use this to pick the + /// right writeback target; adapters where local == default + /// can ignore it. + pub local: bool, +} + +impl<'a> AdapterPushContext<'a> { + /// Construct a default context: no seed URL / token, prod (not + /// local). v9 (round-7 H1): Rust rejects struct-literal + /// construction of `#[non_exhaustive]` types from outside the + /// defining crate, so the CLI MUST build via this constructor + /// and the `with_*` setters below. + #[must_use] + pub fn new() -> Self { + Self::default() + } + + #[must_use] + pub fn with_seed_url(mut self, url: &'a str) -> Self { + self.seed_url = Some(url); + self + } + + #[must_use] + pub fn with_seed_token(mut self, token: &'a str) -> Self { + self.seed_token = Some(token); + self + } + + #[must_use] + pub fn with_local(mut self, local: bool) -> Self { + self.local = local; + self + } +} + +fn push_config_entries( + &self, + manifest_root: &Path, + adapter_manifest_path: Option<&str>, + component_selector: Option<&str>, + store: &ResolvedStoreId, + entries: &[(String, String)], + push_ctx: &AdapterPushContext<'_>, // NEW + dry_run: bool, +) -> Result, String> { ... } +``` + +`AdapterPushContext` is non-exhaustive so we can grow it later +without breaking downstream adapters that RECEIVE it via the +trait method. The CLI (which CONSTRUCTS it) is in-tree and uses +the builder API, so the `#[non_exhaustive]` constraint is +honoured at the source-code level. Same shape on +`push_config_entries_local`. + +Changes to `crates/edgezero-cli/src/args.rs`: + +```rust +pub struct ConfigPushArgs { + /* … existing fields … */ + /// Seed URL for adapters that push via HTTP (currently spin). + /// Resolution: this flag → `EDGEZERO__ADAPTERS____SEED_URL` + /// → `[adapters..commands].seed_url`. + #[arg(long)] + pub seed_url: Option, + /// Seed token for adapters that push via HTTP (currently spin). + /// Resolution: this flag → `EDGEZERO__ADAPTERS____SEED_TOKEN`. + /// Never read from `edgezero.toml` (don't put secrets in the + /// manifest). + #[arg(long)] + pub seed_token: Option, +} +``` + +Manifest schema: `ManifestAdapterCommands` (currently lives in +`crates/edgezero-core/src/manifest.rs`) gains an optional +`seed_url: Option` field. Already covered by `#[non_exhaustive]`, +so additive. + +Changes to `crates/edgezero-cli/src/config.rs`: + +The CLI's internal `PushContext` struct (config.rs:42) gains a +field carrying the resolved adapter context: + +```rust +struct PushContext { + // … existing fields … + /// Resolved by `load_push_context` from CLI args + env + + /// manifest per D3's prod/local chains. Stashed here so + /// `dispatch_push` can pass it through to the trait method + /// without re-reading args / env. Owned strings (not + /// borrows) so the lifetime story stays simple. + adapter_push_ctx: ResolvedAdapterPushContext, +} + +struct ResolvedAdapterPushContext { + seed_url: Option, + seed_token: Option, + local: bool, +} +``` + +`load_push_context(args: &ConfigPushArgs)` (which already takes +`&ConfigPushArgs` and reads `env` for store resolution) gains the +resolution logic per D3's disjoint chains: + +```rust +fn load_push_context(args: &ConfigPushArgs) -> Result { + // … existing manifest + store resolution … + + let env = EnvConfig::from_env(); + let name = &args.adapter; + + let seed_url = args.seed_url.clone().or_else(|| { + if args.local { + // D3 local chain: env → builtin default. Manifest NEVER consulted. + env.get(&["adapters", name, "local_seed_url"]) + .map(str::to_owned) + .or_else(|| Some("http://127.0.0.1:3000/__edgezero/config/seed".to_owned())) + } else { + // D3 prod chain: env → manifest. + env.get(&["adapters", name, "seed_url"]).map(str::to_owned) + .or_else(|| manifest.adapters.get(name) + .and_then(|cfg| cfg.adapter.commands.seed_url.clone())) + } + }); + + let seed_token = args.seed_token.clone() + .or_else(|| env.get(&["adapters", name, "seed_token"]).map(str::to_owned)); + // Manifest never consulted for tokens, even on the prod chain. + + Ok(PushContext { + // … existing fields … + adapter_push_ctx: ResolvedAdapterPushContext { + seed_url, seed_token, local: args.local, + }, + }) +} +``` + +`dispatch_push` (unchanged signature) just borrows from the +already-resolved context when building the `AdapterPushContext` +to hand the trait method: + +```rust +fn dispatch_push(ctx: &PushContext, entries: &[(String, String)], + dry_run: bool, local: bool) -> Result<(), String> { + let r = &ctx.adapter_push_ctx; + // v9 (round-7 H1): build via the builder, NOT a struct literal — + // AdapterPushContext is #[non_exhaustive] and external crates + // can't use struct-literal construction. + let mut push_ctx = AdapterPushContext::new().with_local(r.local); + if let Some(url) = r.seed_url.as_deref() { + push_ctx = push_ctx.with_seed_url(url); + } + if let Some(token) = r.seed_token.as_deref() { + push_ctx = push_ctx.with_seed_token(token); + } + let lines = if local { + ctx.adapter.push_config_entries_local(/* … */, &push_ctx, dry_run)? + } else { + ctx.adapter.push_config_entries(/* … */, &push_ctx, dry_run)? + }; + // … existing logging … +} +``` + +For non-Spin adapters this is constructed but unused — costs nothing. + +This change is **breaking** for any out-of-tree adapter that +implements `Adapter::push_config_entries*` (no in-tree adapter +outside the four ships today). Document in the next release notes. + +### D9. Seed handler security + +Reviewer (H4): pin the security contract before code. + +**Route**: `/__edgezero/config/seed`. Single fixed path, not +configurable per app — keeps every Spin deploy's seeding surface +predictable for ops scripts. + +**Method**: POST only. GET/PUT/DELETE/HEAD/OPTIONS/PATCH → 405. + +**Headers**: + +- `x-edgezero-seed: ` — REQUIRED. Compared constant-time + against `EDGEZERO__ADAPTERS__SPIN__SEED_TOKEN`. +- `content-type: application/json` — REQUIRED. Anything else → 415. + +**Body shape** (validated against this schema): + +```json +{ + "store": "app_config", + "entries": [ + { "key": "greeting", "value": "hello" }, + { "key": "service.timeout_ms", "value": "1500" } + ] +} +``` + +The `store` field is the **platform label** (what `Store::open(name)` +needs), not the logical id. The handler builds the set of accepted +labels from `A::stores().config` × `EnvConfig::store_name("config", id)` +— so an operator running with +`EDGEZERO__STORES__CONFIG__APP_CONFIG__NAME=prod-config` pushes +`{"store": "prod-config", …}` and the validation passes. A body +mentioning the logical id `"app_config"` in that environment is +correctly rejected (404). + +The CLI does the resolution before POSTing — `dispatch_push` already +resolves the platform label via `env.store_name("config", id)`, so +the body the CLI emits matches what the handler expects. + +**Status code table**: + +| Code | Condition | +| ---- | ------------------------------------------------------------------------------------------------------------------------------------------------ | +| 204 | Success. Body empty. | +| 400 | Malformed JSON, missing `store`, missing/empty `entries`, or any `key`/`value` not a string. | +| 401 | `x-edgezero-seed` header missing, or `EDGEZERO__ADAPTERS__SPIN__SEED_TOKEN` env unset/blank/whitespace-only/shorter than 16 bytes (fail-closed). | +| 403 | `x-edgezero-seed` header present but does not match the env token. | +| 404 | `store` does not match any env-resolved platform label for a declared `[stores.config].id`. | +| 405 | Non-POST method. | +| 415 | `content-type` not `application/json`. | +| 422 | KV store open / set hostcall returned an error mid-write (partial-write — see body for the failed key). | + +**Fail-closed contract**: if `EDGEZERO__ADAPTERS__SPIN__SEED_TOKEN` +is unset, blank, whitespace-only, OR **shorter than 16 bytes** +(v6 — round-5 Q2 settled), EVERY request to the seed route returns +401 — even with no `x-edgezero-seed` header. We never default a +token, never accept "no token = no auth", and never accept a +short-enough token to brute-force in a reasonable time. An operator +who forgot to set the token, or set a 4-character placeholder, gets +a clean error rather than an open writeable endpoint. + +**Why 16 bytes**: at 8 bits/byte that's 128 bits of token surface. +Even a single-shot guess against a constant-time compare has +~2^-128 odds; rate-limiting from the Spin runtime kills any +practical brute-force. Below 16 bytes the operator is almost +certainly using a placeholder ("dev", "test123") that doesn't +belong in production OR local. + +**Token comparison**: `subtle::ConstantTimeEq` (workspace dep, +non-optional on the spin adapter per [D11](#d11-dependency-gating) +— v4's "gated under `spin` feature" was wrong; the host +unit tests for `handle_seed_request_core` need to reach this type +without enabling `--features spin`). Prevents timing-oracle +leakage of the token prefix. + +**Logging**: log auth failures at `warn` level with the source IP +(via `spin-client-addr` header) but NEVER the offered token. + +**Opt-in vs always-scaffolded**: scaffold-side OPT-IN — the +generator emits `run_app_with_seeder` for new projects, but +`run_app` (no seeding route) stays available for projects that +explicitly opt out by switching the entrypoint. Existing +deployments keep `run_app` and aren't affected. + +### D10. Testable seed writer + +Reviewer (M2): the v1 plan called for unit tests on the seed handler +but `spin_sdk::key_value` is wasm-runtime-bound. Solution: trait + +fake. + +**v3**: split the handler into two layers so tests compile on the +host without dragging in `spin_sdk` types. The core layer is +host-compilable; the wasm wrapper translates Spin types to/from +`edgezero_core::http::{Request, Response}`. + +`crates/edgezero-adapter-spin/src/seed.rs`: + +```rust +// ---- Core layer (host-compilable) --------------------------------- + +#[async_trait(?Send)] +pub(crate) trait SeedWriter { + async fn write(&self, store: &str, key: &str, value: &str) -> Result<(), SeedError>; +} + +/// Host-compilable seed handler core. Takes a core HTTP `Request` +/// (body already buffered into `Body::Once`) and returns a core HTTP +/// `Response`. Parsing, auth, status-code routing, and the writer +/// dispatch all live here. NO spin_sdk references. +pub(crate) async fn handle_seed_request_core( + req: &edgezero_core::http::Request, + writer: &W, + valid_token: Option<&str>, // None → fail-closed (401) + known_platform_labels: &[String], // env-resolved labels per H3 +) -> edgezero_core::http::Response { ... } + +#[cfg(test)] +pub(crate) struct InMemorySeedWriter { + pub(crate) entries: Mutex>, // (label, key) → value +} + +// ---- Wasm wrapper (spin-runtime only) ----------------------------- + +#[cfg(all(feature = "spin", target_arch = "wasm32"))] +pub(crate) struct SpinKvSeedWriter; + +#[cfg(all(feature = "spin", target_arch = "wasm32"))] +#[async_trait(?Send)] +impl SeedWriter for SpinKvSeedWriter { + async fn write(&self, store: &str, key: &str, value: &str) -> Result<(), SeedError> { + let kv = spin_sdk::key_value::Store::open(store).await?; + kv.set(key, value.as_bytes()).await?; + Ok(()) + } +} + +/// Thin wasm wrapper: Spin `Request` → core `Request` → core handler +/// → core `Response` → Spin `Response`. Lives where the existing +/// `into_core_request` / `from_core_response` helpers do. +/// +/// v10 (round-8 H1): returns `anyhow::Result` so +/// it matches `run_app`'s shape (allows `?` at the call site in +/// `run_app_with_seeder` instead of a `.expect()` panic). +#[cfg(all(feature = "spin", target_arch = "wasm32"))] +pub(crate) async fn handle_seed_request_spin( + req: spin_sdk::http::Request, + writer: &SpinKvSeedWriter, + valid_token: Option<&str>, + known_platform_labels: &[String], +) -> anyhow::Result { + let core_req = crate::request::into_core_request(req).await?; + let core_resp = handle_seed_request_core(&core_req, writer, + valid_token, known_platform_labels).await; + Ok(crate::response::from_core_response(core_resp).await?) +} +``` + +Host-compilable unit tests (live in `seed.rs`'s `#[cfg(test)] mod +tests`). The full row set lives in Task 3.2 — keep this list in +sync if either side moves: + +- **Auth surface (v6 16-byte floor + fail-closed)**: + - Token unset (env missing) → 401. + - Token blank (`""`) → 401. + - Token whitespace-only (`" "`) → 401. + - Token 15 bytes (just under the floor) → 401, even when the + client offers the matching token on the wire. + - Token exactly 16 bytes + matching wire token → 204 + (just-at-the-floor sentinel). + - Token 16 bytes + missing `x-edgezero-seed` → 401. + - Token 16 bytes + wrong `x-edgezero-seed` → 403. +- **Request-shape surface**: + - Non-POST method → 405. + - `content-type` not `application/json` → 415. + - Malformed JSON → 400. + - Missing `store` / `entries` / non-string values → 400. +- **Store-resolution surface**: + - Unknown store (no env-resolved label matches) → 404. +- **Write surface**: + - `SeedWriter::write` errors mid-stream → 422 (body names the + failed key). + - Happy path → 204 + `InMemorySeedWriter` recorded all entries. + +### D11. Dependency gating + +Three new deps. Different gates for different reasons: + +| Dep | Gate | Why | +| ---------------------- | ------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `reqwest` | `cli` feature (host-only) | Pulls `tokio` + TLS — would explode the wasm bundle and fail to compile on `wasm32-wasip2`. Only the host CLI uses it. | +| `subtle` | **non-optional** (host + wasm) | Used by the seed handler core (wasm) AND by its host-compilable unit tests (D10). Reviewer H2: can't be `spin`-gated when host tests reach `ConstantTimeEq` without `--features spin`. Tiny dep; compiles cleanly on both targets. | +| `serde` + `serde_json` | **non-optional** (host + wasm) | Reviewer H3: seed core parses JSON (wasm), CLI builds JSON body (host), `--features cli` body type derives `Serialize` / `Deserialize`. Both already workspace deps; both compile on host AND wasm. | + +Concrete `Cargo.toml` change on `crates/edgezero-adapter-spin`: + +```toml +[features] +spin = [ + "dep:spin-sdk", +] +cli = [ + "dep:edgezero-adapter", + "edgezero-adapter/cli", + "dep:ctor", + "dep:reqwest", # NEW (host HTTP push) + "dep:toml", + "dep:toml_edit", + "dep:walkdir", +] + +[dependencies] +# … existing entries … +reqwest = { workspace = true, optional = true } +serde = { workspace = true } # NEW; non-optional +serde_json = { workspace = true } # NEW; non-optional +subtle = { workspace = true } # NEW; non-optional +``` + +**Why subtle is not optional**: gating it under `spin` would hide +it from the host build, but the host unit tests for +`handle_seed_request_core` (D10) need to construct `subtle::Choice` +and friends. Making it non-optional is the simplest correct +answer; the dep is ~5 KB compiled. + +**Why serde/serde_json are not optional**: similarly, the core +seed handler runs JSON parsing on both wasm (production) and host +(tests). The Cargo features model can't express "available in +wasm under `spin` AND in host under `cfg(test)`" cleanly — making +it always-on does the right thing. + +Verification step (added to Stage 8 gate): use `cargo tree -i` +which errors when the dep is not in the tree at all (per L1). Two +checks: + +```sh +# reqwest MUST NOT be in the wasm tree. +# `cargo tree -i ` exits non-zero when isn't a dep -- +# which is the success case here. Invert with `!`: +! cargo tree -i reqwest -p edgezero-adapter-spin \ + --features spin --target wasm32-wasip2 2>/dev/null + +# subtle / serde_json MUST be in the wasm tree. +# `cargo tree -i ` succeeds when the dep IS present: +cargo tree -i subtle -p edgezero-adapter-spin \ + --features spin --target wasm32-wasip2 +cargo tree -i serde_json -p edgezero-adapter-spin \ + --features spin --target wasm32-wasip2 +``` + +### D12. Blocking HTTP client + +Reviewer (H1): the existing `Adapter::push_config_entries*` trait +methods are SYNCHRONOUS. `reqwest::Client::post` is async. Two +options: + +- **(a) `reqwest::blocking`** — keeps the sync trait shape. Needs + `blocking` + `json` features on the workspace `reqwest`. +- **(b) Async trait + runtime in dispatcher** — clean but bigger + blast radius (every adapter impl signature changes; CLI gets a + tokio dep). + +**Resolution: (a).** Workspace `Cargo.toml` change: + +```toml +reqwest = { version = "0.13", default-features = false, + features = ["rustls", "blocking", "json"] } +``` + +Spin's `push_config_entries`: + +```rust +let client = reqwest::blocking::Client::new(); +let response = client + .post(&seed_url) + .header("x-edgezero-seed", token) + .json(&body) // serde-derived; `json` feature + .send() + .map_err(|err| match err.is_connect() { + true => format!("seed POST to {seed_url} failed: connection refused. Is the Spin app running?"), + false => format!("seed POST to {seed_url} failed: {err}"), + })?; +// Map every status the handler intentionally emits (D9 status table). +match response.status().as_u16() { + 204 => Ok(vec![format!( + "pushed {} entries to seed handler at {seed_url}", + entries.len() + )]), + 400 => Err(format!( + "seed handler rejected (400 Bad Request): {}. Check CLI version / store id.", + response.text().unwrap_or_default() + )), + 401 => Err(format!( + "seed handler rejected (401 Unauthorized). Fail-closed reasons (D9): \ + server-side `EDGEZERO__ADAPTERS__SPIN__SEED_TOKEN` is unset, blank, \ + whitespace-only, or shorter than 16 bytes; OR your client-side \ + `--seed-token` / `EDGEZERO__ADAPTERS__SPIN__SEED_TOKEN` is missing. \ + Check the server's env first -- a 4-character placeholder triggers \ + this even when the wire token matches." + )), + 403 => Err(format!( + "seed handler rejected (403 Forbidden): x-edgezero-seed mismatch. \ + Check that the token on the client matches the server's \ + EDGEZERO__ADAPTERS__SPIN__SEED_TOKEN" + )), + 404 => Err(format!( + "seed handler rejected (404 Not Found): store `{}` is not a recognised platform label. \ + Check `[stores.config].ids` and any EDGEZERO__STORES__CONFIG____NAME overrides", + store.platform + )), + 405 => Err(format!( + "seed handler rejected (405 Method Not Allowed). \ + This usually means a transparent proxy rewrote the POST -- check intermediaries" + )), + 415 => Err(format!( + "seed handler rejected (415 Unsupported Media Type). \ + Internal: the CLI should always set content-type: application/json" + )), + 422 => Err(format!( + "seed handler rejected (422 Unprocessable): KV write failed mid-stream: {}", + response.text().unwrap_or_default() + )), + other => Err(format!( + "seed handler returned unexpected status {other}: {}", + response.text().unwrap_or_default() + )), +} +``` + +The blocking client is fine for a CLI binary; it spins up its own +single-thread tokio runtime under the hood. No external runtime +needed. + +## Migration story (hard-cutoff) + +Existing Spin deployments break on upgrade. No legacy flag. + +- Apps that read config via `ctx.config_store_default()` keep working + unchanged after a `config push --adapter spin` against the new + backend. +- Apps that read config via `spin_sdk::variables::get(...)` directly + break. They must either (a) move to the EdgeZero abstraction, or + (b) keep their values in `[variables]` and stop using EdgeZero's + config store for those keys. +- Existing `spin.toml` files that declare config keys in + `[variables]` need a one-time migration: the values move from + `[variables].` (and `[component..variables].`) to + the KV store via `config push --adapter spin`. After confirming + the values land in KV, the operator manually removes the + now-orphaned `[variables].` entries. + +Migration guide section title: "Spin: variables → KV for config +(2026-Q3)". + +## Scope (files touched) + +### crates/edgezero-adapter-spin (the heavy crate) + +- `src/config_store.rs` — rewrite `SpinConfigStore` per + [D1](#d1-backend-spin-kv-via-spin_sdkkey_valuestore). Cfg-gated + backend enum: wasm variant holds the opened + `key_value::Store`; the `InMemory` test variant is keyed + plain `String → bytes::Bytes` (one store at a time — that's all + the contract-test macro exercises). Drop `translate_key`. +- `src/request.rs` — rewrite `build_config_registry` as **async** + per H1 (v5: returns `anyhow::Result` so registry-build errors + propagate up the dispatcher): + ```rust + async fn build_config_registry( + meta: Option, + env: &EnvConfig, + ) -> anyhow::Result> { + let Some(meta) = meta else { return Ok(None); }; + let mut by_id = BTreeMap::new(); + for id in meta.ids { + let label = env.store_name("config", id); // per-id env resolution + let store = SpinConfigStore::open(label).await + .map_err(|err| anyhow::anyhow!( + "open config store for id `{id}`: {err}" + ))?; + by_id.insert((*id).to_owned(), + ConfigStoreHandle::new(Arc::new(store))); + } + Ok(StoreRegistry::from_parts(by_id, meta.default.to_owned())) + } + ``` + And in `dispatch_with_registries`: + ```rust + let config_registry = build_config_registry(config_meta, env).await?; + ``` + Mirrors `build_kv_registry`'s existing async + Result shape. +- `src/cli.rs` — + - `push_config_entries`: HTTP POST against `seed_url` (resolved + from `AdapterPushContext` via D8). Body is the D9 schema. + Uses `reqwest` (D11/D12). Surfaces every status code from D9 + with clear messages (D12). + - `push_config_entries_local`: defaults `seed_url` to + `http://127.0.0.1:3000/__edgezero/config/seed` if + `AdapterPushContext` didn't supply one. Otherwise identical. + - `provision`: emit `key_value_stores = [...]` entries per D4. + Drop the `[variables]` / `[component..variables]` + config-declaration writes (the migration guide tells operators + to remove existing ones). + - `validate_app_config_keys`: no-op per D1.5. Delete + `translate_key_for_spin`. + - `validate_typed_secrets`: delete the collision-check block per + D6. Keep the secret-name format check. + - `single_store_kinds`: returns `&["secrets"]`. +- `src/seed.rs` — NEW. `SeedWriter` trait + `SpinKvSeedWriter` + + `handle_seed_request`. ~200 LoC + tests. +- `src/lib.rs` — `pub mod seed;`. Plus two functions sharing + the same concrete return type (v9 round-7 H2 fix — `run_app`'s + old `impl IntoResponse` opaque return type made the fall-through + uninvocable from `run_app_with_seeder`). **v11 round-9 M1**: + drop `IntoResponse` from the + `use spin_sdk::http::{IntoResponse, Request as SpinRequest, +Response as SpinResponse}` import line — once `run_app` returns + `SpinFullResponse`, `IntoResponse` is no longer referenced and + the wasm-clippy gate would fail on `unused_imports`. + + ```rust + pub async fn run_app(req: SpinRequest) + -> anyhow::Result { /* existing body */ } + + pub async fn run_app_with_seeder(req: SpinRequest) + -> anyhow::Result { + // Route /__edgezero/config/seed to the seed handler, else + // fall through to run_app::. v10 (round-8 H1): + // handle_seed_request_spin now also returns + // anyhow::Result, so both arms are + // type-compatible. + if req.uri().path() == "/__edgezero/config/seed" { + handle_seed_request_spin(req, &SpinKvSeedWriter, …).await + } else { + run_app::(req).await + } + } + ``` + + Changing `run_app` from `impl IntoResponse` → `SpinFullResponse` + is **source-compatible with the generated scaffold handler + signature** (NOT a Spin-variable backwards-compat carve-out — + this migration stays hard-cutoff). `SpinFullResponse: IntoResponse`, + so the existing + `async fn handle(req: Request) -> anyhow::Result` + template signature keeps accepting the value through type + coercion — no need to regenerate already-scaffolded projects. + Token resolved from + `EnvConfig::get(&["adapters", "spin", "seed_token"])`; if unset + / blank / shorter than 16 bytes (D9), every request hitting the + seed route returns 401 (fail-closed). + +- `src/templates/src/lib.rs.hbs` — scaffold uses + `run_app_with_seeder` per + [D9 opt-in scaffolding](#d9-seed-handler-security). +- `src/templates/spin.toml.hbs` — add + `key_value_stores = ["app_config"]` to the default + `[component.*]` block per M1. Scaffolded projects work with + `config push --adapter spin --local` out of the box. +- `Cargo.toml` — per D11: `reqwest` optional under `cli` feature + (host HTTP push); `serde`, `serde_json`, `subtle` non-optional + (used by both the wasm seed handler core and its host-compilable + unit tests, so feature-gating would break the test layer). + +### crates/edgezero-adapter (the trait) + +- `src/registry.rs` — `AdapterPushContext` struct + threaded + through `push_config_entries` / `push_config_entries_local` + per D8. + +### crates/edgezero-core + +- `src/manifest.rs` — `ManifestAdapterCommands::seed_url: +Option` per D8 (additive; `#[non_exhaustive]` already in + place). + +### crates/edgezero-cli + +- `src/args.rs` — `ConfigPushArgs::seed_url` / `seed_token` per D8. +- `src/config.rs` — per D8: `load_push_context` resolves the + `ResolvedAdapterPushContext` (owned `String`s) and stashes it + on the CLI's `PushContext`. `dispatch_push` constructs the + borrowing `AdapterPushContext<'_>` from it and hands that to + the trait method. Update the `push_args` test fixture. + +### examples/app-demo + +- `crates/app-demo-adapter-spin/src/lib.rs` — switch + `run_app` → `run_app_with_seeder`. +- `crates/app-demo-adapter-spin/spin.toml` — add `app_config` to + `key_value_stores = [...]`. Remove `[variables].greeting` / + `feature__new_checkout` / `service__timeout_ms` (now in KV). +- `edgezero.toml` — `[adapters.spin.commands].seed_url = +"http://127.0.0.1:3000/__edgezero/config/seed"` so contributors + don't need to set the env var locally. + +### Workspace + +- `Cargo.toml` — three changes: + - `reqwest`: add `blocking` + `json` features to the existing + workspace declaration so the CLI's sync push (D12) works: + `reqwest = { version = "0.13", default-features = false, +features = ["rustls", "blocking", "json"] }`. + - `subtle`: NEW workspace dep for constant-time token + comparison: `subtle = "2"` (non-optional per D11; used by + both the wasm seed handler core and its host tests). + - `serde` / `serde_json`: already workspace deps; just declared + as non-optional on `edgezero-adapter-spin` per D11. + +### docs + +- `guide/adapters/spin.md` — rewrite config-store section: + KV-backed, no `.→__` translation, no collision check. New + seed-handler section explaining the security model + token + rotation guidance. +- `guide/manifest-store-migration.md` — new section "Spin: + variables → KV for config". +- `guide/cli-walkthrough.md` — update the Spin row in the + `config push` section. Add a `config push --adapter spin --local` + example that mirrors the Fastly one. +- `guide/cli-reference.md` — document `--seed-url` / + `--seed-token` on `config push`. + +## Stages + +### Stage 1 — Spec promotion + tracking issue + +- [ ] Move this plan into + `docs/superpowers/specs/2026-06-01-spin-kv-config.md`. +- [ ] Open a tracking issue with the acceptance criteria + (matches Task 2.5 + Stage 8 — wasm KV hostcalls aren't + reachable under the CI wasm matrix's `wasmtime run`, so + real KV coverage lives in the `spin up` smoke test): - host-side `config_store_contract_tests!` passes against + the `InMemory` backend; - the wasm32-wasip2 contract test compiles + runs (no live + KV hostcalls — those are runtime-bound); - collision check gone; - provision writes the right `key_value_stores`; - seed handler hits all status codes from D9's table; - `app-demo` works end-to-end under `spin up` with real + KV writes via `config push --adapter spin --local`. + +### Stage 2 — Runtime backend swap + registry rewrite + +- [ ] **Task 2.1**: Rewrite `SpinConfigStore` per D1. +- [ ] **Task 2.2** (M4 fix): `InMemory` test backend is keyed + plain `String → bytes::Bytes`. (One store per + `config_store_contract_tests!` invocation — no need to track + labels at this layer. The multi-store seed-handler test + fixture `InMemorySeedWriter` IS the place that tracks + `(label, key)`; see D10.) **v6**: `get` uses strict + `String::from_utf8` (NOT `from_utf8_lossy`) to match the + wasm backend's error path. New contract-test case + `non_utf8_value_returns_unavailable` documents the + behaviour and prevents future divergence. +- [ ] **Task 2.3**: Delete `translate_key_for_spin` and its callers + inside `config_store.rs`. +- [ ] **Task 2.4** (H1 + M1): Rewrite `build_config_registry` in + `request.rs` as **async**. Per declared id, await + `SpinConfigStore::open(env.store_name("config", id))` so the + `key_value::Store` handle is opened ONCE at dispatch setup + and cached in `SpinConfigStore`. Thread `&env` to + `dispatch_with_registries`'s config branch. Missing + `key_value_stores = [...]` surfaces as a registry-build + error, not a first-read error. +- [ ] **Task 2.5** (M1 update): `config_store_contract_tests!` + against the `InMemory` backend on the **host** target. Real + KV write/read coverage CANNOT live in the wasm contract test + — CI runs that via plain `wasmtime run`, which does not host + Spin's KV hostcalls. Real coverage moves to the Stage 8 + end-to-end smoke test (which requires `spin up`). + +### Stage 3 — Seed handler + testable writer + +- [ ] **Task 3.1** (D10 split): `crates/edgezero-adapter-spin/src/seed.rs`. + Build the host-compilable core: `SeedWriter` trait, + `InMemorySeedWriter`, `handle_seed_request_core(req: &Request, + …) -> Response` using `edgezero_core::http` types only. NO + `spin_sdk` references in the core layer. +- [ ] **Task 3.2**: Host unit tests against `InMemorySeedWriter` + covering every row of the D9 status code table PLUS the + v6 short-token fail-closed cases (M1 fix). Required test + rows: - Token unset (env var missing) → 401. - Token blank ("") → 401. - Token whitespace-only (" ") → 401. - Token 15 bytes (one under the floor) → 401, EVEN when + the client offers the matching token on the wire. - Token exactly 16 bytes + matching wire token → 204. - Token 16 bytes + missing wire header → 401. - Token 16 bytes + wrong wire token → 403. - Non-POST method → 405. - `content-type` not `application/json` → 415. - Malformed JSON → 400. - Missing `store` / `entries` / non-string values → 400. - Unknown store (no env-resolved label matches) → 404. - `SeedWriter::write` errors mid-stream → 422. - Happy path → 204 + `InMemorySeedWriter` recorded all + entries. +- [ ] **Task 3.3** (H3): Token comparison uses + `subtle::ConstantTimeEq`. The `known_platform_labels` arg is + computed by the caller (the wasm wrapper / lib.rs) from + `A::stores().config` × `env.store_name("config", id)`. +- [ ] **Task 3.4** (D10 wrapper, wasm-gated): Thin + `rust + pub(crate) async fn handle_seed_request_spin( + req: spin_sdk::http::Request, + writer: &SpinKvSeedWriter, + valid_token: Option<&str>, + known_platform_labels: &[String], + ) -> anyhow::Result + ` + that translates Spin `Request` → `edgezero_core::http::Request` + via `into_core_request` (uses `?`), calls the core handler, + translates back via `from_core_response` (uses `?`). v10 + (round-8 H1): returns `anyhow::Result` so + `run_app_with_seeder`'s seed branch is type-compatible with + the fall-through `run_app::` branch. NO `.expect()` panic + in the request path. +- [ ] **Task 3.5** (M2 + v9 round-7 H2 + v10 round-8 H1 + v11 + round-9 M1): 1. Change `run_app`'s signature from + `anyhow::Result` to + `anyhow::Result` (concrete type already + publicly aliased). **Source-compatible with the generated + scaffold handler signature** (NOT a Spin-variable + carve-out — this migration stays hard-cutoff): + `SpinFullResponse: IntoResponse`, so the template + `async fn handle(...) -> anyhow::Result` + keeps compiling without re-scaffolding. + 1a. Drop `IntoResponse` from the + `use spin_sdk::http::{...}` import in `src/lib.rs` — once + `run_app` no longer returns `impl IntoResponse`, the + import is unused and the wasm-clippy `-D warnings` gate + fails on `unused_imports`. 2. Add `run_app_with_seeder` with the SAME return shape: + `rust + pub async fn run_app_with_seeder(req: SpinRequest) + -> anyhow::Result + ` + Routes `/__edgezero/config/seed` to + `handle_seed_request_spin(req, &SpinKvSeedWriter, …).await` + (returns `anyhow::Result` per Task 3.4) + and falls through to `run_app::(req).await`. Both + arms produce `anyhow::Result` so the + `if/else` typechecks and either result propagates via + the outer `?` at the handler call site. 3. Scaffold template handler stays + `async fn handle(req: Request) -> anyhow::Result` + with the body swapped from + `edgezero_adapter_spin::run_app::(req).await` to + `edgezero_adapter_spin::run_app_with_seeder::(req).await`. 4. Token resolved from `EnvConfig::get(&["adapters", "spin", + "seed_token"])`; if unset / blank / shorter than 16 bytes + (D9), every request hitting the seed route returns 401 + (fail-closed). + +### Stage 4 — CLI push rewrite + +- [ ] **Task 4.1** (D8): Add `AdapterPushContext` to the trait + (renamed from v4's `PushContext` to avoid colliding with + the CLI's internal `PushContext`). Update all four existing + impls to take it (no-ops for fastly/cloudflare/axum; spin + reads from it). +- [ ] **Task 4.2**: Add `seed_url` / `seed_token` to + `ConfigPushArgs`. Update the `push_args` test fixture and the + `app-demo-cli/tests/config_flow.rs` helper. +- [ ] **Task 4.3**: Rewrite `load_push_context` to resolve the + `ResolvedAdapterPushContext` (D3's disjoint prod/local + chains per D8). `dispatch_push` converts to the + borrow-shaped `AdapterPushContext<'_>` at call time. +- [ ] **Task 4.4** (D12): Implement spin `push_config_entries` via + `reqwest::blocking::Client::post`. The CLI must resolve the + body's `store` field to the **platform label** (via + `env.store_name("config", id)`), per H3. JSON body per D9. + Surface every status from D9's table — 400 / 401 / 403 / + 404 / 405 / 415 / 422 — per D12's match block. Handle + connection-refused with a specific hint ("is the spin app + running?"). +- [ ] **Task 4.5**: Implement spin `push_config_entries_local`. + Defaults `seed_url` to local. Otherwise delegates to the + Task 4.4 impl. +- [ ] **Task 4.6**: `--dry-run` prints the planned URL + entries + without POSTing. Tests for the dry-run shape. +- [ ] **Task 4.7** (v12 round-10 L1): **Delete and replace stale + Spin-variable push tests.** Today's push tests in + `examples/app-demo/crates/app-demo-cli/tests/config_flow.rs` + (around line 257) and `crates/edgezero-adapter-spin/src/cli.rs` + (around line 1846) assert: - dotted-key → underscore translation - `[variables].` writes - `[component..variables].` writes + Under KV-backed push these assertions are wrong (variables + table is no longer touched). Delete them; add coverage for + the new contract: - Push body contains the resolved platform-label `store` + (with and without `EDGEZERO__STORES__CONFIG__APP_CONFIG__NAME=…` + override). - Push body's `entries` array is the flattened typed + `AppDemoConfig` minus `#[secret]` / `#[secret(store_ref)]` + (mirrors the existing config-flow assertions, just on the + body shape instead of the manifest edit). - `--dry-run` produces NO POST (verify via a mock seed + endpoint that records hits). - Each D9 status code surfaces as the matching D12 error + string (covers 400 / 401 / 403 / 404 / 405 / 415 / 422 + happy 204). + +### Stage 5 — Provision + scaffold + manifest updates + +- [ ] **Task 5.1**: Drop `[variables]` / + `[component..variables]` config-key writes from spin's + `provision`. +- [ ] **Task 5.2**: For each `[stores.config].id`, append the + platform name to the component's `key_value_stores = [...]`. + Idempotent. New `provision_writes_config_kv_store_entry` + test. +- [ ] **Task 5.3**: `single_store_kinds` returns `&["secrets"]`. +- [ ] **Task 5.4** (M1): Generator `spin.toml.hbs` declares + `key_value_stores = ["app_config"]` by default. Add a test + in `generated_project_builds.rs` that checks the rendered + spin.toml contains the entry. +- [ ] **Task 5.5** (v12 round-10 L1): **Delete stale + provision-side variable-write assertions** that pair with + the Stage 4.7 deletions. Concrete sites in + `crates/edgezero-adapter-spin/src/cli.rs` (around line 1846) + currently assert the provision step emits `[variables]` / + `[component..variables]` blocks for declared config + ids. Under D4 those writes are gone. Replace with assertions + that: - For each `[stores.config].id`, the platform label appears + in the component's `key_value_stores = [...]` (Task 5.2's + change). - `[variables]` / `[component..variables]` are NOT + touched for config ids (regression guard so a future + change doesn't silently revive the old path). - Existing `[variables]` entries for `#[secret]` fields + (Task 6.2 keeps these) are preserved. + +### Stage 6 — Validator changes + +- [ ] **Task 6.1** (H3): Delete uppercase/dash/leading-digit tests + on `validate_app_config_keys`. Replace with + `validate_app_config_keys_accepts_any_utf8`. +- [ ] **Task 6.2**: Delete `validate_typed_secrets`'s + collision-check block per D6. Keep the secret-name format + check (it still validates `#[secret]` values against Spin + variable rules). +- [ ] **Task 6.3**: Update strict-completeness tests: + `[stores.config].ids.len() > 1` now PASSES for spin. + +### Stage 7 — Docs + app-demo migration + +- [ ] **Task 7.1**: Rewrite `docs/guide/adapters/spin.md` config + section. Add seed-handler section with the D9 security table. +- [ ] **Task 7.2**: Add the migration section to + `docs/guide/manifest-store-migration.md`. +- [ ] **Task 7.3**: Update `docs/guide/cli-walkthrough.md` Spin row + add `--adapter spin --local` example. +- [ ] **Task 7.4**: Update `docs/guide/cli-reference.md` for + `--seed-url` / `--seed-token`. +- [ ] **Task 7.5**: app-demo migration in ONE commit (per + resolved Q5): switch entrypoint to `run_app_with_seeder`, + update `spin.toml`, set `seed_url` in `edgezero.toml`. + +### Stage 8 — Verify gate + +- [ ] Full gate: cargo fmt, host clippy --workspace, workspace + tests, all three adapter wasm-clippy gates, docs + lint/format/build. +- [ ] Spin wasm contract test under wasmtime (wasm32-wasip2). +- [ ] **Wasm dep gating checks** (D11, fixed per L1 — use + `cargo tree -i` which errors when the dep is absent). + ``sh + # reqwest MUST NOT leak into the wasm tree. `cargo tree -i` + # errors when reqwest isn't a dep; invert with `!`: + ! cargo tree -i reqwest -p edgezero-adapter-spin \ + --features spin --target wasm32-wasip2 2>/dev/null + # subtle / serde_json MUST be in the wasm tree. + cargo tree -i subtle -p edgezero-adapter-spin \ + --features spin --target wasm32-wasip2 + cargo tree -i serde_json -p edgezero-adapter-spin \ + --features spin --target wasm32-wasip2 + `` +- [ ] **End-to-end smoke test** in `examples/app-demo` (v11 + round-9 L1: shell-form, backgrounded, port-wait + trap + cleanup so the test can actually be run in CI / pasted + into a shell). + + ```sh + #!/usr/bin/env bash + set -euo pipefail + + readonly TOKEN="test-token-1234567890" + readonly PORT=3000 + readonly URL="http://127.0.0.1:${PORT}" + export EDGEZERO__ADAPTERS__SPIN__SEED_TOKEN="$TOKEN" + + cd examples/app-demo + + # 1. Build the wasm so `spin up` has something to serve. + (cd crates/app-demo-adapter-spin && \ + cargo build --target wasm32-wasip2 --release \ + -p app-demo-adapter-spin) + + # 2. Background `spin up` and arrange to kill it on exit. + (cd crates/app-demo-adapter-spin && spin up --listen "127.0.0.1:${PORT}") \ + &> /tmp/edgezero-spin-smoke.log & + readonly SPIN_PID=$! + trap 'kill $SPIN_PID 2>/dev/null || true; wait $SPIN_PID 2>/dev/null || true' \ + EXIT INT TERM + + # 3. Wait up to 10s for the listener (Spin warm-up + KV + # backend init). 20 × 0.5s = 10s. Fail clean on timeout. + for _ in $(seq 1 20); do + if curl --silent --fail --max-time 1 "${URL}/" \ + > /dev/null 2>&1; then + break + fi + sleep 0.5 + done + if ! curl --silent --fail --max-time 1 "${URL}/" \ + > /dev/null 2>&1; then + echo "spin up did not bind ${URL} within 10s" >&2 + tail -n 100 /tmp/edgezero-spin-smoke.log >&2 + exit 1 + fi + + # 4. Push config to the LOCAL endpoint. The token env var + # is inherited from the parent shell (line 5). + cargo run -p app-demo-cli --quiet -- \ + config push --adapter spin --local + + # 5. Assert the pushed value flows through to the handler. + readonly GOT="$(curl --silent --fail "${URL}/config/greeting")" + readonly WANT="hello from app-demo" + if [[ "$GOT" != "$WANT" ]]; then + echo "smoke test FAILED: got=${GOT@Q} want=${WANT@Q}" >&2 + exit 1 + fi + echo "smoke test PASSED: GET /config/greeting → ${GOT@Q}" + # trap kills SPIN_PID on exit. + ``` + + The token value (`test-token-1234567890`, 21 bytes) clears + the v6 16-byte floor on BOTH sides (server `spin up` + inherits the var; CLI `config push` inherits the var). + The `trap` ensures no orphan `spin up` lingers on port 3000 + if the assertion fails — important for re-runnability. + +## Open questions + +None outstanding. All round-2/3/5 questions are settled. See the +"Settled" section below for the historical decisions. + +## Settled + +- **Q1 (round 2) → YES**: `[adapters.spin.commands].seed_url` IS a + valid source (third in the resolution order after CLI flag and + env). `seed_token` stays env/CLI only — never manifest. +- **Q2 (round 5) → YES, 16-byte floor**: The seed handler rejects + tokens shorter than 16 bytes at startup with a fail-closed 401 + on every request. See D9 "Fail-closed contract" for rationale. +- **Q3 (round 2) → ONE COMMIT**: Stage 7.5 ships + `run_app_with_seeder` switch + `spin.toml` KV declaration + + `edgezero.toml` seed_url together for atomic reversibility. + +## Estimated scope (v4) + +- **Code**: 14 files modified, 1 new (`seed.rs`), ~820 LoC impl + - ~430 LoC tests. (Up from v3 — D1's cfg-gated backend enum, + the H4 disjoint local resolution chain in `dispatch_push`, and + the extra D12 status-code arms add ~70 LoC; H2/H3 non-optional + dep moves are zero-LoC on the runtime side.) +- **Docs**: 4 files modified, ~100 LoC prose. +- **Migration**: hard-cutoff (resolved per L1). +- **Time**: 2 focused days assuming no surprises in the spin + hostcall surface. + +## Risks (v2 additions) + +- **`PushContext` is a breaking trait change for any out-of-tree + adapter**. Document in release notes; no in-tree adapter outside + the four ships today. +- **`reqwest` adds ~3 MB to the host CLI binary**. Acceptable for + a dev tool; flag if it ever becomes a problem. +- **Token enforcement in CI**: the end-to-end smoke test needs the + `EDGEZERO__ADAPTERS__SPIN__SEED_TOKEN` env var to flow into both + `spin up` and `app-demo-cli`. Test harness sets it once. diff --git a/docs/superpowers/plans/2026-06-04-spin-per-backend-push.md b/docs/superpowers/plans/2026-06-04-spin-per-backend-push.md new file mode 100644 index 00000000..a04781a9 --- /dev/null +++ b/docs/superpowers/plans/2026-06-04-spin-per-backend-push.md @@ -0,0 +1,402 @@ +# Plan: Replace Spin Seed Handler with Per-Backend Writers + +**Status:** v1 — drafted 2026-06-04 in response to PR-thread security +concern that exposing `/__edgezero/config/seed` on every deployed Spin +app creates a permanent internet-facing attack surface owned by +EdgeZero, even with the Pass 1-7 hardening we just landed (16-byte +constant-time token, 256 KiB body cap, 1000-entry/64 KiB caps, +fail-closed token-first ordering). + +**Goal:** Get `config push --adapter spin` off our embedded HTTP +endpoint and onto the runtime backend's own protocol — matching the +pattern Cloudflare (`wrangler kv bulk put`) and Fastly +(`fastly config-store-entry create`) already use. Delete the seed +handler from prod entirely; the only writers are (a) Spin's own SQLite +backend file for local dev and (b) `spin cloud key-value set` for +Fermyon-hosted prod. + +## Why this PR, not a follow-up + +The seed handler is currently default-on in the scaffold +(`run_app_with_seeder`). Every project generated by `edgezero new +--adapter spin` would ship the endpoint to prod. We can't ship that +default and clean it up later; the right move is to land the +deletion + replacement in the same PR that introduced the migration. + +## Design + +### Architecture (current → target) + +``` +BEFORE (Pass 1-7): + config push --adapter spin + └─> HTTP POST https:///__edgezero/config/seed + └─> run_app_with_seeder intercepts before app router + └─> seed::SpinKvSeedWriter.set(label, key, value) + └─> spin_sdk::key_value::Store::set (inside wasm) + +AFTER (this plan): + config push --adapter spin + └─> parse runtime-config.toml next to spin.toml + └─> dispatch on backend type: + ┌─ type = "spin" → rusqlite-direct write to .spin/sqlite_key_value.db + ├─ Fermyon Cloud → shell `spin cloud key-value set` per entry + │ (auto-detected from `[adapters.spin.commands].deploy` + │ containing `spin deploy` or `spin cloud deploy`) + ├─ type = "redis" → error: "use `redis-cli SET` directly" + └─ type = "azure" → error: "use `az cosmosdb` directly" +``` + +Per-backend writers mirror what Cloudflare (Wrangler) and Fastly +(Fastly CLI) already do. No internet-facing endpoint owned by us; no +embedded HTTP write surface in the deployed wasm component; no token +rotation; no `--seed-url`/`--seed-token` chain to defend. + +### SQLite-direct writer for `type = "spin"` + +Spin's `key-value-spin` crate +([`crates/key-value-spin/src/store.rs`](https://github.com/spinframework/spin/blob/main/crates/key-value-spin/src/store.rs)) +uses one table with this exact schema: + +```sql +CREATE TABLE IF NOT EXISTS spin_key_value ( + store TEXT NOT NULL, + key TEXT NOT NULL, + value BLOB NOT NULL, + PRIMARY KEY (store, key) +) +``` + +Spin's `SET` statement is: + +```sql +INSERT INTO spin_key_value (store, key, value) VALUES ($1, $2, $3) +ON CONFLICT(store, key) DO UPDATE SET value=$3 +``` + +Our writer uses the SAME `CREATE TABLE IF NOT EXISTS` and the SAME +`INSERT … ON CONFLICT` statement. The file lives at +`/.spin/sqlite_key_value.db` by default (Spin's +hard-coded default for `DatabaseLocation::Path`); operators can +override per-label via `[key_value_store.
__…__`. `` is +`[app].name` uppercased with `-`→`_`. `__` separates every nesting +level; a single `_` is literal. + +**Deterministic, ambiguity-rejecting matching.** Each config key is +transformed to its env-segment form (uppercase, `_` left as-is) and +compared exactly. Two sibling keys mapping to the same segment is an +`AppConfigError`. + +**Type coercion.** The env string is parsed against the existing TOML +value's type; parse failure → `AppConfigError`. + +**Scope.** `config validate` and `config push` both see env-resolved +values; `--no-env` disables the overlay. `--no-env` is implemented by +calling `load_app_config_with_options` (§4) with +`AppConfigLoadOptions { env_overlay: false }`; the default (no flag) +uses the simple `load_app_config` form (overlay on). The axum demo +server (the `demo` subcommand) resolves via the same path. + +Note the deliberate consistency: the env separator (`__`) is the same +as the Spin config-key separator (§6.4/§6.7). + +### 6.12 `Default` on `*Args` + +Non-subcommand `*Args` derive `Default` (external construction despite +`#[non_exhaustive]`). Subcommand-wrapping `AuthArgs` does not (a +defaulted required subcommand could leak into a real auth path); +external tests construct it via `clap::Parser::try_parse_from`. + +### 6.13 Documentation updates (definition-of-done for every stage) + +This effort changes the manifest schema, the runtime store API, the +CLI surface, and the `dev`→`demo` subcommand. The VitePress docs site +under `docs/guide/` has existing pages describing all of these, which +go stale. **Updating documentation is part of every stage's +definition-of-done** — a stage that changes user-facing behaviour +updates the affected `docs/guide/` pages _in the same stage_, so the +PR never has a docs-lag window. The docs CI (ESLint + Prettier on +`docs/`) must pass. + +Affected existing pages and the stage that owns each update: + +| Page | What changes | Stage | +| ----------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------- | +| `docs/guide/cli-reference.md` | `dev`→`demo` rename; `edgezero-cli` as a library; new `auth` / `provision` / `config` commands | 1, 5, 6, 7 | +| `docs/guide/configuration.md` | new `[stores]` logical-id schema + per-adapter mapping + capability rules; removal of `[stores.config.defaults]`; the `.toml` app-config file + env overlay | 2, 3 | +| `docs/guide/kv.md` | multi-store model, `ctx.kv_store(id)` / bound handles, `Kv` extractor `default()`/`named()` | 2 | +| `docs/guide/handlers.md` | extractor refactor; async `ConfigStore`; reading config/secrets by logical id | 2 | +| `docs/guide/getting-started.md` | generator now scaffolds `-cli` and `.toml` | 1, 3 | +| `docs/guide/adapters/cloudflare.md` | config store moves `[vars]` → KV | 2 | +| `docs/guide/adapters/overview.md` + Spin adapter docs | Spin store semantics (KV labels, flat-variable config/secrets) | 2 | +| `docs/guide/architecture.md` | light review — store/adapter description | 2 | + +New pages (created in their owning stage): + +- `docs/guide/manifest-store-migration.md` — stage 2 (how to migrate a + pre-rewrite `edgezero.toml`). +- `docs/guide/cli-walkthrough.md` — stage 8 (full `myapp` loop). + +Stage 8 additionally performs a **documentation audit**: grep the +`docs/` tree for stale references (old manifest store keys, the `dev` +subcommand, the old single-store runtime API) and confirm none remain; +verify every page is listed in the `docs/.vitepress/config.mts` +sidebar. The audit is a checklist item in stage 8's ship gate. + +--- + +## 7. Sub-project 1 — Extensible `edgezero-cli` library + generator + `app-demo-cli` skeleton + +**Goal:** establish the substrate. + +**Source changes:** promote `Command` variant fields into +`#[derive(clap::Args)]` structs (`#[non_exhaustive]`, `Default` per +§6.11); add `lib.rs` with `run_*` handlers; shrink `main.rs`; move +existing tests to `lib.rs`; extend the generator to scaffold +`crates/-cli`; add the handwritten `examples/app-demo/crates/ +app-demo-cli` parallel. + +The `dev` subcommand is renamed to **`demo`** — it runs the example +app locally on axum, which is a demo workflow, not a dev workflow; the +name `dev` is reserved for a future dev-workflow command. Stage 1 +renames the CLI's `dev_server` module to `demo_server`, the public +function `run_dev` to `run_demo`, and the `Command::Dev` variant to +`Command::Demo`. `run_demo` returns `Result<(), String>` (consistent +with the other `run_*` functions) — `Ok(())` on graceful shutdown, +`Err(String)` on startup failure (e.g. port bind). It is **not** +`-> !` — the demo server is allowed to return. The current +`dev_server::run_dev()` returns `()`; stage 1 adjusts that boundary. +(The `edgezero-adapter-axum` crate's own internal `dev_server` module +is not user-facing and is left as-is.) + +**Tests:** existing tests pass post-relocation; `tests/lib_consumer.rs`; +`app-demo-cli/tests/help.rs`; generator structure test. + +**Ship gate:** existing `edgezero` commands keep the same flags; +`app-demo-cli --help` shows the four downstream built-ins (`build`, `deploy`, `new`, `serve`); `edgezero new +throwaway-app && cargo check --workspace` succeeds. + +## 8. Sub-project 2 — Manifest + runtime rewrite (atomic, all four adapters) + +**Goal:** the big atomic sub-project. The manifest becomes portable and +non-adapter-specific (§6.6), adapter config moves to `EDGEZERO__*` +environment variables, and the runtime store API is rewritten. With a +hard cutoff these ship together as one stage (stage 2 of the +eight-stage PR). + +**Scope:** + +- **Manifest → portable schema:** rewrite `ManifestStores` to the §6.6 + portable schema — `[stores.]` carries only logical `ids` / + `default`. The `[adapters.*]` store/runtime tables are removed. + Legacy fields are a hard load error. +- **`EDGEZERO__*` env-config layer:** a new `edgezero-core` module + parses `EDGEZERO__`-prefixed environment variables (`__` nesting) + into adapter runtime config — store platform names + tuning, bind + host/port, logging. Absent variables fall back to defaults (§6.6). +- **No compiled-in manifest:** `run_app` drops its `manifest_src` + parameter on all four adapters. The `app!` macro bakes the portable + config (routes + logical store registry) into the `App` / `Hooks` + type; `run_app::()` reads it from `A` and layers `EDGEZERO__*` env + config on top. `include_str!("edgezero.toml")` is removed everywhere. +- **`ConfigStore` async:** `get` becomes `async` + (`#[async_trait(?Send)]`). +- **New `KvError` variants:** add `KvError::Unsupported` (Spin TTL + writes, §6.7) and `KvError::LimitExceeded` (Spin listing past + `max_list_keys`, §6.7), each with a 5xx-class `EdgeError` mapping. +- **Bound handles:** `BoundKvStore` / `BoundConfigStore` / + `BoundSecretStore`; `RequestContext` accessors id-keyed, with + `_default()` helpers. +- **Static metadata:** `Hooks` / `ConfigStoreMetadata` rewritten to + id-keyed metadata; `app!` macro emits them from the portable schema. +- **Adapter store rewrites — ALL FOUR adapters:** each builds a + `StoreRegistry` keyed by logical id, platform names resolved from + `EDGEZERO__STORES__*` (or the id default): + - **axum:** local KV registry; config from + `.edgezero/local-config-.json` (§15); secrets from env vars. + - **cloudflare:** KV registry; **config rewritten `[vars]` → KV** + with async reads; secrets from worker secrets. + - **fastly:** KV / config / secret store registries. + - **spin:** wire `SpinKvStore` (label registry, `max_list_keys` + respected), `SpinConfigStore` (single flat-variable store, `.`→`__` + key translation), `SpinSecretStore` (single flat-variable store) + into the registry; KV labels come from + `EDGEZERO__STORES__KV____NAME`, not hardcoded defaults. +- **Extractors:** `Kv` / `Secrets` refactored to `default()` / + `named()`; `Config` extractor added. +- **`[stores.config.defaults]` removed** (hard error). Replaced by the + axum config-store file flow (§15). The axum dev-server config seeding + is removed. +- **Migrate in-tree:** `examples/app-demo/edgezero.toml` rewritten to + the portable schema (≥2 KV ids `sessions`+`cache`; one config id; + one secrets id). The app-demo adapter crates' `EDGEZERO__*` env + config lives in their run configuration. `app-demo` handlers are + migrated **only for the store-accessor change** — `ctx.kv_store(id)` + / `config_store` / the refactored `Kv` / `Secrets` / `Config` + extractors. Stage 2 does **not** introduce `AppDemoConfig` or any + typed-app-config handler work: that lands in stage 3 (§9). This keeps + stage 2 independently buildable. +- **`docs/guide/manifest-store-migration.md`** published. + +**Tests:** manifest round-trip + validation (non-empty ids; default +required when `ids.len() > 1`; pre-rewrite manifest → hard error with +migration message); `EDGEZERO__*` env-layer parsing (nesting, defaults, +store-name resolution); `run_app` builds and runs with no manifest file +and zero env vars; id-keyed contract-test factories across all four +adapters; cross-adapter named-KV test; Cloudflare config-from-KV async +round-trip; Spin config `.`→`__` translation test; **Spin TTL write +returns `KvError::Unsupported`** (contract test); Spin KV listing-cap +pagination test; `Kv`/`Secrets`/`Config` extractor tests; `app!` macro +metadata registry test. + +**Bisectability — config seeding before `config push` exists.** Stage +2 removes `[stores.config.defaults]` and makes the axum config store +read `.edgezero/local-config-.json`, but `config push` (which +_writes_ that file) does not land until stage 7, and `edgezero demo`'s +auto-regeneration of the file depends on the stage-3 loader and the +stage-7 resolve-and-write step. So between stage 2 and stage 7: + +- The axum config store's backing-file **contract** is what stage 2 + establishes; stage 2 does not need anything to _produce_ the file. +- Stage 2's axum config-store tests **write the JSON fixture file + directly** in test setup (a temp-dir fixture) — they exercise the + read path without depending on `config push`. +- `app-demo`'s stage-2 state: if no fixture file is present the axum + config store is empty (the documented "absent → empty" behaviour). + Any stage-2 `app-demo` test that asserts a config value seeds the + fixture file itself. The full `config push` → running-demo-server + read-back end-to-end test lands in stage 8. + +This keeps stage 2 independently buildable and testable. + +**Ship gate:** multi-store handlers work on axum, cloudflare, fastly, +and spin; async config reads work; an adapter binary builds and runs +with no `edgezero.toml` and zero env vars (falling back to defaults); +all five CI gates green (including the wasm32 spin gate). + +## 9. Sub-project 3 — App-config schema, derive macro, env-overlay loader + +**Goal:** the `.toml` format, `#[derive(AppConfig)]`, and the +generic loader with env-var overlay (§6.10). + +**Source changes:** `edgezero-core::app_config`; `edgezero-macros` +`AppConfig` derive + `#[proc_macro_derive]` export; generator +templates for `.toml` (with a nested `[service]` table at the +root — no `[config]` wrapper) and `-core/src/config.rs` (with +`#[serde(deny_unknown_fields)]`); `examples/app-demo/app-demo.toml` + +- `app-demo-core/src/config.rs`. + +**Generated template vs the `app-demo` example — deliberately +different.** The **generated** `-core/src/config.rs` (what +`edgezero new` scaffolds) is the _common-case_ starting point: a +`greeting` field, a nested `[service]` table (to exercise the env +overlay), and a single plain `#[secret]` field as the common +secret pattern. It does **not** include `#[secret(store_ref)]` — +`store_ref` only buys multiple secret stores on a Fastly-only project +(§6.8), so putting it in every fresh scaffold would teach the edge +case as the default. A commented line in the template shows how to add +`#[secret(store_ref)]` if needed. The **`app-demo` example** is the +opposite: it deliberately exercises _everything_, so its +`app-demo-core/src/config.rs` includes a nested section, one +`#[secret]`, **and** one `#[secret(store_ref)]` — `app-demo` is the +full-capability showcase, not a representative new project. + +**Tests:** `load_app_config` (valid, missing file, bad TOML, +validator failure); env-overlay tests (top-level, nested `__`, type +coercion, parse failure, ambiguous key → error, `--no-env`); +round-trip for `AppDemoConfig`; macro tests for all §6.8 +compile-error constraints. + +**Ship gate:** `AppDemoConfig::SECRET_FIELDS` matches; `load_app_config` +succeeds; `APP_DEMO__SERVICE__TIMEOUT_MS` overrides the nested value +in a test. + +## 10. Sub-project 4 — `config validate` command + +```rust +#[derive(clap::Args, Default, Debug)] +#[non_exhaustive] +pub struct ConfigValidateArgs { + #[arg(long, default_value = "edgezero.toml")] pub manifest: PathBuf, + #[arg(long)] pub app_config: Option, + #[arg(long)] pub strict: bool, + #[arg(long)] pub no_env: bool, +} +``` + +Bound: `DeserializeOwned + Validate + AppConfigMeta` (no `Serialize`). + +App-config validation: TOML syntax; deserialises into `C`; types; +`validator` rules; unknown fields rejected when `C` opts in; +`#[secret]` non-empty; `#[secret(store_ref)]` in +`[stores.secrets].ids`. **When `spin` is in the adapter set**, three +additional Spin checks (all per §6.7): + +1. every flattened config key, `.`→`__` translated, matches + `^[a-z][a-z0-9_]*$` — **typed and raw** (both flavours have the + config keys); +2. the effective Spin variable name set — {flattened config keys} ∪ + {`#[secret]` field values}, after `.`→`__` translation — has no + duplicate (config/secret namespace collision check). **Typed + only** — `#[secret]` fields are identified via + `AppConfigMeta::SECRET_FIELDS`, which the raw flavour does not + have. `run_config_validate` (raw) cannot tell which keys are + secrets, so it performs check 1 and check 3 but **not** check 2; + its diagnostics say so. The collision check is therefore guaranteed + only for the typed path, which is the one downstream CLIs wire up; +3. Spin component discovery resolves (exactly one `[component.*]` in + `spin.toml`, or an explicit, matching `[adapters.spin.adapter] +.component`) — **typed and raw** (manifest-based, no struct + needed). + +Manifest: `ManifestLoader` checks; under `--strict`, capability-aware +completeness and well-formed handler paths. + +**Tests:** dedicated fixtures per failure mode incl. all three Spin +checks above (key-syntax, collision, component discovery); env-overlay +on/off. + +**Ship gate:** `app-demo-cli config validate --strict` exits 0; +corrupted fixtures fail with expected messages. + +## 11. Sub-project 5 — `auth` command (adapter-trait dispatch) + +```rust +#[derive(clap::Args, Debug)] // NO Default — §6.11 +#[non_exhaustive] +pub struct AuthArgs { #[command(subcommand)] pub sub: AuthSub } + +#[derive(clap::Subcommand, Debug)] +pub enum AuthSub { + Login { #[arg(long)] adapter: String }, + Logout { #[arg(long)] adapter: String }, + Status { #[arg(long)] adapter: String }, +} +``` + +UX: `auth login --adapter cloudflare`. Dispatch follows the same +path as `build` / `deploy` / `serve`: `AdapterAction::AuthLogin` / +`AuthLogout` / `AuthStatus` extend the existing +`edgezero_adapter::registry::AdapterAction` enum, and each +`edgezero-adapter-*` crate implements the variants in its own +`Adapter::execute` impl (shell out, HTTP call, or no-op — the CLI +doesn't care). Per-project override via +`[adapters..commands].auth-{login,logout,status}` in +`edgezero.toml`, same precedence as `build` / `deploy` / `serve`. + +Built-ins (each in its adapter crate): + +- axum: no-op (no remote auth surface). +- cloudflare: `wrangler login/logout/whoami`. +- fastly: `fastly profile create/delete/list`. +- spin: `spin cloud login/logout/info`. + +The standalone `CommandRunner` indirection originally sketched here +was dropped: each adapter chooses its own implementation mechanism +and is responsible for its own testability. The CLI's `auth.rs` is +a five-line args-to-action delegate to `adapter::execute`. + +**Tests:** the orchestration test mirrors `build`/`deploy`/`serve` — +configure `[adapters..commands].auth-login = "echo logged in"` +in a fixture manifest and assert dispatch succeeds. The real native +CLIs are not exercised in CI (§13). + +## 12. Sub-project 6 — `provision` command + +```rust +#[derive(clap::Args, Default, Debug)] +#[non_exhaustive] +pub struct ProvisionArgs { + #[arg(long, default_value = "edgezero.toml")] pub manifest: PathBuf, + #[arg(long)] pub adapter: String, + #[arg(long)] pub dry_run: bool, +} +``` + +Iterate every id in `[stores.].ids`. Per-adapter behaviour: + +**axum** — no remote resources. `provision --adapter axum` is an +explicit no-op: it prints, for each store, "axum store `` is local +(KV in-memory; config in `.edgezero/local-config-.json`; secrets +from env vars) — nothing to provision." Exit 0. + +**cloudflare** — for KV and config ids: `wrangler kv namespace create +`; parse the namespace id from stdout; patch `wrangler.toml` +`[[kv_namespaces]] binding = ""`, `id = ""`. Secrets: +no-op (worker secrets are runtime-managed via `wrangler secret put`). + +**fastly** — for each id: `fastly -store create --name=`; +ensure `fastly.toml` contains `[setup._stores.]` and +`[local_server._stores.]` table entries (keyed by the +resource-link name = our `name`). Store IDs are not persisted; `config +push` resolves them on demand (§13). + +**spin** — no remote `create` step (Spin KV stores and variables are +provisioned by the Spin runtime / Fermyon at deploy). `provision +--adapter spin` performs **KV-label `spin.toml` writeback only**: + +- KV: ensure each KV label (resolved from + `EDGEZERO__STORES__KV____NAME`, defaulting to the logical id) + appears in the resolved component's `key_value_stores` array field + (`key_value_stores = [...]` under `[component.]`). +- **Config and secret variables are NOT handled by `provision`.** The + manifest only carries store _ids_, not app-config field keys or + secret key names — `provision` cannot know which Spin variables to + declare. Config-variable declaration is done by `config push +--adapter spin` (which loads `.toml` and therefore knows the + keys; see §13). Secret-variable declaration is **manual** — the + developer declares Spin secret variables in `spin.toml` themselves + (§6.7); the CLI never writes secret variables. + +Component resolution for the KV writeback follows §6.7's rule. No +shell-out for Spin — it is pure manifest editing. + +`--dry-run` prints the would-be commands and would-be manifest +edits without performing them. + +**Tests:** each adapter crate owns its per-(adapter, kind) writeback +tests (temp-fixture writeback for `wrangler.toml`, `fastly.toml`, +and the Spin `key_value_stores` array in `spin.toml`; axum no-op +output asserted). The CLI's orchestration test asserts dispatch +and `--dry-run` short-circuits without invoking the adapter; +`--dry-run` performs nothing. + +## 13. Sub-project 7 — `config push` command + +```rust +#[derive(clap::Args, Default, Debug)] +#[non_exhaustive] +pub struct ConfigPushArgs { + #[arg(long, default_value = "edgezero.toml")] pub manifest: PathBuf, + #[arg(long)] pub adapter: String, + #[arg(long)] pub store: Option, // logical config id; default resolved + #[arg(long)] pub app_config: Option, + #[arg(long)] pub no_env: bool, + #[arg(long)] pub dry_run: bool, +} +``` + +Bound: `DeserializeOwned + Validate + Serialize + AppConfigMeta`. + +**Behaviour:** strict pre-flight validation; load app-config (env +overlay unless `--no-env`); flatten + serialise per §6.4/§6.5 (skip +`SECRET_FIELDS`); resolve target id (`--store` or resolved default). +Push is **split by adapter** — there is no single "resource-ID" model: + +| Adapter | Push behaviour | +| ---------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| axum | Write resolved values to `.edgezero/local-config-.json` (the file the axum config store reads, §15). No runner call. | +| cloudflare | Read the namespace id from `wrangler.toml` (error "did you run `provision`?" if absent); `wrangler kv bulk put --namespace-id=`. Keys in dotted form. | +| fastly | Resolve the store id on demand: `fastly config-store list --json`, match by ``; per key `fastly config-store-entry create --store-id= --key= --value=` (`--stdin` for large values). Keys in dotted form. | +| spin | Declare + set each config value as a Spin variable, writing **both** `spin.toml` tables (see below). Keys in `.`→`__` lowercase form (§6.7). No remote call — live Fermyon Cloud variable push is out of scope (§2). | + +**Spin `config push` writes two `spin.toml` tables.** A Spin variable +is not readable by a component unless it is both _declared_ and +_bound_. `config push --adapter spin` therefore writes: + +1. `[variables].` — the application-level variable declaration, + with `default = ""`. +2. `[component..variables].` — the component binding, + ` = "{{ }}"`, surfacing the application variable into the + component. Without this, the component cannot read the variable. + +If the component-bindings table is missing entries for keys this push +needs and `config push` cannot resolve the component (§6.7), it +errors rather than writing a half-configured manifest. The component +is resolved per §6.7's discovery rule. Config-variable _declaration_ +lives here (not in `provision`) because only `config push` loads +`.toml` and thus knows the keys. Secret variables remain manual +(§6.7) — `config push` skips `SECRET_FIELDS` and never writes secret +variables. + +**Tests:** typed + raw; per-adapter mock-runner / fixture with golden +payloads; `#[secret]` / `#[secret(store_ref)]` absent from payload; +missing native-manifest id (cloudflare) → clear error; Spin key +`.`→`__` translation asserted; Spin writeback updates **both** +`[variables]` and `[component..variables]`; Spin push errors +when the component cannot be resolved; `--store` selection; `--dry-run` +performs nothing; env-overlay on vs `--no-env`. **Explicit "validate +passes, push serialization fails" cases:** non-object typed config, +unsupported compound shape, `skip_serializing_if`, `Option::None`, +`#[serde(flatten)]` on a non-secret field. + +**Spin `spin.toml` golden test.** A golden-file test captures the +generated `spin.toml` after a Spin `config push` and asserts: every +written variable name matches `^[a-z][a-z0-9_]*$` (§6.7); the +generated manifest **parses** (round-trips through the same TOML / +Spin-manifest parser the runtime uses), so the `^[a-z][a-z0-9_]*$` +rule cannot silently drift from Spin's actual manifest behaviour. + +**Validation strength, strongest first:** the test uses the strongest +check available in its environment. (1) If the `spin` CLI is present +(the wasm32 spin CI job already installs it), the test runs Spin's own +manifest validation against the generated file — this is authoritative +and catches semantic errors a plain TOML parse cannot. (2) Else if +`spin_sdk` exposes a manifest-validation entry point, it calls that. +(3) Otherwise it falls back to `toml` parsing + the variable-name +regex. The regex is the **floor**, not the ceiling — the +implementation prefers real Spin validation wherever it is reachable +and treats the TOML-only fallback as the weakest acceptable check. +The golden file is regenerated only on an intentional format change. + +**Ship gate:** `app-demo-cli config push --adapter cloudflare +--dry-run` and `--adapter spin --dry-run` each show the expected +output; secret fields absent; Spin keys `__`-encoded. + +## 14. (reserved — sub-project numbering uses the `#` column in §16) + +## 15. Sub-project 8 — `app-demo` integration polish (all four adapters) + +**Goal:** `app-demo` demonstrates the **full** feature set in CI across +all four adapters. + +- **Extensible CLI:** `app-demo-cli` with the four downstream built-ins plus + `Auth`, `Provision`, `Config` (`Validate` / `Push`); the `Config` + arm wired to the **typed** functions with `AppDemoConfig`. +- **Multi-store manifest + runtime:** `edgezero.toml` declares 2 KV ids + (`sessions`, `cache`), one config id, one secrets id, with per-adapter + mappings for **all four** adapters (Spin KV labels included). The + Spin capability rule is satisfied (one config id, one secrets id). +- **Multi-store runtime:** handlers read both `sessions` and `cache` + via the `Kv` extractor's `named()`. +- **Async config:** a handler does + `ctx.config_store_default()?.get("greeting").await?`. +- **Nested config + Spin key encoding:** `AppDemoConfig.service. +timeout_ms` is read at runtime; the Spin path proves `.`→`__` + translation. +- **Env-var override:** an integration test sets + `APP_DEMO__SERVICE__TIMEOUT_MS` and asserts the override. +- **Secrets:** one `#[secret]` (`api_token`) and one + `#[secret(store_ref)]` (`vault`); a handler reads each. `app-demo` + targets all four adapters, so `[stores.secrets].ids` has exactly one + id (§6.6 capability rule) and the `vault` field's value **is** that + single secrets id — the walkthrough doc explicitly shows + `#[secret(store_ref)]` resolving to the one declared id for an + all-four-adapter app (§6.8). `app-demo`'s `spin.toml` **manually + declares** its Spin secret variables (with `secret = true`, bound + under `[component..variables]`), demonstrating the §6.7 + manual-secret rule. The `app-demo-core` handler keeps its + `#[secret(store_ref)]` runtime key clear of every config key so the + Spin flat namespace does not collide. +- **Spin component:** `app-demo`'s `spin.toml` is single-component, so + component discovery resolves implicitly; the walkthrough doc also + shows the explicit `[adapters.spin.adapter].component` form. +- **`config validate` / `config push`:** CI runs `config validate +--strict` (exit 0 — including the three Spin checks of §10) then + `config push --adapter axum` and reads the value back through a + running axum demo server on `/config/greeting`. `config push + --adapter spin --dry-run` is asserted to **print** the would-be + `__`-encoded keys and the would-be content of **both** `spin.toml` + tables — and the on-disk `spin.toml` is asserted **unchanged** + (dry-run never mutates). The non-dry-run Spin push writing both + tables is covered by stage 7's tests, not the dry-run assertion. +- **`auth` / `provision`:** dispatch tests in `edgezero-cli` use + fixture manifests with `auth-login = "echo logged in"` (etc.) and + assert that `adapter::execute` is reached for the right + `AdapterAction`. The actual native-CLI invocation and any manifest + writeback live in each adapter crate's own tests (temp-fixture + writeback for `wrangler.toml`, `fastly.toml`, and the Spin + `key_value_stores` array in `spin.toml`). Spin `provision` is + asserted to write only the `key_value_stores` array, not + variables. + +**Axum config store backing.** The axum config store is backed by +`.edgezero/local-config-.json` (gitignored). `config push +--adapter axum` writes it from `.toml` (env overlay applied); +the axum config store reads the same file; `edgezero demo` regenerates +it at startup. If absent, the axum config store is empty. + +**Docs:** create `docs/guide/cli-walkthrough.md` (full `myapp` loop — +`new`, `auth`, `provision`, `config validate`, `config push`, `deploy`, +the `demo` subcommand, an env-override example, all four adapters, +including the manual Spin secret-variable `spin.toml` entries and the +explicit `[adapters.spin.adapter].component` form). Update +`docs/.vitepress/config.mts` so the sidebar lists `cli-walkthrough.md` +and `manifest-store-migration.md`. + +**Documentation audit (§6.12).** Stage 8 finishes with a docs audit: +grep `docs/` for stale references — old `[stores.*]` manifest keys, +the `dev` subcommand, the pre-rewrite single-store runtime API — and +confirm none remain; confirm every page in §6.12's table was updated +by its owning stage; confirm the docs CI (ESLint + Prettier) passes. + +**Ship gate:** CI runs the full loop on axum end-to-end; manifest / +runtime behaviour for cloudflare, fastly, and spin is covered by +contract + mock tests; the documentation audit passes with zero stale +references. + +--- + +## 16. Implementation order and milestones + +The whole effort is **a single pull request containing eight stages**, +one per sub-project, applied in this order: + +| Stage | § | Title | Risk | +| ----- | --- | ------------------------------------------------------ | ---- | +| 1 | §7 | Extensible lib + scaffold | M | +| 2 | §8 | Manifest + runtime rewrite (atomic, all four adapters) | H | +| 3 | §9 | App-config schema + derive macro + env-overlay loader | M | +| 4 | §10 | `config validate` | L | +| 5 | §11 | `auth` (adapter-trait dispatch) | M | +| 6 | §12 | `provision` | H | +| 7 | §13 | `config push` | M | +| 8 | §15 | `app-demo` polish (all four adapters) + docs audit | M | + +Every stage also updates the `docs/guide/` pages it makes stale +(§6.12) — documentation is part of each stage's definition-of-done, +not a deferred afterthought. Stage 8 closes with a documentation +audit. + +**CI and bisectability.** CI gates the PR as a whole on its head +commit; all four gates (`fmt`, `clippy -D warnings`, `cargo test`, +feature `cargo check`) plus the wasm32 spin gate must pass there. Each +of the eight stages should nonetheless compile and pass tests on its +own so the history stays bisectable — stage boundaries are chosen so +that each is a self-contained, buildable increment. Stage 2 is the one +unavoidably large stage (the atomic manifest+runtime rewrite); the +other seven are individually small. + +**Review note.** Because this is one PR, the reviewer sees all eight +stages together. The PR description should list the eight stages and +point at this spec. Reviewing stage-by-stage is recommended. +**Stage 2 is the review hotspot** — the atomic manifest+runtime +rewrite is intentionally large (the hard cutoff leaves no smaller +coherent unit), so it warrants the most reviewer attention. Its +per-adapter contract tests (§8) are the primary mitigation and should +be reviewed alongside the code. + +**Highest-risk:** stage 2 — atomic manifest+runtime rewrite touching the +schema, `ConfigStore` (async), **all four** adapters' store impls, the +Cloudflare `[vars]`→KV swap, Spin store wiring, `Hooks` / +`ConfigStoreMetadata` / `app!`, and the extractors, in one stage. +Large by necessity under the hard-cutoff decision. Mitigated by +per-adapter contract tests and `app-demo` as the in-tree canary. +Stage 6 (`provision`) — shell-out + multi-file native-manifest +writeback across four adapters (`wrangler.toml`, `fastly.toml`, +`spin.toml`). + +## 17. Risks and trade-offs + +- **Hard manifest cutoff:** a pre-rewrite `edgezero.toml` fails to + load with a migration-guide error. All in-tree projects migrated in + stage 2; external projects migrate once. +- **Large atomic stage (stage 2):** unavoidable without a + compatibility layer, which the hard-cutoff decision rejects. It is + one stage, not one PR — the PR carries all eight. +- **Async `ConfigStore` cascade:** `get` becomes async across the + trait and **all four** adapter impls, handlers, and the `Config` + extractor. `#[async_trait(?Send)]` keeps WASM compatibility. +- **Cloudflare `[vars]`→KV swap:** deployed workers migrate once. +- **Spin model asymmetry:** Spin config/secrets are a single flat + variable namespace; multi-config/multi-secret projects cannot target + Spin. The capability matrix (§6.6) enforces this at validate time + with a clear error. Spin config keys are `__`-encoded lowercase. +- **Spin config is build-time:** `config push --adapter spin` writes + static `spin.toml` variables; changing them needs a redeploy. Live + Spin variable providers are out of scope (§2). +- **Spin secret variables are manual:** the CLI never declares Spin + secret variables (their key names are not reliably knowable, §6.7). + A project targeting Spin must declare them in `spin.toml` by hand; + the walkthrough doc covers this. `#[secret(store_ref)]` is the + awkward case on Spin (single flat secret namespace, code-local + keys) — supported, but the developer owns the `spin.toml` entries. +- **Spin KV TTL / listing-cap:** stage 2 adds two new `KvError` + variants — `Unsupported` (Spin TTL writes) and `LimitExceeded` + (Spin listing past `max_list_keys`) — both 5xx-class in their + `EdgeError` mapping. Spin TTL writes return `Unsupported` + deterministically (not silent); the Spin listing path returns + `LimitExceeded`, replacing PR #253's `KvError::Validation` for that + case. Both are settled in this spec, not left open. +- **Spin component discovery:** writing `[component..*]` tables + needs the component id; single-component `spin.toml` resolves + implicitly, multi-component requires `[adapters.spin.adapter] +.component`. `config validate --strict` surfaces a failure early. +- **Env overlay surprising `config push`:** `--no-env` is the escape + hatch. +- **Shell-out + ID-writeback fragility:** current platform syntax + pinned; golden parser tests; `--dry-run` available. +- **Extractor breaking change:** `Kv(handle)` → `kv.default()`; only + in-tree consumer is `app-demo`. +- **API stability:** non-subcommand `*Args` are `#[non_exhaustive]` + + `Default`; `AuthArgs` without `Default`. + +## 18. What this spec does not cover + +- Anthropic credentials, edge DNS / TLS, observability / metrics. +- Per-environment config _files_ (env-var override is in scope). +- Restructuring `app-demo-core` handlers beyond what §15 requires. +- `edgezero-core` changes beyond `app_config`, the rewritten + `manifest` / `RequestContext` / `Hooks` / `ConfigStore` (async) / + extractor / `ConfigStoreMetadata` / `app!` surface, and the + Cloudflare adapter config backend. +- A migration _tool_; migration is manual via the published guide. +- Dynamic Spin variable providers (Fermyon Cloud variable push, Vault). + +When all eight sub-projects ship, `edgezero new myapp` produces a +workspace with `myapp-cli`, a typed `MyappConfig` +(`#[derive(AppConfig)]`, `#[serde(deny_unknown_fields)]`, optional +`#[secret]` / `#[secret(store_ref)]`), a `myapp.toml`, and an +`edgezero.toml` using the new logical-store schema with capability- +correct store declarations. The developer authenticates, provisions, +validates, pushes config (with optional env overrides), and deploys. +At runtime the service reads config (async) and secrets by logical id +across all four adapters. `app-demo` demonstrates every capability in +CI. diff --git a/docs/superpowers/specs/2026-05-21-outbound-http-design.md b/docs/superpowers/specs/2026-05-21-outbound-http-design.md new file mode 100644 index 00000000..b01ea61a --- /dev/null +++ b/docs/superpowers/specs/2026-05-21-outbound-http-design.md @@ -0,0 +1,4295 @@ +# EdgeZero Outbound HTTP — Design Spec + +> **Status:** Draft, revised through review rounds 1–51 (round 51 = round-50 carry-over fixes: early Fastly dynamic-backend paragraph reconciled with the corrected `NameInUse` algorithm, `FASTLY_RESPONSE_STREAM_BUFFER_BYTES` added for the buffered passthrough fallback, §5.4 lazy-passthrough rows rebucketed so Fastly is no longer grouped with CF/Spin, residual `between_bytes_timeout` write-side claims scrubbed from §5.4 + §8 risk 7, Spin host-write race rewritten against actual WASI nonblocking + readiness-poll semantics, two appendix entries flagged as superseded by AY) · **Date:** 2026-06-08 +> **Branch:** `docs/outbound-http-spec` · **Audience:** EdgeZero maintainers +> **Driving pattern:** fan-out HTTP workloads — N concurrent outbound requests under a shared wall-clock deadline, results harvested in input order. The spec is written against this pattern as a portable substrate; it deliberately does not name a specific consumer. +> **Target codebase baseline:** [`stackpop/edgezero` PR #269](https://github.com/stackpop/edgezero/pull/269) (`feature/extensible-cli`, rev `b4c80e9`) — **not yet merged into `main`**. PR #269 introduces the multi-store manifest (`ManifestStores { config, kv, secrets }`), the `edgezero_cli::adapter::execute(..)` shell-or-registry dispatcher, the expanded `AdapterAction` (`AuthLogin` / `AuthLogout` / `AuthStatus` / `Build` / `Deploy` / `Serve`), separate `Adapter::provision(..)` and config-validation hooks, Spin SDK 6 / wasip2, the contributor-only `demo` command replacing `dev`, and the new `examples/app-demo/crates/app-demo-cli` integration crate. +> **Current checkout (pre-#269):** `crates/edgezero-cli/src/args.rs` still has `Command::{Build, Deploy, Dev, New, Serve}`; `crates/edgezero-adapter/src/registry.rs` still has `AdapterAction::{Build, Deploy, Serve}`; `main.rs` still handles `Command::Dev`. **The CLI rows in §3.5.3 / §5.4 / §7 / Appendix AR are contingent on PR #269 landing.** If PR #269 ships in a different shape, the affected rows must be re-rebased; if it never lands, the spec's CLI surface degrades to the current `build` / `serve` / `deploy` / `dev` set plus the `ensure_capabilities` gate applied at each of those four call sites (the round-1–43 wording). Spec §1 / §3.1 / §3.2 / §3.3 / §3.4 / §4 (the outbound HTTP design itself) is independent of PR #269 and lands either way. +> **Where rebase claims live (authoritative surfaces):** §3.5.3 build-enforcement, §3.5.2 `Adapter` trait shape (showing both the pre-#269 and PR-#269 forms), §5.4 capability test rows mentioning `demo` / `auth` / `provision` / `config push|validate`, and the §7 `edgezero-cli` migration bullet. Earlier appendices that quote `handle_build` / `handle_serve` / `handle_deploy` / `handle_dev` / `edgezero dev` are the round-1–43 historical resolution journal and remain accurate against the current checkout. **Appendix AR is the round-44 rebase snapshot and is now superseded by Appendices AS / AT / AU / AV / AW / AX / AY / AZ** (rounds 44–51): AR still describes the gate as "a single `Adapter::execute` dispatch point" — that wording was corrected to "four pre-dispatch gates" in AS, then to "five gate sites" in AU. Treat AR as round-44 history; the §3.5.3 + §7 active text is authoritative. + +## 1. Overview + +### 1.1 Goal + +Make EdgeZero a production-safe substrate for **outbound HTTP fan-out**: an app must be +able to issue many independent target requests concurrently, enforce per-request and +whole-fan-out batch deadlines, keep memory predictable, and run the *same handler source* +unchanged on Axum, Cloudflare Workers, Fastly Compute, and Spin. + +"Predictable memory" here means: a documented, bounded cost per buffered response and +per inbound body, plus an explicit batch-level memory model the app controls (§3.4.4). +It does **not** mean EdgeZero imposes a global allocation ceiling. + +### 1.2 Context + +Applications today proxy a single outbound request through the current +`ProxyClient` / `ProxyHandle`. What is missing: + +- A first-class, **independently constructed** outbound request type. +- **True concurrent fan-out.** Today's Fastly client calls `pending_request.wait()` + inside a single `send()`, so any `join_all` of `send()` calls runs strictly serially. +- A **portable deadline** primitive. +- **Bounded buffering** helpers with clean error mapping. +- A way for an app to **declare required capabilities** and fail the build early. + +### 1.3 Non-goals + +- No consumer-specific target logic in EdgeZero. +- EdgeZero does not own privacy, the external batch protocol, or target allowlists. It exposes + `OutboundRequest::uri()` so apps enforce their own allowlist; it never blocks a + request itself. +- No new direct dependency on `tokio`, `reqwest`, `fastly`, `worker`, or `spin-sdk` in + application/library crates or in `edgezero-core`. Those stay inside adapter crates. +- No general-purpose "timeout any future" combinator in this spec — see §3.3.5. + +### 1.4 Decisions locked before / during review + +- **No backward compatibility.** `ProxyClient` is renamed and reshaped in place; + `app-demo`, scaffolding templates, and docs are migrated. No deprecated + aliases. +- **One portable buffered fan-out primitive.** `send_all` is the only fan-out API + for buffered request bodies + buffered responses. Its **input/output contract** + is identical on every adapter (preflight, index alignment, per-slot Ok/Err + shape — see §3.1.1 / §3.2). **Cross-slot timing is not uniform** — on + Axum/CF/Spin `join_all` fans out body drains concurrently, on Fastly buffered + body drains run serially in harvest order (§3.3.4); the + `send-all-slot-isolation` capability (§3.5.1 footnote 4) lets apps require + the stricter guarantee and fail closed on Fastly. **Streamed-response fan-out + is explicitly non-portable** — Fastly's dispatch-all-then-harvest model and + lack of a concurrent body-drain primitive (§3.3.4 / §3.2 / §8 risk 8) make + it unsafe to expose as a portable primitive. Apps that need streamed-response + concurrency use single `send` per request and orchestrate themselves; that is + reactor-bearing only (Axum/CF/Spin), as is any concurrent body consumption. + `futures::future::join_all` is an internal adapter detail for `send_all`'s + implementation on the three reactor-bearing adapters, never app-facing. +- **Unified body.** Outbound request and response bodies use the existing core `Body` + type and may be **buffered (default)** or **streamed (opt-in)**. Streaming + proxy-forwarding is preserved — it is not dropped (review finding / residual risk). +- **Deliverable:** this spec only. Implementation plan and code are follow-ups. + +## 2. Current state (summary) + +| Concern | Today | File | +| --- | --- | --- | +| Outbound trait | `ProxyClient::send(ProxyRequest) -> Result` | `crates/edgezero-core/src/proxy.rs:16` | +| Handle | `ProxyHandle` (`Arc`), `RequestContext::proxy_handle()` | `proxy.rs:21`, `context.rs:97` | +| Request type | `ProxyRequest::new(method, uri)`; `ProxyRequest::from_request` (streaming) | `proxy.rs:138`, `proxy.rs:100` | +| Body | `Body { Once(Bytes), Stream(..) }`; `Body::into_bytes_bounded(max)` exists | `body.rs:14`, `body.rs:76` | +| Errors | `EdgeError`: 400/422/404/405/503/500. No 502/504. `#[non_exhaustive]` | `error.rs:14` | +| Deadlines | None. `web_time::Instant` used only by `RequestLogger` | `middleware.rs:1` | +| Fastly send | `send_async_streaming()` then `pending_request.wait()` — serializes | `crates/edgezero-adapter-fastly/src/proxy.rs:30` | +| Fastly backend name | host with only `.`/`:` sanitized | `crates/edgezero-adapter-fastly/src/proxy.rs:110` | +| Manifest | `Manifest { adapters, app, environment, logging, stores, triggers }` | `manifest.rs:89` | +| Adapter trait | `Adapter { execute, name }` — no capability metadata | `crates/edgezero-adapter/src/registry.rs` | +| Contract tests | exist for Cloudflare/Fastly/Spin; **Axum has none** | `crates/edgezero-adapter-*/tests/contract.rs` | +| Scaffold templates | emit proxy code | `crates/edgezero-cli/.../handlers.rs.hbs`, `spin.toml.hbs:13` | +| Public docs | document `ProxyService`/`ProxyRequest` | `docs/guide/proxying.md`, `docs/guide/handlers.md`, `docs/guide/architecture.md`, `docs/guide/what-is-edgezero.md`, `docs/guide/adapters/*` | + +## 3. Design + +### 3.1 Outbound HTTP client abstraction + +`crates/edgezero-core/src/proxy.rs` is renamed to `crates/edgezero-core/src/outbound.rs`. +Bodies use the **existing core `Body`** type (`Once(Bytes)` | `Stream(..)`), so a request +or response may be buffered or streamed. Buffered is the default; +streaming is an explicit opt-in that preserves proxy-forwarding. + +#### 3.1.1 Adapter-facing trait — two required methods + +```rust +// crates/edgezero-core/src/outbound.rs + +#[async_trait(?Send)] +pub trait OutboundHttpClient: Send + Sync { + /// Send a single request. Accepts streamed request bodies — this is the API + /// for streaming proxy-forwarding (one inbound → one outbound). + /// + /// **`Buffered` mode:** `Ok(resp)` means the full exchange completed — + /// headers AND the response body buffered within the deadline and the + /// decompressed-byte cap. `Err(_)` is returned for transport failure + /// (DNS/TLS/connect), deadline expiry, or over-cap. + /// + /// **`Streamed` mode:** `Ok(resp)` means headers completed. Body-phase + /// failures surface later, when the caller consumes `resp.body`: + /// - **Read errors / decompression failures / deadline expiry** during + /// chunk reads come from the deadline-aware stream wrapper (§3.3.3, + /// §4.3 "Streamed-response wrapping") as `Err(EdgeError::..)` chunks. + /// - **Over-cap** only fires when the consumer uses a bounded helper + /// (`OutboundResponse::into_bytes_bounded(max)`, `into_bytes_bounded_until`, + /// `json_bounded[_until]`) — the streaming decoder itself does **not** + /// count bytes (§3.4.1 "Cap ownership"). Raw `into_response()` passthrough + /// carries no EdgeZero cap; the platform downstream wire is the budget. + /// Axum's response converter is the exception: it buffers, with its own + /// `AXUM_RESPONSE_STREAM_BUFFER_BYTES` cap → 502 on overflow (§4.1). + /// If the caller has *already started writing the downstream response + /// headers* (e.g. a proxy-forward via `into_response()` that the platform + /// converter has begun sending), HTTP no longer allows a status change. + /// The adapter response converter then **aborts the downstream body** (TCP + /// close on HTTP/1.1, RST_STREAM on HTTP/2) and logs the originating + /// `EdgeError`; clients observe an early close, not a synthetic 502/504. + /// See §5.4 for the cross-adapter contract test. + async fn send(&self, req: OutboundRequest) -> Result; + + /// Issue every request concurrently, then collect every result. + /// + /// The returned vec is index-aligned with `reqs`: `out[i]` is the result of + /// `reqs[i]`. **Input handling is isolated per slot**: a `bad_request` for + /// one preflight failure never changes another slot's input shape, and one + /// slot's `Ok`/`Err` type never mutates another's. Cross-slot *timing* is + /// **not uniformly isolated** — see the `send-all-slot-isolation` capability + /// (§3.5.1 footnote 4): on Axum/CF/Spin it's `Native` (concurrent body + /// drains), but on Fastly it's `BestEffort` because buffered-body drains + /// run in harvest order (§3.3.4), so a slot whose own budget would have + /// covered it can still return `gateway_timeout` because an earlier slot + /// monopolized harvest. Apps that require the stricter cross-slot timing + /// guarantee declare the capability required and get a hard build failure + /// on Fastly. `send_all(vec![])` returns `vec![]`. + /// + /// **Memory model:** worst-case **persistent collected buffer** memory for + /// one `send_all` is `Σᵢ request_bodyᵢ.len() + Σᵢ max_response_bytesᵢ` + /// (per-slot caps). Transient overhead during a buffered drain adds up to + /// one in-flight chunk per actively-draining slot (the + /// `sizeof(current_chunk)` term from §3.4.1); the full bound is therefore + /// `Σᵢ request_bodyᵢ.len() + Σᵢ max_response_bytesᵢ + Σⱼ + /// sizeof(current_chunkⱼ)` where j ranges over slots currently in a drain + /// step (§3.4.4). EdgeZero does NOT impose a global cap on N — apps are + /// responsible for bounding the number of requests passed in. On Fastly all + /// requests are in-flight at the host simultaneously to make fan-out work, + /// so a `max_concurrency` knob would defeat the feature; instead, bound N + /// at the application layer (typically the fan-out batch's target count). + /// + /// **Request bodies MUST be buffered (`Body::Once`).** A `Body::Stream` + /// request body yields `out[i] = Err(EdgeError::bad_request("send_all + /// requires buffered request bodies; use send() for a streamed upload"))`, + /// identically on every adapter. This rule prevents Fastly's + /// dispatch-all-then-harvest fan-out from serializing on slow request + /// uploads. + /// + /// **Response mode MUST be Buffered.** A request whose `response_mode` + /// is `Streamed` (via `stream_response()`) yields `out[i] = + /// Err(EdgeError::bad_request("send_all requires buffered responses; + /// use send() for a streamed response"))`, identically on every adapter. + /// Reason: `send_all` returns its `Vec` only after every slot has reached + /// headers, so a fast slot's deadline-aware streamed body wrapper has + /// already been running while later siblings were still in headers phase + /// — by the time the consumer gets the Vec, the fast slot's body may + /// already be at-or-past its deadline. There is no concurrent + /// body-consumption primitive in `send_all` to fix this (Fastly has no + /// guest reactor, §3.3.5; even on Axum/CF/Spin a consumer iterating + /// `out[i].body()` serially can't outrun the wrapper deadlines that have + /// been ticking since headers). Apps that want streamed responses use + /// single `send` and orchestrate concurrency themselves on the three + /// reactor-bearing adapters — the canonical pattern is `futures::join_all` + /// of N `send` calls, then consume each `OutboundResponse` via the + /// **app-facing consuming accessor `into_body() -> Body`** (§3.1.4) and + /// iterate the `Body::Stream` chunks concurrently across the N slots. + /// `into_parts(..)` exists too but is labelled adapter-facing because it + /// returns the (status, headers, body) tuple that response converters + /// need; pure orchestration paths just want the body. This rule keeps + /// `send-all-slot-isolation`'s `Native` claim on Axum/CF/Spin honest — + /// the cross-slot body-lifetime problem is removed by construction rather + /// than papered over. + /// + /// **"Identical" scope.** The trait contract guarantees identical + /// **input handling**: same preflight, same index alignment, same + /// per-slot Ok/Err shape. The *cross-slot timing behaviour* is **not** + /// uniform — see the `send-all-slot-isolation` capability (§3.5.1). + /// On Axum/CF/Spin `join_all` fans out body drains concurrently and a + /// slot's result reflects what it would have produced in isolation. + /// On Fastly buffered-body drains run in harvest order (§3.3.4), so a + /// slot can return `gateway_timeout` because an earlier slot + /// monopolised harvest — even when its own `budget.deadline` would + /// have covered its body in isolation. Apps that require cross-slot + /// isolation declare the capability required and get a hard build + /// failure on Fastly per §3.5.3. + /// + /// Per-slot `Ok`/`Err` semantics: since preflight rejects streamed bodies AND + /// streamed responses, every surviving slot is Buffered on both sides, so the + /// per-slot result shape matches `send`'s **Buffered-mode** semantics — `Ok(resp)` + /// means the full exchange completed within the deadline and the body fits + /// within `max_response_bytes`; `Err(_)` is transport / deadline / over-cap. + /// Streamed-mode `Ok`-means-headers-only does not apply here because there are + /// no streamed slots. + async fn send_all( + &self, + reqs: Vec, + ) -> Vec>; +} +``` + +Both `send` and `send_all` are required on the trait. Each adapter implements both; in +practice they share an internal helper for buffered-body single sends, so the +single-request and batch paths cannot drift. + +#### 3.1.2 App-facing handle + +```rust +/// Cloneable handle stored in request extensions and handed to handlers. +/// This is the only outbound *client/handle* type application code touches; +/// handlers also build `OutboundRequest` and read `OutboundResponse`. +#[derive(Clone)] +pub struct HttpClient { + inner: Arc, +} + +impl HttpClient { + pub fn new(client: Arc) -> Self; + pub fn with_client(client: C) -> Self; + + pub async fn send(&self, req: OutboundRequest) -> Result; + pub async fn send_all( + &self, + reqs: Vec, + ) -> Vec>; +} +``` + +Obtained from the context: + +```rust +// crates/edgezero-core/src/context.rs — replaces proxy_handle() +// After the round-6 restructure (§3.4.5), the context exposes `parts` rather than +// a `Request`. The `HttpClient` handle is stored in request extensions during +// adapter setup and retrieved via parts.extensions. +impl RequestContext { + pub fn http_client(&self) -> Option { + self.parts.extensions.get::().cloned() + } +} +``` + +#### 3.1.3 Request and response types + +```rust +pub struct OutboundRequest { + method: Method, + uri: Uri, // validated + canonicalized; see below + headers: HeaderMap, + body: Body, // buffered or streamed + timeout: Option, // per-request budget + deadline: Option, // shared absolute cap; copy one value into every target request, do not recompute per request (see §3.3.2) + response_mode: ResponseMode, // Buffered { max_bytes } (default) | Streamed + max_request_body_bytes: usize, // cap when `body` is Body::Stream (default 8 MiB) +} + +/// How the adapter delivers the response body. Default is `Buffered`. +pub enum ResponseMode { + /// Adapter reads the full body within the deadline, enforcing a decompressed + /// byte cap. `OutboundResponse.body` is `Body::Once`. + Buffered { max_bytes: usize }, // default max_bytes = DEFAULT_MAX_RESPONSE_BYTES + /// Adapter returns headers; `OutboundResponse.body` is `Body::Stream`. The + /// caller buffers later (e.g. `into_bytes_bounded`) or passes the body through. + Streamed, +} + +impl OutboundRequest { + /// Constructors validate **and canonicalize** the URI: + /// + /// - Scheme must be `http` or `https` (plain `http` is permitted — + /// required for loopback contract tests). Other schemes → + /// `Err(EdgeError::bad_request("outbound URI scheme must be http or + /// https"))`. + /// - An authority must be present. Missing authority → + /// `Err(EdgeError::bad_request("outbound URI must be absolute with + /// authority"))`. + /// - **Userinfo is rejected.** `https://user:pass@example.com` → + /// `Err(EdgeError::bad_request("outbound URI must not contain + /// userinfo; pass credentials via the `authorization` header"))`. + /// This keeps the Fastly backend Host override (§4.3) unambiguous and + /// stops accidental credential leakage. + /// - **Fragments are rejected at the string-input boundary.** + /// `OutboundRequest::get("https://x/p#anchor")` and `::post(..)` parse + /// the input as a string *first* (they take `impl AsRef` — see + /// below) and reject a `#` before `http::Uri` ever sees it, with + /// `Err(EdgeError::bad_request("outbound URI must not contain a + /// fragment"))`. `http::Uri` truncates at `#`, so a Uri-typed input + /// has already lost the fragment by the time we receive it. + /// `OutboundRequest::new(method, uri)` and `OutboundRequest::from_parts` + /// therefore cannot detect fragments — the caller built a `Uri`, which + /// means whatever was after `#` is gone. Documented asymmetry, not a + /// silent surprise: when constructing from a raw string use + /// `get`/`post` and you get fragment rejection for free; when you + /// already hold a `Uri`, fragments are not an issue because they were + /// stripped during `Uri` parsing. + /// - **Default ports are normalized away.** A `Uri` parsed from + /// `https://example.com:443` is rewritten so `uri.port()` returns + /// `None`; `http://example.com:80` likewise. This means + /// `https://example.com` and `https://example.com:443` produce + /// identical `OutboundRequest`s — same `resolved_port` in the §4.3 + /// Fastly identity, same Host override, one dynamic backend. Explicit + /// non-default ports (`:8443`, `:3000`) are preserved verbatim. + /// - **Scheme and host are lowercased.** Per RFC 3986 §3.1 (scheme) and + /// §3.2.2 (host) both are case-insensitive, so `https://EXAMPLE.com`, + /// `HTTPS://example.com`, and `https://example.com` are the same + /// origin. The canonicalization rewrites the stored URI to lowercase + /// so `OutboundRequest::uri()` always reports the lowercase form, + /// and downstream consumers (Fastly backend identity in §4.3, + /// app-level allowlist checks, Spin `allowed_outbound_hosts` + /// matching) compare against one canonical spelling. Userinfo and + /// fragments are already rejected above; path and query are passed + /// through verbatim (case-sensitive per RFC 3986 §3.3 / §3.4). + /// + /// These canonicalizations run inside the constructors before the URI + /// is stored, so every downstream consumer (Fastly backend identity, Host + /// override, allowlist checks) sees a single canonical form. + pub fn new(method: Method, uri: Uri) -> Result; + /// `get` and `post` take `impl AsRef` (not `TryInto`) so the raw + /// string is available for fragment detection *before* `http::Uri` + /// truncates at `#`. The impl checks for `#` in the input bytes, then + /// parses with `Uri::try_from(&str)`, then runs the rest of §3.1.3 + /// canonicalization. `&str`, `String`, and any `AsRef` work; an + /// already-built `Uri` goes through `OutboundRequest::new` (which cannot + /// detect fragments because the `Uri` has already lost them — see + /// "Fragments are rejected at the string-input boundary" above). + pub fn get(uri: impl AsRef) -> Result; + pub fn post(uri: impl AsRef) -> Result; + + /// Forward an inbound request to a new target. Preserves method and body + /// (which may stream). Headers are normalized for proxy forwarding — + /// the rules live in core so adapters cannot diverge: + /// + /// - hop-by-hop headers are stripped: `connection`, `keep-alive`, + /// `proxy-authenticate`, `proxy-authorization`, `te`, `trailer`, + /// `transfer-encoding`, `upgrade` (RFC 7230 §6.1), plus every header + /// named in the inbound `connection` header value; + /// - `host` is **dropped** from the headers. The adapter sets the final + /// `Host` value (or platform SDK equivalent) from + /// `req.host_authority()` at SDK-construction time — the same + /// canonical accessor every adapter uses (§3.1.4). The accessor + /// already encodes the rules: explicit port preserved when the URI + /// carries a non-default port (`https://example.com:8443` → + /// `Host: example.com:8443`); port stripped when default + /// (`https://example.com` → `Host: example.com`); IPv6 hosts + /// bracketed. **Adapters MUST NOT read `req.uri()` for the Host + /// value** — `host_authority()` is the single source of truth, so the + /// Fastly identity hash, the Cloudflare `set_header("host", ..)` arg, + /// the Axum reqwest Host setter, and the Spin outgoing-request Host + /// field all observe the same string. No part of the pipeline reads + /// `host` from `req.headers()`. `normalize_for_dispatch` re-strips + /// `host` defensively as a safety net for callers that reached past + /// `header(..)` via `headers_mut()`; + /// - `content-length` is dropped — the adapter sets it from the new body + /// for `Body::Once`, or omits it (relying on chunked transfer) for + /// `Body::Stream`. + /// + /// All other headers are preserved verbatim. Validates `uri` per `new`. + pub fn from_request(request: Request, uri: Uri) -> Result; + + /// Fallible: header name/value construction from arbitrary inputs can + /// fail. The signature takes `impl AsRef<[u8]>` for both name and value + /// — **not** `TryInto` / `TryInto`. The standard + /// `TryFrom<&str> for HeaderValue` path is built on + /// `HeaderValue::from_str`, which rejects every byte outside visible + /// ASCII and would refuse a valid non-ASCII UTF-8 header + /// (`x-app-display-name: café`) before EdgeZero's own UTF-8 rule could + /// run. By taking bytes directly: + /// + /// 1. `HeaderName::from_bytes(name.as_ref())` — strict name check (HTTP + /// grammar). + /// 2. `std::str::from_utf8(value.as_ref()).is_err()` → reject with + /// `EdgeError::bad_request("header value is not valid UTF-8: ")` + /// (the EdgeZero rule per §3.1.4). + /// 3. `HeaderValue::from_bytes(value.as_ref())` — applies the **HTTP + /// header-value byte rule** (visible ASCII + obs-text; rejects + /// control bytes like `\n`, `\0` that would enable header injection). + /// Combined with step 2, the values that survive are exactly the ones + /// that are **both** valid UTF-8 **and** valid HTTP header bytes — a + /// valid-UTF-8 string containing a forbidden control byte is still + /// rejected, which is intended security behaviour. Two distinct error + /// messages distinguish the cause (forbidden-bytes vs invalid-UTF-8). + /// + /// Works for `&str`, `String`, `&[u8]`, `Vec`, and `HeaderName` / + /// `HeaderValue` (both `AsRef<[u8]>`). + pub fn header(self, name: N, value: V) -> Result + where + N: AsRef<[u8]>, + V: AsRef<[u8]>; + /// Escape hatch for callers holding already-validated + /// `HeaderName`/`HeaderValue` (or building from `from_request`). The + /// returned `HeaderMap` is not validated here — non-UTF-8 values and + /// stray hop-by-hop / framing headers (`host`, `content-length`, + /// `transfer-encoding`) are caught by the adapter's + /// `normalize_for_dispatch` sweep before the request is issued (§3.1.4). + pub fn headers_mut(&mut self) -> &mut HeaderMap; + + pub fn body(self, body: impl Into) -> Self; // Bytes or a stream + /// Serialize `value` as JSON and set the request body to the resulting + /// bytes. Sets `content-type: application/json` only if the request has + /// no `content-type` yet — a caller-set value is preserved unchanged. + /// `content-length` is left to the adapter (it is recomputed from the + /// serialized body for `Body::Once` and omitted for `Body::Stream`). + /// Serialization failure yields `Err(EdgeError::internal(..))`. + pub fn json(self, value: &T) -> Result; + + pub fn timeout(self, d: Duration) -> Self; + pub fn deadline(self, d: Deadline) -> Self; + pub fn max_response_bytes(self, n: usize) -> Self; // sets Buffered { n } + pub fn stream_response(self) -> Self; // sets Streamed + + /// Cap on the **request** body when it is a `Body::Stream` — see + /// §4.1/§4.2/§4.3/§4.4. EdgeZero's core `Body::Stream` is `LocalBoxStream` + /// (WASM-friendly, not `Send + 'static`), so adapters cannot hand it + /// directly to a SDK that requires `Send` streams (notably reqwest + /// without its `stream` feature). The contract is therefore: streamed + /// request bodies are **bounded** by this cap on every adapter; adapters + /// MAY pass the stream through to the platform natively (Fastly's + /// `send_async_streaming`, Spin's WASI outgoing body) or buffer to + /// `Bytes` within the cap before dispatch (Axum, Cloudflare). Over-cap + /// during drain → `bad_request` (400) — a client-side misuse. + /// Default `DEFAULT_OUTBOUND_REQUEST_BODY_BYTES = 8 MiB`. + pub fn max_request_body_bytes(self, n: usize) -> Self; + + pub fn method(&self) -> &Method; + pub fn uri(&self) -> &Uri; // apps inspect this for their own allowlist + pub fn headers(&self) -> &HeaderMap; + + // ---- Canonicalized URI accessors (adapter-facing, non-consuming) ---- + // + // These four accessors are the **single canonical source** of the + // host/port/SNI/cert-host split that every adapter needs. They are + // derived from `self.uri()` after the §3.1.3 canonicalization rules + // have rejected **userinfo and fragments**, validated the port, and + // lower-cased scheme + host. **Path and query are preserved verbatim** + // (per §3.1.3 — case-sensitive per RFC 3986 §3.3 / §3.4); they do not + // appear in these accessors because none of them are host/port/SNI/cert + // values, but they remain accessible via `self.uri()` for the wire-level + // request line. **Adapters MUST consume these accessors rather than + // re-deriving from `uri()`** for the host/port/SNI/cert split — both to + // share the canonicalization logic and so the Fastly identity hash + // sees a single canonical form (§4.3). They are also the values + // tested by the Tier 1 half of the §5.4 four-value row. + // + // **Manifest `[capabilities.outbound].hosts` entries are a separate + // grammar** (§3.5.4) — those entries are host-authority-only + // declarations, so the manifest-host validator **rejects** path / query + // / fragment / userinfo on the manifest side. That validator and the + // request-URI canonicalization rules above share the userinfo / fragment + // reject and the lowercase-scheme/host pass, but diverge on path/query: + // request URIs pass them through; manifest host entries reject them. The + // two rule sets must not be conflated. + + /// Connection target — always `":"`, with the port resolved + /// (default ports filled in: `http` → 80, `https` → 443). IPv6 hosts + /// are bracketed (`[::1]:443`). This is what Fastly's + /// `Backend::builder(name, ..)` expects and what Spin uses for its + /// `allowed_outbound_hosts` rendering when the source had no explicit + /// port. Stable across canonicalization (same value whether the input + /// was `https://example.com` or `https://example.com:443`). + pub fn backend_target(&self) -> String; + + /// Authority for the outgoing `Host` header. Carries the explicit port + /// **only when it is non-default** for the scheme: + /// `https://example.com:8443` → `"example.com:8443"`; + /// `https://example.com` → `"example.com"`. IPv6 hosts are bracketed. + /// This is what Fastly's `.override_host(..)` and Cloudflare's + /// outbound `Request::set_header("host", ..)` consume; Axum / Spin pick + /// it up the same way. + pub fn host_authority(&self) -> String; + + /// SNI hostname — what an HTTPS adapter passes to its TLS stack's + /// SNI setter (Fastly's `.sni_hostname(..)`, Spin/CF's underlying + /// TLS config, etc.). Port-stripped, bracket-stripped for IPv6. + /// **Returns `None` for IP-literal hosts** (IPv4 and IPv6) per + /// RFC 6066 §3, which forbids SNI for IP literals. Adapters call + /// the TLS-stack SNI setter only when this returns `Some`; for `None` + /// the SNI extension is omitted from the ClientHello. **Adapters + /// MUST NOT fall back to `uri().host()` for SNI** — `None` here + /// means "send no SNI," not "derive it yourself." The cert verification + /// host is `cert_host()` below, not this accessor. + pub fn sni_hostname(&self) -> Option<&str>; + + /// Certificate-verification host — what an HTTPS adapter passes to + /// its TLS stack's certificate-verification setter (Fastly's + /// `.check_certificate(..)`, Spin/CF's underlying TLS verifier). + /// **Always present for HTTPS, always port-stripped, always + /// bracket-stripped for IPv6.** Unlike SNI, certificate verification + /// is meaningful for IP literals too — verification will check the + /// presented certificate's SAN against the IP literal (e.g. `127.0.0.1`, + /// `::1`). Returns `None` only for non-HTTPS schemes (i.e. `http`), + /// where the accessor is not used by the adapter. **This is the + /// single canonical source for `.check_certificate(..)` arguments + /// across every adapter**; adapters MUST NOT call `uri().host()` and + /// post-process — they call `cert_host()` and pass it through. + /// + /// Concrete examples: + /// - `https://example.com` / `https://example.com:443` → `Some("example.com")` + /// - `https://example.com:8443` → `Some("example.com")` (port stripped — cert is not port-qualified) + /// - `https://127.0.0.1` → `Some("127.0.0.1")` + /// - `https://[::1]` / `https://[::1]:443` → `Some("::1")` (brackets stripped) + /// - `http://example.com` → `None` + pub fn cert_host(&self) -> Option<&str>; + + // ---- Adapter-facing inspection (non-consuming) ---- + /// Cheap non-consuming check used by `send_all` preflight (§3.1.1 / + /// §4.1–§4.4): if `true`, the slot is rejected with `bad_request` + /// *before* `send_one` is invoked, so the streamed-upload path is never + /// reached from `send_all`. `send` (single-request) handles `Body::Stream` + /// directly per its trait contract. + pub fn is_stream_body(&self) -> bool; + + /// Cheap non-consuming check used by `send_all` preflight: if `true` + /// (i.e. `response_mode == Streamed`), the slot is rejected with + /// `bad_request` before `send_one` is invoked. `send` (single-request) + /// handles streamed responses directly. + pub fn is_stream_response(&self) -> bool; + + // ---- Adapter-facing disassembly / reassembly ---- + /// Consume the request into its constituent parts. Adapters call this + /// inside `send` / `send_all` after `normalize_for_dispatch` has run, + /// to hand the components to the platform SDK. + pub fn into_parts(self) -> OutboundRequestParts; + /// Round-trip constructor for adapters that need to destructure, mutate + /// a single field, and reassemble (rare — most adapter paths consume). + /// All fields are pub on `OutboundRequestParts`, so this is just a + /// disciplined re-wrap and applies the same invariants as + /// `new`/`get`/`post` (URI validation re-runs). + pub fn from_parts(parts: OutboundRequestParts) -> Result; +} + +/// Disassembled form of an `OutboundRequest`. Adapter-facing only. +pub struct OutboundRequestParts { + pub method: Method, + pub uri: Uri, + pub headers: HeaderMap, + pub body: Body, + pub timeout: Option, + pub deadline: Option, + pub response_mode: ResponseMode, + pub max_request_body_bytes: usize, // applies when `body` is Body::Stream +} + +pub struct OutboundResponse { + status: StatusCode, + headers: HeaderMap, + body: Body, // Once in Buffered mode, Stream in Streamed mode +} + +impl OutboundResponse { + /// Adapter-facing constructor. Adapters build the response from the + /// platform SDK's reply: status, normalized headers (decompression + /// strips `content-encoding`/`content-length` per §3.4.1; non-UTF-8 + /// values are dropped per §3.1.4), and the body (`Body::Once` in + /// `Buffered` mode after the adapter has drained and capped, or a + /// `Body::Stream` wrapped with the deadline-aware wrapper described + /// in `into_bytes_bounded_until` for `Streamed` mode). + pub fn new(status: StatusCode, headers: HeaderMap, body: Body) -> Self; + + /// Adapter-facing destructure. Mirrors `OutboundRequest::into_parts`. + pub fn into_parts(self) -> (StatusCode, HeaderMap, Body); + + /// Adapter-facing mutation point — used during construction (e.g. to + /// strip `content-encoding` after decompression). App code uses the + /// immutable `headers()` accessor instead. + pub fn headers_mut(&mut self) -> &mut HeaderMap; + + // ---- App-facing accessors ---- + pub fn status(&self) -> StatusCode; + pub fn is_success(&self) -> bool; // 2xx + pub fn headers(&self) -> &HeaderMap; + pub fn body(&self) -> &Body; + + /// **App-facing consuming accessor** for the response body — the orchestration + /// path for streamed responses recommended by `send_all`'s rustdoc (§3.1.1). + /// Returns the underlying `Body` so app code can iterate `Body::Stream` chunks + /// directly (the wrapper installed at response construction time still + /// enforces `dispatch_budget(req).deadline` per §3.3.3) or extract the + /// `Body::Once` `Bytes` if the adapter buffered. This is distinct from the + /// adapter-facing `into_parts(self) -> (StatusCode, HeaderMap, Body)` + /// destructure used inside response converters; apps that need just the + /// body for streaming orchestration call `into_body()` and drop the rest. + /// On `Streamed` mode with single `send`, this is the canonical orchestration + /// path: drive `send` concurrently across N requests via `futures::join_all` + /// on Axum/CF/Spin, then iterate each response's `into_body()` stream in + /// parallel — no `send_all` (which is buffered-only by design, §3.1.1). + pub fn into_body(self) -> Body; + + /// Buffer the body with a decompressed-byte cap. Works for both `Once` + /// and `Stream`. Over-cap yields `Err(EdgeError::bad_gateway(..))` (502). + /// + /// This is NOT a thin wrapper over `Body::into_bytes_bounded` — that + /// helper maps over-limit to `bad_request` (400), correct for inbound + /// bodies but wrong for an over-large upstream response. This method + /// performs its own bounded drain (pre-append checked accounting per + /// §3.4.1) and maps to `bad_gateway` (502). On adapters that decompress + /// (§3.4.1), the cap is enforced against decompressed output here too. + /// + /// **Effective-budget deadline is already honoured on a streamed body.** + /// Per §3.3.3, adapters with platform timers (Axum/CF/Spin) wrap + /// `Streamed` response bodies with a deadline-aware stream bounded by + /// `dispatch_budget(req).deadline` — which is non-`None` even for + /// timeout-only and no-deadline requests (the synthetic 30 s ceiling) — + /// so a stalled upstream yields a `gateway_timeout` error chunk and + /// this drain returns 504. Fastly's bounded-cooperative body check + /// (§3.3.4) achieves the same end with a documented overshoot bound. + /// There is no need to thread the deadline through manually — call + /// `into_bytes_bounded_until(max, deadline)` only when you want to + /// **cooperatively narrow** the failure timing on top of the request + /// budget (see the precise bound and caveat below). + pub async fn into_bytes_bounded(self, max: usize) -> Result; + + /// As `into_bytes_bounded`, but additionally bounded by a `Deadline` + /// that the caller passes per drain. **The helper is a *cooperative* + /// post-read / EOF validator, not a timer-backed race.** The bound it + /// provides is *exactly* "the first `is_expired()` check that observes + /// expiry returns `gateway_timeout`," where the check sites are + /// enumerated below. A read that is already blocked when the deadline + /// passes does **not** get preempted by this helper — it returns when + /// the underlying source returns (chunk, EOF, or wrapper-emitted error + /// chunk past the request budget), and the helper's *next* check (or + /// post-return check for `Body::Once`) is what fires. Real-time + /// preemption is the *wrapper's* job (the adapter installs a + /// deadline-aware stream bounded by `dispatch_budget(req).deadline` at + /// response construction time, per §3.3.3); the helper only catches the + /// **tighter `until`** case at yield boundaries. + /// + /// Concretely, if the wrapper still has 500 ms and the caller passes + /// `until_deadline = now + 100 ms`, and a body read happens to block + /// for the full 500 ms, the helper does **not** return at 100 ms — it + /// observes the expired `until` at the 500 ms post-read check and + /// returns `gateway_timeout`. The bound the helper provides is "first + /// expiry check at or after `until_deadline`," not wall-clock = `until`. + /// Apps that need wall-clock preemption tighter than the request budget + /// must either lower `dispatch_budget(req).deadline` (set + /// `.deadline(min(req_deadline, app_inner_deadline))` on the builder) + /// or split the work into a smaller request. + /// + /// Works on both `Body::Once` and `Body::Stream`: + /// + /// - **`Body::Once` (already buffered)**: the helper checks + /// `until_deadline.is_expired()` **at entry**, before doing anything + /// else, and returns `gateway_timeout` if expired. Otherwise it + /// checks the buffered length against `max` — under cap → `Ok(bytes)`; + /// over cap → `bad_gateway`. **Precedence: expired deadline beats + /// over-cap** (an over-cap error after the deadline has expired is + /// masked by the deadline check, since the caller's `until` rolled + /// the result regardless of cap behaviour). This entry-time check + /// makes single `send` + `Body::Once` callers see consistent + /// `gateway_timeout` semantics whether their response arrived + /// already-buffered or streamed. + /// - **`Body::Stream`**: the helper checks `until_deadline.is_expired()` + /// **both before issuing each blocking body read and again after it + /// returns** — including the EOF read. Returns + /// `Err(EdgeError::gateway_timeout(..))` (504) on the first expired + /// check. + /// + /// **Enforcement composes layer-wise without sharing state.** The + /// adapter wrapper installed at response construction time enforces + /// the request's `dispatch_budget(req).deadline` by yielding + /// `Err(EdgeError::gateway_timeout(..))` chunks past *that* deadline + /// (§3.3.3); this helper enforces `until_deadline` cooperatively at + /// the four check sites enumerated above (entry for `Body::Once`; + /// before and after each underlying read including EOF for + /// `Body::Stream`). **"Whichever fires first" is at yield boundaries + /// only**: the wrapper's error chunk arrives in real time (timer-backed + /// on Axum / CF / Spin; bounded-cooperative on Fastly per §3.3.4); the + /// helper's `until_deadline` fires at the next check site. If the + /// caller's `until_deadline` is tighter and the next underlying read + /// returns promptly, the helper fires first; if the next underlying + /// read blocks past `until` but within the wrapper's budget, the helper + /// still fires (post-read check) and the helper's bound is "read + /// latency + at most one extra check," not zero. There is no shared + /// "effective deadline" stored on `OutboundResponse` (which carries + /// only status / headers / body), and no `min(..)` computation in the + /// helper. Apps that need a single combined check with **timer-backed + /// preemption** of the tighter deadline pass + /// `min(req_deadline, app_inner_deadline)` to `.deadline(..)` on the + /// `OutboundRequest` builder instead of layering here — that pushes + /// the tighter deadline into the wrapper, which is the only layer with + /// real-time enforcement on Axum / CF / Spin. + /// + /// **Enforcement is layered.** The helper itself is cooperative on every + /// adapter — its before-and-after-read `is_expired()` check cannot + /// preempt a read in progress. Real-time enforcement of the request + /// budget comes from the adapter wrapping streamed response bodies at + /// construction time: + /// + /// - **Axum, Cloudflare, Spin** — the adapter wraps the response body + /// with a deadline-aware stream using its platform timer (tokio / + /// `worker::Delay` / wasi monotonic-clock), bounded by + /// `dispatch_budget(req).deadline`. That deadline is non-`None` for + /// every request (synthetic 30 s ceiling when `req.deadline` was + /// absent), so the wrapping is unconditional — *not* "only when + /// `req.deadline.is_some()`." Each chunk read is bounded by the + /// request's effective deadline, so a peer that stalls mid-stream + /// produces an error chunk at that deadline rather than blocking. + /// `into_bytes_bounded_until`'s helper-side `is_expired()` check on + /// the caller-supplied `until_deadline` is what catches the + /// *tighter* `until` case (e.g. the wrapper has 500 ms left but the + /// caller passed a 100 ms `until`) **at the next yield boundary**, + /// not in real time. If a read happens to block for the full 500 ms, + /// the helper returns at 500 ms with `gateway_timeout` (post-read + /// check observed expiry), not at 100 ms. Use + /// `min(req_deadline, app_inner_deadline)` on the builder for + /// timer-backed preemption. + /// - **Fastly** — no guest async timer (§3.3.5), but the adapter still + /// wraps the streamed response body with a **cooperative + /// deadline-aware stream** that checks `budget.deadline.is_expired()` + /// **both before issuing the underlying body read and again after it + /// returns** (including the read that discovers EOF, per §3.3.4) and + /// emits a `gateway_timeout` error chunk past the deadline instead + /// of `Ok(chunk)` or stream-end. This makes `into_bytes_bounded`, + /// `into_response()` passthrough, and any other consumer of the + /// wrapped body honour the deadline uniformly — the deadline does + /// not depend on whether the caller chose this helper specifically. + /// Bounded-cooperative semantics apply: a stream that yields one + /// chunk and then stalls returns control on the host's + /// between-bytes-timeout (§3.3.4), so worst-case overshoot per chunk + /// gap is one between-bytes-timeout interval — never unbounded. + /// + /// The real-vs-bounded distinction matches the `outbound-deadlines` + /// capability matrix in §3.5.2. Decompression-cap and 502-mapping + /// behaviour matches `into_bytes_bounded`. + pub async fn into_bytes_bounded_until( + self, + max: usize, + deadline: Deadline, + ) -> Result; + /// JSON-decode the already-buffered body. Requires `Body::Once`; on a + /// `Body::Stream` returns `Err(EdgeError::bad_gateway("response body + /// not buffered; use json_bounded(max) or json_bounded_until(max, + /// deadline)"))`. Malformed JSON yields `Err(EdgeError::bad_gateway(..))` — + /// an upstream returning unparseable JSON is a 502 outcome, not a 400. + pub fn json(&self) -> Result; + + /// Buffer (with a decompressed-byte cap) then JSON-decode in one step. + /// Consuming convenience for the `Streamed` mode: equivalent to + /// `into_bytes_bounded(max).await` + `serde_json::from_slice`, with + /// malformed JSON mapping to `bad_gateway` (502). + pub async fn json_bounded(self, max: usize) + -> Result; + + /// As `json_bounded`, additionally bounded by a caller-supplied + /// `Deadline`. **The caller-supplied deadline is enforced + /// cooperatively by `into_bytes_bounded_until`** — that is, at the + /// yield boundaries enumerated in that helper's rustdoc (entry for + /// `Body::Once`; before and after each underlying read including EOF + /// for `Body::Stream`). A read already blocked when `deadline` passes + /// does **not** get preempted by this helper; it returns when the + /// underlying source returns, and the next check fires. **Real-time + /// enforcement is the wrapper's job** — adapters with platform timers + /// (Axum / CF / Spin) install a deadline-aware stream bounded by + /// `dispatch_budget(req).deadline` at response construction time + /// (§3.3.3), so the **request budget** is enforced in real time on + /// those three; Fastly is `BoundedCooperative` on the request budget + /// (§3.3.4). The `deadline` argument here only adds the cooperative + /// post-read tighten; it does not get its own wrapper. Apps that need + /// timer-backed preemption of a deadline tighter than the request + /// budget set `.deadline(min(req_deadline, app_inner_deadline))` on + /// the `OutboundRequest` builder so the tighter deadline lands in the + /// wrapper. Malformed JSON maps to `bad_gateway` (502). + pub async fn json_bounded_until( + self, + max: usize, + deadline: Deadline, + ) -> Result; + /// Pass the response through as a core `Response` (keeps a streamed body lazy). + pub fn into_response(self) -> Result; +} +``` + +The complete builder surface — `new`/`get`/`post`/`from_request`/`header`/`headers_mut`/ +`body`/`json`/`timeout`/`deadline`/`max_response_bytes`/`max_request_body_bytes`/`stream_response`. Every fallible +step returns `EdgeError`, so handler code uses `?` uniformly. + +#### 3.1.4 Adapter behaviour contract — redirects and header encoding + +These rules apply identically on every adapter so handler code is portable. + +**Redirects: not followed automatically.** A 3xx upstream response is delivered to the +app as `Ok(OutboundResponse)` with the 3xx status and the `Location` header preserved. +EdgeZero never silently follows a redirect on the app's behalf. This is a security +property: an app that allowlists `https://trusted.example` and checks `req.uri()` before +sending can never be diverted to `https://attacker.example` by an upstream 302, because +following the redirect requires the app to issue a fresh `OutboundRequest` — at which +point its allowlist runs again. Per-adapter mechanics: + +| Adapter | How to disable auto-redirect | +| --- | --- | +| Axum | `reqwest::ClientBuilder::redirect(reqwest::redirect::Policy::none())` | +| Cloudflare | `worker::RequestInit { redirect: "manual", .. }` | +| Spin (WASI) | `spin_sdk::http::send` does not auto-follow — no opt-out needed | +| Fastly | `fastly` does not auto-follow — no opt-out needed | + +Apps that want to follow a redirect read `resp.headers().get("location")`, run their +allowlist against the new URI, and issue a new request. + +**Header value encoding: UTF-8.** EdgeZero requires every outbound and inbound-of-outbound +header value to be valid UTF-8. Spin/WASI cannot represent non-UTF-8 header values, so +portability mandates this rule everywhere — uniform behaviour beats per-adapter +lossiness for headers that matter. + +- *Outbound request headers.* `OutboundRequest::header(..)` constructs the + `HeaderValue` via `HeaderValue::from_bytes(value.as_ref())`, **not** + `HeaderValue::from_str` — the latter rejects every byte outside visible ASCII and + would refuse a perfectly valid non-ASCII UTF-8 header like + `x-app-display-name: café` before EdgeZero's UTF-8 rule runs. The builder's + `V: AsRef<[u8]>` bound means `value.as_ref() -> &[u8]` works uniformly for `&str`, + `String`, `&[u8]`, `Vec`, `HeaderName`, and `HeaderValue`. + `HeaderValue::from_bytes` accepts the **HTTP header-value byte set** (visible + ASCII + obs-text, with control bytes like `\n`/`\0` rejected to prevent header + injection); EdgeZero then layers its own UTF-8 check via + `std::str::from_utf8(value.as_ref()).is_ok()`. The accepted set is therefore + **valid UTF-8 *and* valid HTTP header-value bytes**, not "all valid UTF-8" — an + HTTP-invalid byte (`\n`, `\0`) inside a UTF-8-valid string still rejects, and + that's intended security behaviour. Two distinct failure messages: + `Err(EdgeError::bad_request("header value contains forbidden bytes: "))` + for the HTTP-validity reject, `Err(EdgeError::bad_request("header value is not + valid UTF-8: "))` for the UTF-8 reject. Loud and at construction time. +- *Outbound response headers.* If an upstream response carries non-UTF-8 header values, + **each individual value** is checked (`std::str::from_utf8` on the raw byte slice from + the platform SDK) — invalid values are dropped, valid sibling values for the same + header name are preserved. Multi-value headers like `set-cookie` therefore keep + every valid entry even if one duplicate is invalid. The adapter emits a `log::warn!` + naming each dropped header. The rest of the response is delivered normally so a + malformed exotic header cannot poison an otherwise valid fan-out batch response. + +*Implementation guardrail.* The UTF-8 check uses `std::str::from_utf8(value.as_bytes())`, +**not** `HeaderValue::to_str()`. `to_str()` is stricter than UTF-8 — it rejects any +byte outside visible ASCII — and would incorrectly drop valid non-ASCII UTF-8 headers +(e.g. an `x-app-display-name: café` style header). Adapters and the core +`normalize_for_dispatch` helper both use `str::from_utf8(value.as_bytes()).is_ok()`. +§5.4 has a test that asserts a valid non-ASCII UTF-8 request and response header survive +round-trip on every adapter, plus one that asserts a header containing a `\x80` byte is +dropped (response) or rejected (request). + +Headers that matter for security, tracing, caching, and content negotiation +(`authorization`, `traceparent`, `cookie`, `cache-control`, `accept`, `content-type`, +…) are ASCII-only by spec and are unaffected by this rule. The trade-off only restricts +exotic non-UTF-8 custom headers; apps requiring fidelity for those must not use +EdgeZero outbound for that case. + +**Final normalization at dispatch (`outbound::normalize_for_dispatch`).** Two surfaces +bypass the construction-time `header(..)` check — `headers_mut()` exposes raw +`HeaderMap`, and `from_request(..)` carries inbound headers in. Adapters MUST call a +core helper `outbound::normalize_for_dispatch(&mut OutboundRequest)` immediately before +handing the request to the platform SDK. The helper is idempotent and runs the same +rules end-to-end: + +1. Drop any header value that is not valid UTF-8 (drop + `log::warn!` naming the + header) — same lossy semantics as the response side. This applies **only** to + values that arrived via `headers_mut()` or `from_request(..)` (which carries + inbound headers verbatim). `OutboundRequest::header(..)` already rejects invalid + UTF-8 at construction with `bad_request` (§3.1.3), so a non-UTF-8 value can only + reach this stage by bypassing the checked builder. The policy split is + deliberate: construction is loud (caller error → 400); proxy-forward and + pre-validated-map paths are lossy (don't fail an otherwise-good forward over an + exotic header). The `warn!` makes the drop observable in either case. +2. Strip hop-by-hop headers (`connection`, `keep-alive`, `proxy-authenticate`, + `proxy-authorization`, `te`, `trailer`, `transfer-encoding`, `upgrade`, plus every + header named in any `connection` header value). Idempotent for `from_request` + output; mandatory for manually built requests. +3. Remove `host` — `normalize_for_dispatch` is the single source of truth for stripping + it from the request; the adapter then sets the final `Host` header (or platform + SDK equivalent) from `req.host_authority()` at SDK-construction time — the canonical + accessor (§3.1.4) — and does **not** re-read whatever was in `req.headers()` nor + reconstruct it from `req.uri()` directly. `from_request` (§3.1.3) also drops `host` + so the two sites agree end-to-end: the request structure carries no `host` from the + moment it leaves the core builders; the value on the wire comes from + `host_authority()`, which itself is derived from the canonicalized URI. One + accessor, one canonical string, every adapter consumes the same value. +4. Remove `content-length` — the adapter sets it from the body (length for + `Body::Once`; omitted for `Body::Stream`). +5. Remove `transfer-encoding` — the adapter sets it per body type and HTTP version. + +Apps can therefore use `headers_mut()` and `from_request` freely; portability and +framing safety are guaranteed by this final sweep, not by individual callers +remembering to sanitize. + +**Multi-value headers preserved.** `HeaderMap` permits repeated names — `set-cookie`, +`warning`, custom tracing headers, etc. EdgeZero adapters MUST preserve every entry for +a repeated header on both request and response: use `HeaderMap::append` (never +`insert`) when building, and read with `get_all` (never `get`) when serializing to the +platform SDK or deserializing platform responses. Per-adapter mechanics (the spots +current code uses single-value APIs that collapse): + +| Adapter | Request side (build platform request) | Response side (read platform response) | +| --- | --- | --- | +| Axum | `reqwest::RequestBuilder::header` (calls `HeaderMap::append`) | iterate `reqwest::Response::headers()` which is already a `HeaderMap` — preserve as-is | +| Cloudflare | `worker::Headers::append(name, value)` — **not** `set` (collapses) | iterate `worker::Headers` entries; `set-cookie` is enumerated separately by the worker runtime, handled explicitly | +| Fastly | `fastly::Request::append_header(name, value)` — **not** `set_header` | `fastly::Response::get_header_all(name)` per name, **not** `get_header` (returns first only) | +| Spin | `spin_sdk::http::Headers::append` — uses WASI HTTP `fields` which natively support multi-value | iterate WASI `fields` per name | + +Contract tests in §5.4 exercise repeated `set-cookie` response headers and repeated +outbound request headers, so any regression to collapsing duplicates is caught at CI +time. If a future SDK update breaks multi-value round-tripping on one adapter, the +spec downgrades the contract for that adapter and documents the limitation rather than +silently dropping headers. + +### 3.2 Concurrent fan-out + +`HttpClient::send_all` is the single concurrency API. It is truly concurrent on all four +platforms, and its **input/output contract** is identical (preflight, index alignment, +per-slot Ok/Err shape). Cross-slot timing **is not uniform** — see the +`send-all-slot-isolation` capability and §3.3.4 for Fastly's buffered-body +harvest-order caveat. App code never calls `futures::future::join_all`. + +| Adapter | `send_all` mechanism | Concurrency source | +| --- | --- | --- | +| Axum | `futures::future::join_all` of per-request `reqwest` sends | tokio reactor | +| Cloudflare | `futures::future::join_all` of `worker::Fetch` sends | Workers JS event loop | +| Spin | `futures::future::join_all` of `spin_sdk::http::send` | wasi async reactor | +| Fastly | dispatch every request with `send_async`, **then** harvest | Fastly host (parallel) | + +**Why a batch API and not `join_all` in app code.** Axum/Cloudflare/Spin have an async +reactor, so `join_all` of independent futures fans out. Fastly Compute has no guest +reactor: a future wrapping Fastly's poll-based `PendingRequest` would return `Pending` +with no waker, and `block_on` would deadlock. Fastly fan-out therefore *must* be +structured as "dispatch all, then harvest" — a shape that cannot be decomposed into N +independent futures. Making `send_all` the one primitive hides this entirely. + +**Where "identical" stops being identical: Fastly buffered body drain.** Adapter +contracts for the *headers* phase are identical across all four. The body-drain +phase is not: Fastly's buffered-body drain runs in harvest order rather than +concurrently with sibling drains (§3.3.4 "Buffered body drain runs in harvest +order"). For small bodies (fan-out batches, JSON) the wall-clock difference is negligible; +for large bodies on Fastly, EdgeZero has no API that delivers concurrent large-body +fan-out — `Streamed` mode defers drain but does not let the app consume chunks +concurrently across slots either (no guest reactor; §3.2). This is a known +limitation, not a recommendation. + +**Partial failure.** `send_all` returns `Vec>` +index-aligned with the input. A single target timing out or returning a 502 yields +`out[i] = Err(..)` or `out[i] = Ok(non-2xx)` without changing the *type* of any +other slot's result. Cross-slot **timing** is governed by `send-all-slot-isolation` +(§3.5.1 footnote 4): `Native` on Axum/CF/Spin, `BestEffort` on Fastly because +serial harvest-order body drain can cause a slot to return `gateway_timeout` even +when its own budget would have covered it (§3.3.4). Apps that need the stricter +timing guarantee declare the capability required and get a hard build failure on +Fastly. + +### 3.3 Portable deadline + +#### 3.3.1 `Deadline` — portable value type, in core + +```rust +// crates/edgezero-core/src/time.rs (new module) + +/// An absolute monotonic instant after which work should stop. A pure value type +/// — arithmetic over `web_time::Instant`, identical on every target, with no +/// runtime dependency. `time.rs` contains `Deadline`, `DispatchBudget`, +/// `dispatch_budget`, and the public timing constants (§7); the deliberate +/// constraint per §3.3.5 is that core carries **no runtime / timer / platform +/// dependency** — none of those types reaches outside the value-level +/// arithmetic and the trait surface adapters implement. +#[derive(Clone, Copy, Debug)] +pub struct Deadline { + at: web_time::Instant, +} + +impl Deadline { + /// `now + min(d, DEADLINE_FAR_FUTURE)`, where `DEADLINE_FAR_FUTURE` is a + /// **defined constant** clamp (7 days, see below). Bounded far-future clamping, + /// not "saturate to whatever Instant::MAX happens to be" — `std::time::Instant` + /// has no `MAX` and platform overflow behaviour differs. The clamp is + /// finite and well above any realistic fan-out batch/proxy budget, so this never + /// truncates a legitimate caller and never panics. Adapter boundaries must + /// not crash the host. + pub fn after(d: Duration) -> Self; + pub fn at_instant(instant: web_time::Instant) -> Self; // construct from absolute instant + pub fn instant(&self) -> web_time::Instant; // accessor for the absolute instant + pub fn remaining(&self) -> Option; // None once passed + pub fn is_expired(&self) -> bool; +} + +/// Hard upper bound on any caller-supplied duration. The clamp exists so +/// `Deadline::after` and `dispatch_budget` cannot panic on a pathological +/// `Duration::MAX` input. Set to **7 days** rather than something larger so the +/// ceiling fits inside every supported platform's per-request timeout range — in +/// particular Fastly's backend timeouts are `u32` milliseconds (≈ 49.7 days max +/// per Fastly 0.12.1), so the EdgeZero clamp must stay well below that. 7 days +/// is still orders of magnitude above any realistic outbound budget; nobody hits +/// it legitimately. +pub const DEADLINE_FAR_FUTURE: Duration = Duration::from_secs(7 * 24 * 60 * 60); +``` + +#### 3.3.2 Mapping an external batch deadline to EdgeZero deadlines + +| External concept | EdgeZero mechanism | +| --- | --- | +| External batch deadline (whole fan-out) | Compute `let batch_deadline = Deadline::after(Duration::from_millis(batch_deadline_ms))` **once** at handler entry, then pass that absolute value into every target request via `.deadline(batch_deadline)`. `Deadline` is `Copy` and absolute, so all targets share the same wall-clock cap. Do **not** call `Deadline::after(..)` per target — that re-anchors `now` per call and lets later targets drift past the batch deadline. | +| Per-target request timeout | `OutboundRequest::timeout(per_target)` | +| Effective per-request budget | computed by `dispatch_budget` — see below | + +**Effective budget rule (`dispatch_budget(req)`).** Returns a `DispatchBudget` struct +carrying **both** the duration to feed to platform SDK timeouts AND the absolute +`Deadline` to use for cooperative body-phase `is_expired()` checks. The implementation +computes a single set of candidate **absolute** deadlines from one monotonic `now` +snapshot and takes the minimum — so the effective deadline can never extend an +original `req.deadline`, and "no deadline" never gets conflated with "expired +deadline" via an `Option` round-trip. + +```rust +pub struct DispatchBudget { + pub duration: Duration, // SDK timeout setting + pub deadline: Deadline, // effective absolute deadline +} + +/// `now` is passed in (not snapshotted internally) so a single `send_all` can use +/// **one** `now` snapshot across every slot. Without that, sequential per-slot +/// `Instant::now()` calls produce slightly different `duration` values for the same +/// shared `Deadline`, which on Fastly would produce different `budget_ms` values +/// and therefore different dynamic-backend identities for the same host under one +/// batch deadline (§4.3). `send` (single request) just passes +/// `web_time::Instant::now()`. +pub fn dispatch_budget( + req: &OutboundRequest, + now: web_time::Instant, +) -> Result { + // (1) Expired-deadline check using the *single* now snapshot — no remaining() + // round-trip that could lose the distinction between "no deadline" and + // "deadline expired" (both produce None from remaining()). + if let Some(dl) = req.deadline { + if dl.instant() <= now { + return Err(EdgeError::gateway_timeout("deadline expired before dispatch")); + } + } + + // (2) Candidate absolute deadlines. Use checked_add throughout — a caller- + // supplied Duration::MAX must not panic the adapter. The same clamp as + // Deadline::after (§3.3.1): cap the duration at DEADLINE_FAR_FUTURE + // *before* the add, so the addition itself never overflows in practice + // (now + 7 days is well within Instant range). checked_add on the + // clamped value is belt-and-suspenders. + let saturating = |dur: Duration| -> Deadline { + let clamped = dur.min(DEADLINE_FAR_FUTURE); + let inst = now.checked_add(clamped).unwrap_or(now); // last-resort: now (immediate) + Deadline::at_instant(inst) + }; + let from_timeout = req.timeout.map(&saturating); + // `Deadline::at_instant` is public (§3.3.1), so a caller could construct a + // Deadline well past DEADLINE_FAR_FUTURE and bypass Deadline::after's clamp. + // Re-clamp `from_caller` here: the caller's deadline is never honoured beyond + // `now + DEADLINE_FAR_FUTURE`. This only tightens; a caller's deadline closer + // than that is unaffected. + let from_caller = req.deadline.map(|d| { + let far = now.checked_add(DEADLINE_FAR_FUTURE).unwrap_or(now); + Deadline::at_instant(d.instant().min(far)) + }); + let from_default_only = + (req.timeout.is_none() && req.deadline.is_none()) + .then(|| saturating(DEFAULT_NO_DEADLINE_BUDGET)); + + // (3) Effective deadline = min of the candidates (always at least one). + let deadline = [from_timeout, from_caller, from_default_only] + .into_iter() + .flatten() + .min_by_key(|d| d.instant()) + .expect("at least one candidate by construction"); + + // (4) Duration is derived from the chosen deadline and the same now snapshot + // — never `Deadline::after(duration)`, which would re-anchor to a *later* + // now and could extend the absolute deadline past the caller's intent. + let duration = deadline.instant().saturating_duration_since(now); + if duration.is_zero() { + return Err(EdgeError::gateway_timeout("effective budget is zero")); + } + + Ok(DispatchBudget { duration, deadline }) +} +``` + +Behaviour table (the implementation gives these directly; listed here for clarity): + +All `now + t` entries in this table are shorthand for `now + min(t, +DEADLINE_FAR_FUTURE)` (§3.3.1) — the clamp is universal, not a special case for +`Duration::MAX`. + +Below, `clamped(d)` denotes `Deadline::at_instant(d.instant().min(now + +DEADLINE_FAR_FUTURE))` — the re-clamp of a caller's `req.deadline` performed by +`dispatch_budget` so a `Deadline::at_instant` constructed past the 7-day clamp +cannot escape the bound (§3.3.2 step 2 / round 16). For brevity the table writes +`clamped(d)` rather than the full expression. + +| `req.timeout` | `req.deadline` | `duration` | `deadline` (absolute) | +| --- | --- | --- | --- | +| `None` | `None` | `30 s` | `now + 30 s` | +| `Some(t)` | `None` | `min(t, DEADLINE_FAR_FUTURE)` | `now + min(t, DEADLINE_FAR_FUTURE)` | +| `None` | `Some(d)` | `clamped(d).instant() - now` | `clamped(d)` | +| `Some(t)` | `Some(d)` with `now + min(t, …) < clamped(d).instant()` | `min(t, …)` | `now + min(t, …)` (tighter) | +| `Some(t)` | `Some(d)` with `now + min(t, …) ≥ clamped(d).instant()` | `clamped(d).instant() - now` | `clamped(d)` (tighter) | +| any | expired (`d.instant() <= now`) | — | `Err(gateway_timeout)` | +| any | duration ends up zero | — | `Err(gateway_timeout)` | +| `Some(Duration::MAX)` | `None` | `DEADLINE_FAR_FUTURE` (7 d) | `now + DEADLINE_FAR_FUTURE` | +| `None` | `Some(d)` 100 years out via `at_instant` | `DEADLINE_FAR_FUTURE` (7 d) | `now + DEADLINE_FAR_FUTURE` | + +`.timeout(50ms)` with no batch deadline therefore yields `duration = 50ms` and +`deadline = now + 50ms`, **not** 30 s. The single absolute `deadline` is what Fastly's +between-chunk checks (§3.3.4) and the streamed-body wrappers in §4.1/§4.2/§4.4 use, so +per-request `timeout` is honoured across the entire exchange — including the streamed +body phase — whether or not an batch deadline was provided. + +"No deadline configured" therefore differs from "deadline configured and expired" — +the former is bounded by the synthetic 30 s ceiling; the latter is a hard fail at +dispatch with `gateway_timeout`. + +The same rule governs the dispatch+headers phase in `Streamed` mode. The body phase is +**also** governed by `dispatch_budget(req).deadline` (see §3.3.3) — the spec +deliberately does +not split the deadline into "before headers" and "after headers" pieces. + +#### 3.3.3 What the deadline covers + +The deadline on `OutboundRequest` covers the **entire exchange end-to-end** in both +modes. The mechanism differs: + +- **`Buffered` (default):** the adapter buffers the body *inside* the deadline-bounded + region, so a slow body counts against the budget. `Ok(resp)` from `send`/`send_all` + means the full exchange completed within the deadline. +- **`Streamed`:** `Ok(resp)` is returned once headers arrive — earliest possible + delivery — but the **body stream returned in `resp` is adapter-wrapped to honour + `dispatch_budget(req).deadline`.** That deadline is the *effective* one computed by + the budget rule (§3.3.2), which is non-`None` even for timeout-only and no-deadline + requests — adapters wrap the body stream in every case, not only when + `req.deadline.is_some()`. Axum/CF/Spin wrap with a platform-timer-aware stream + (real preemption per chunk); Fastly is bounded-cooperative per §3.3.4. So a stalled + upstream cannot exceed the effective budget silently in either mode. + +What this means in practice: + +- `OutboundResponse::into_bytes_bounded(max)` on a streamed body already honours the + effective-budget deadline through the wrapped stream — body chunks past the + deadline yield `gateway_timeout`. +- `OutboundResponse::into_bytes_bounded_until(max, deadline)` is for tightening the + bound below the effective-budget deadline (e.g. an inner budget for body-only) — + not for re-applying the same deadline, which is automatic. +- If the caller dropped the `Deadline` value but still wants the same effective + ceiling, passing `Deadline::after(remaining_budget_from_some_source)` works; or + just call `into_bytes_bounded` and trust the wrapped stream. + +This is one contract for everyone: handlers never have to remember "Streamed cuts the +deadline at headers." Adapter notes (§4.1–§4.4) implement this end-to-end. + +#### 3.3.4 Per-adapter enforcement (`Buffered` mode) + +| Adapter | Mechanism | Strength | +| --- | --- | --- | +| Axum | `reqwest::RequestBuilder::timeout(effective)` — reqwest applies it through response-body read | Real, whole-operation | +| Cloudflare | race the entire `send_one` future (fetch **and** body drain) against `worker::Delay(effective)`; drop on expiry | Real, whole-operation | +| Spin | race the entire `send_one` future (send **and** body collect) against a wasi monotonic-clock timer; drop on expiry | Real, whole-operation | +| Fastly | host phase timers split per §4.3 (`connect = budget/4`, `first_byte = 3*budget/4`, `between_bytes = budget`); during body drain, `budget.deadline.is_expired()` is checked **after every blocking body read returns, including the EOF read** (the synthetic 30 s deadline applies when no caller deadline was set); the host between-bytes timeout bounds each gap | Real for connect+headers with a documented phase split (see §4.3 — a connect that itself takes longer than `budget/4` fails even if the rest of the budget would have sufficed); **bounded-cooperative** for the body phase | + +**Fastly precision, stated honestly.** Fastly has no guest wall-clock primitive to +preempt a chunk read in progress. At dispatch the adapter computes `let budget = +dispatch_budget(req, now)?` (§3.3.2, `now` snapshotted inline for single `send`, +passed in as `batch_now` for `send_all` — round 23. `DEFAULT_NO_DEADLINE_BUDGET = 30 s` +and the synthetic absolute deadline both apply when no deadline is set, identical to +every other adapter) and derives the host timeouts via the named helper: + +```rust +fn fastly_timeout_ms(budget: &DispatchBudget) -> u64 { + // True ceil-to-ms — never floor a sub-ms remainder away (round 20). + // The DEADLINE_FAR_FUTURE clamp keeps this below Fastly's 2^32 ms ceiling + // (round 24); we still assert it explicitly because a bug elsewhere + // shouldn't crash the host. + let ms = ((budget.duration.as_nanos() + 999_999) / 1_000_000).max(1); + debug_assert!(ms < (u32::MAX as u128), "fastly_timeout_ms exceeds u32::MAX ms"); + ms.min(u32::MAX as u128 - 1) as u64 +} + +// `dispatch_budget` always takes an explicit `now` (round 23). Single `send` +// snapshots inline; `send_all` snapshots once into `batch_now` and reuses it +// across slots so the dynamic-backend identity stays consistent for a shared +// caller Deadline. +let now = web_time::Instant::now(); // single `send`; `send_all` passes batch_now +let budget = dispatch_budget(req, now)?; + +// Fastly 0.12.1 exposes the timeout setters on BackendBuilder, NOT on Request — see +// https://docs.rs/fastly/0.12.1/fastly/backend/struct.BackendBuilder.html. +// IMPORTANT: connect_timeout and first_byte_timeout are *separate* phase timers +// per Fastly's docs — connect bounds DNS+TCP+TLS setup; first_byte bounds the gap +// from "request sent" until headers are received. Setting both to the same `t` +// would make the dispatch+headers worst case ~2*t, breaking the absolute-deadline +// bound. We therefore SPLIT the budget across the two phases (and the third, +// between-bytes, which only applies once chunks are flowing during body drain), +// keeping the sum exactly equal to total_ms: +// total_ms = ceil-to-ms(budget.duration) +// connect_ms = total_ms / 4 [floor; most connects take <100ms] +// first_byte_ms = total_ms - connect_ms [remainder; sum invariant] +// between_ms = total_ms [body-phase ceiling unchanged] +// Sub-4 ms degenerate case: both = total_ms (sum = 2*total_ms, documented). +// SSL configuration also lives on BackendBuilder: `use_ssl` defaults to false, so +// HTTPS targets MUST opt in explicitly with .enable_ssl() and configure SNI + +// certificate verification (per the existing pattern at +// crates/edgezero-adapter-fastly/src/proxy.rs:120). HTTP targets opt out via +// .disable_ssl(). +// +// Four canonicalized values come from the OutboundRequest accessors (§3.1.4 — +// adapters MUST consume these, never re-derive from `req.uri()`): +// - `req.backend_target()` — connection target `"host:port"` with the +// resolved port; passed as the +// BackendBuilder's `target` arg. +// (current adapter precedent: +// `host_with_port` at +// crates/edgezero-adapter-fastly/src/proxy.rs:108) +// - `req.host_authority()` — authority for `.override_host(..)` +// (carries the explicit port only when +// non-default; preserves §3.1.3 Host +// semantics). +// - `req.sni_hostname()` — `Option<&str>`. `Some(host)` for DNS-name HTTPS +// targets; `None` for IP-literal HTTPS (RFC 6066 §3 +// forbids SNI for IP literals). When `None`, the +// adapter omits `.sni_hostname(..)` entirely; it +// does NOT fall back to `req.uri().host()`. +// - `req.cert_host()` — `Option<&str>`. `Some(host)` for any HTTPS target +// (DNS name OR IP literal — port-stripped, +// bracket-stripped); `None` for non-HTTPS schemes. +// Passed to `.check_certificate(..)` verbatim; the +// adapter does NOT bracket-trim, parse, or +// post-process. +// Phase split. The documented semantics: connect gets a *floor quarter* of the +// already-ceiled total; first_byte gets the remainder; between_bytes gets the full +// budget. Invariant we want: connect_ms + first_byte_ms == total_ms exactly, so +// the worst-case dispatch+headers wall-clock is bounded by `budget.duration` +// (modulo ms rounding). Using `total_ms / 4` (floor) keeps the sum exact; the +// earlier "ceil-to-ms of budget * 1/4" framing was a misnomer — that would have +// made the sum exceed total_ms by up to 1 ms for some inputs. For tiny budgets +// where the 1/4 share would round to 0, we degenerate to "both = total_ms" — +// the absolute-deadline bound becomes 2*total_ms but at sub-4 ms scale this is +// negligible (and the ceil-to-ms rounding already dominates). +let total_ms = fastly_timeout_ms(&budget); // ceil-to-ms of budget.duration +let (connect_ms, first_byte_ms) = if total_ms < 4 { + (total_ms, total_ms) // sum = 2*total_ms; documented +} else { + let connect = total_ms / 4; // floor — keeps sum exact + let first_byte = total_ms - connect; // sum = total_ms exactly + (connect, first_byte) +}; +let between_ms = total_ms; +let mut builder = Backend::builder(&backend_name, &req.backend_target()) + .connect_timeout(Duration::from_millis(connect_ms)) + .first_byte_timeout(Duration::from_millis(first_byte_ms)) + .between_bytes_timeout(Duration::from_millis(between_ms)) + .override_host(req.host_authority()); +// TLS handling — the §3.1.4 accessors carry the canonicalized split. We do NOT +// inspect `req.uri()` directly: `cert_host()` returns `Some` iff the scheme is +// HTTPS (the adapter-local "is TLS?" question), and `sni_hostname()` carries +// the DNS-vs-IP-literal distinction (`None` for IP literals per RFC 6066 §3). +builder = match req.cert_host() { + Some(cert) => { + // HTTPS: always set .check_certificate(..). Pass req.cert_host() + // through unmodified — bracket-stripping for IPv6 is already done in + // the accessor; we never call .trim_start_matches('['). + let mut b = builder.enable_ssl().check_certificate(cert); + // SNI: only when the accessor returns Some (DNS-name host). + // For IP literals (`None`), .sni_hostname() is omitted entirely. + if let Some(sni) = req.sni_hostname() { + b = b.sni_hostname(sni); + } + b + } + None => builder.disable_ssl(), // HTTP +}; +let backend = builder.finish()?; +// Fastly's Request public API has no `with_backend`. The backend is passed as +// the argument to `send` / `send_async` / `send_async_streaming` at send time +// (each accepts `impl ToBackend`). `Backend` implements `ToBackend`. +// Buffered request body (send_all only — preflight rejected streams): +let pending = fastly_req.send_async(&backend)?; +// Streamed request body (single `send` only): +// let (streaming_body, pending) = fastly_req.send_async_streaming(&backend)?; +``` + +The dynamic-backend identity tuple (§4.3) is `scheme + ":" + host + ":" + +resolved_port + ":" + tls_mode + ":" + budget_ms`, where `tls_mode` is derived from +`req.uri().scheme_str()` and `budget_ms = ceil-to-ms(budget.duration)` — the same +`total_ms` that drives the `connect_ms / first_byte_ms / between_ms` deterministic +phase split above. The cached `Backend` and a freshly-requested one therefore always +carry identical timeouts AND identical SSL configuration because both are +deterministic functions of the same tuple. Existing in-tree precedent for +the SSL setters lives at `crates/edgezero-adapter-fastly/src/proxy.rs:120`; the +migration generalises that pattern to every dynamic backend. The budget is set once +before `send_async` and not mutated afterwards — the Fastly SDK does not expose +dynamic per-chunk timeout updates. During body drain the adapter checks +`budget.deadline.is_expired()` **after every blocking body read returns, including +the EOF read** (per the §3.3.4 rule — the earlier "between chunks" wording was +incomplete because a final EOF read can itself cross the deadline). Because +`dispatch_budget` always returns a concrete `Deadline` (synthetic if the request +had none), this cooperative check works uniformly whether or not the caller +supplied a deadline. +`connect-timeout` and `first-byte-timeout` together bound the dispatch+headers phase +at `budget.duration` (their sum, by the §4.3 split) **when `total_ms ≥ 4`**; for +`total_ms < 4` the code degenerates to `connect = first_byte = total_ms` and the +sum is `2 * total_ms`. The absolute-deadline guarantee in the sub-4 ms branch is +therefore "≤ `total_ms + BATCH_DISPATCH_SLACK_MAX + ms_rounding` past deadline" +(strict upper bound: `BATCH_DISPATCH_SLACK_MAX + total_ms + ms_rounding` +which is `25 + (≤ 3) + (≤ 1) < 29` ms), not the common-case "≤ 26 ms" — see +the two explicit +branches in §4.3 "Net guarantee." Sub-4 ms outbound budgets are degenerate inputs +where ms-rounding already dominates, not a normal operating point. The documented trade-off (§4.3) is that a request +spending more than `budget/4` on connect-phase work (DNS+TCP+TLS) fails at the +connect timer even if the remaining budget would have sufficed for headers; that +is captured by the separate `outbound-flexible-phase-budget` capability (§3.5.1). +During body drain (post-`wait()`), the adapter checks `budget.deadline.is_expired()` +**after every blocking body read returns, including the EOF read** (not "between +chunks" — the EOF read can itself block past the deadline and would otherwise +slip through with `Ok(resp)`). On the first expired check the slot is aborted +with `gateway_timeout`; each individual chunk-gap (including the gap before EOF) +is bounded by the host `between-bytes-timeout`. So the Buffered `Ok(resp)` +contract — "headers AND body completed within the deadline" — holds end-to-end: +either every read (including EOF) observed `!is_expired()`, or the slot returned +`gateway_timeout`. + +**Slot-level vs. wall-clock-observed completion.** The bound above is on +**host-side** enforcement per slot: the Fastly host stops each request when its own +configured timeouts elapse. The host runs all dispatched requests in parallel, so +fast-budget slots complete (success or host-timeout) at host-time independent of how +long the guest blocks on earlier slots' `wait()`. What the guest **observes**, though, +is gated by harvest order — a slot with a 50 ms effective budget sitting behind a +3 s `wait()` on slot 0 has already completed at the host (either successfully or as a +host-timeout error) at t ≈ 50 ms, but the guest does not see the result until slot 0's +`wait()` returns. So: + +- **Per-slot result correctness (headers phase):** each slot's connect / first-byte / + between-bytes timeouts are configured from its own `budget.duration`, and the host + enforces them independently. A 50 ms slot that fails to receive headers in time + errors at 50 ms host-side, not 3 s — the headers phase is genuinely per-slot. + *This holds only for the headers phase.* Buffered body drain in `send_all` is + bounded by the same host timeouts on a per-chunk-gap basis but is **scheduled + sequentially in harvest order** — see the next bullet for the wall-clock + consequence. +- **Per-slot wall-clock-observed delivery:** bounded by + `max_over_remaining_slots(effective_at_dispatch)` in the worst case (harvest-order + delay). When all slots in one fan-out batch share the same effective + deadline the bounds coincide; in heterogeneous-budget scenarios + apps should be aware that observed completion can be later than per-slot + completion. The opportunistic `poll()` of later slots after each `wait()` + (Phase 2 above) reduces this gap in practice but does not eliminate it. +- **Buffered body drain runs in harvest order, not concurrently.** `harvest()` does + `pending.wait()` *and then* drains the response body (Buffered mode) *and then* + moves to the next slot. On Axum/CF/Spin `join_all` polls all `send_one` futures + concurrently, so two slow body drains complete in parallel; on Fastly they are + sequential. Wall-clock for the entire `send_all` is therefore + `max(header_arrivals) + Σ buffered_body_drain_times` on Fastly versus + `max(header_arrivals + buffered_body_drain_times)` elsewhere. **A slot can therefore + return `gateway_timeout` even though its host-side headers + body would have + completed within `budget.deadline` in isolation** — its body-drain phase started + late because an earlier slot's drain monopolised harvest, and the inter-chunk + `is_expired()` check fires once `budget.deadline` is crossed. The + "per-slot result correctness" bullet above applies only to the *headers* phase; + for the body phase, results genuinely depend on harvest order. The `send_all` + contract on Fastly therefore *admits* harvest-order-induced 504s in Buffered mode, + and the §5.4 test row asserts this explicitly. Concrete contract: + - For typical small JSON bodies (fan-out batches, the external batch protocol, sub-100 KiB responses) the + drain times are on the order of a few hostcalls (≤ low single-digit ms) and the + summed term is well within any realistic fan-out batch deadline. + - For large body responses, Fastly `send_all` is **simply suboptimal** compared + to the other three adapters and there is no current EdgeZero API that recovers + parallel large-body fan-out on Fastly. `Streamed` mode defers each slot's drain + to the consumer, but the consumer has no concurrent body-drain primitive + either — Fastly's body reads are synchronous host calls with no guest reactor + (§3.2 / §3.3.5), so iterating `Stream::next` on `out[0].body()` and + `out[1].body()` still serializes at the guest. Apps that fan out to large-body + upstreams on Fastly should either (a) target a different adapter for that + workload, (b) issue requests in a topology that doesn't require parallel + large-body drains, or (c) wait for the interleaved-drain follow-up in §8 risk 8. + typical small-body fan-outs are unaffected (response bodies under a few KiB). + +The worst-case post-deadline overshoot per slot **once that slot is actively draining** +is therefore **one between-bytes-timeout interval, which is ≤ `effective_at_dispatch`**. +Note: that bound is on the host timeout set at dispatch and does *not* shrink while a +slot waits behind earlier harvest work. **Total wall-clock observed by the caller** +is *not* bounded by one between-bytes-timeout — it also includes the harvest delay +described above: the sum of preceding slots' drain times before this slot's drain +phase begins. Concretely, in a Buffered-mode `send_all` of N homogeneous-budget slots +on Fastly with sequential body drains, slot `k`'s observed completion can be as late +as `Σᵢ<ₖ drain_timeᵢ + (effective_at_dispatch for slot k)` — and once slot `k`'s drain +*begins*, the inter-chunk `is_expired()` check fires within one between-bytes-timeout +of `budget.deadline` for that slot. + +Apps reasoning about precise wall-clock should treat `effective_at_dispatch` as the +maximum per-slot *active-drain* overshoot — i.e., the original batch budget is the +bound on each slot's drain phase **in isolation**, not the bound on its observed +completion time across the whole `send_all`. The `send-all-slot-isolation` capability +(§3.5.1 footnote 4) is what scopes the cross-slot half: declaring it required gives +the hard build failure on Fastly, signalling that an app needs isolation guarantees +the harvest order does not provide. This is what `BoundedCooperative` means at the +single-slot level (§3.5.1); the cross-slot harvest-order weakening is the separate +`BestEffort` `send-all-slot-isolation` story. A peer dribbling bytes still cannot +blow past the batch deadline indefinitely *on its own slot*, but a fan-out batch observing total +wall-clock should also account for harvest serialization. + +#### 3.3.5 No general-purpose timeout combinator (deliberate) + +An earlier draft put a `timeout(deadline, future)` combinator for *arbitrary* futures in +`edgezero-core`. That is **removed**: a real timer future needs a platform runtime +(`tokio` / `worker` / `spin-sdk`), which core may not depend on (§1.3). Core therefore +ships only the `Deadline` value type; outbound-deadline enforcement lives entirely inside +adapters (§3.3.4). A general arbitrary-future timeout would require an adapter-injected +`Timer` trait and a dedicated capability; it is **out of scope** here because the fan-out pattern's +timing needs are fully met by the outbound path. Noted as possible future work. + +### 3.4 Bounded buffering & error mapping + +#### 3.4.1 Outbound responses + +In `Buffered` mode, `max_response_bytes` (default `DEFAULT_MAX_RESPONSE_BYTES = 1 MiB`) +caps the body. The cap is measured in **decompressed, app-visible bytes**, not +compressed wire bytes. Every adapter that transparently decompresses gzip/br +**must enforce the cap incrementally during decompression** and abort as soon as the +decompressed output exceeds the cap — this closes the decompression-bomb gap so a +small compressed body cannot expand past the limit. Over-cap → +`Err(EdgeError::bad_gateway("response body exceeded N bytes"))`. + +**Pre-append check is mandatory.** Both inbound (`RequestContext::body_bytes`) and +outbound (`OutboundResponse::into_bytes_bounded` / `_until`) bounded drains MUST check +`collected.len().checked_add(chunk.len()).map_or(true, |n| n > max)` (equivalently +`chunk.len() > max.saturating_sub(collected.len())`) **before** extending the buffer +— never extend +then check. A single oversized chunk on a small cap would otherwise allocate past the +limit before erroring. The existing `Body::into_bytes_bounded` helper at +`crates/edgezero-core/src/body.rs:84` extends then checks; the migration updates it +to pre-append checked length accounting. Both helpers therefore guarantee that the +**persistent collected buffer** is bounded by `max` — pre-append checking aborts before +ever extending past `max`. + +Worst-case **transient** resident memory during a drain is `max + sizeof(current_chunk)`: +the in-flight chunk briefly co-exists with the collected buffer during the check, then +is dropped (over-cap) or appended (under-cap). **`sizeof(current_chunk)` is +source-controlled, not bounded by this spec.** The `8–64 KiB` figure typical sources +yield (`tokio::io` 8 KiB, `hyper` 16 KiB, WASI body reads 64 KiB) is descriptive of the +adapters' incoming stream chunking, not a contract. Three concrete consequences readers +must internalise: + +- **An upstream that yields one large `Bytes` exceeds the typical figure.** A peer + returning a 4 MiB response in a single chunk produces a single 4 MiB in-flight + `Bytes` while the over-cap check runs; if the cap is 1 MiB, the persistent buffer + never grows past 1 MiB but resident memory transiently includes the full 4 MiB + chunk. The check still aborts before any append, but the host did receive 4 MiB. +- **The spec does not rechunk.** EdgeZero's `Body::Stream` forwards chunks verbatim; + there is no `chunk_size_cap` configuration knob on `OutboundRequest`/`OutboundResponse`. + Adding one would require either every adapter to rechunk on the inbound side (a + non-trivial perf cost) or a core wrapper around every adapter-emitted stream (which + defeats lazy passthrough on CF/Fastly/Spin). **Deferred** — tracked in §8 risk 11. +- **The batch model in §3.4.4 inherits the same property.** `Σⱼ sizeof(current_chunkⱼ)` + for actively-draining slots is bounded by what each source yields, not by EdgeZero. + Apps that need a hard per-batch ceiling against adversarial chunking must either + size the request fan-out (N) conservatively against the **upstream's** advertised + maximum chunk size, or wait for the §8 risk 11 follow-up. + +This is a per-call drain bound, **not** a whole-process memory ceiling; the batch-level +bound is `Σ persistent buffers + Σ in-flight chunks` per §3.4.4, with the same +source-controlled caveat on the in-flight term. + +Decompression-cap responsibility per adapter: + +- **Cloudflare, Fastly, Spin** — already decompress gzip/br explicitly today; the cap + obligation applies in-line in their existing decode paths. +- **Axum** — the workspace `reqwest` dependency is currently + `default-features = false` and does not enable gzip/brotli decoding. This migration + enables the `gzip` and `brotli` features on `reqwest` so behaviour matches the other + three adapters; reqwest then performs decoding and the byte cap is enforced + incrementally while the adapter drains the response. The Cargo.toml change is part of + the file-by-file summary (§7). + +Whenever an adapter decompresses, the `OutboundResponse.headers` it returns MUST have +both `content-encoding` and `content-length` removed — the original values describe +compressed wire bytes and no longer match the app-visible body. This applies in both +`Buffered` and `Streamed` modes: callers must never see decoded bytes alongside stale +compressed metadata. Existing Cloudflare and Fastly proxy code already does this and +the contract codifies it. + +**Streaming-decompressor design (Streamed mode).** Lazy +`lazy-streamed-response-passthrough` on **CF / Spin** coexists with the cap +obligation because each adapter wraps the raw compressed byte stream with a +**streaming decoder** that emits decompressed chunks as they arrive, never buffering +the full body. (Fastly is `BestEffort` for lazy passthrough per capability footnote +6 — its response converter performs buffered passthrough through +`FASTLY_RESPONSE_STREAM_BUFFER_BYTES`, so its streaming-decompressor wrapper still +runs but the converter buffers downstream of it. Axum is also `BestEffort` for an +unrelated `Send`-bounds reason — see footnote 3.) The decoder's *only* +responsibilities are decoding bytes, stripping the two compressed-only headers, and +surfacing decoder errors — it deliberately does **not** enforce a byte cap, because +`ResponseMode::Streamed` carries no `max_bytes` (§3.1.3) and the cap lives with the +consumer: + +1. Pull a raw compressed chunk from the platform stream. +2. Feed it into the decoder; emit whatever decompressed output is currently available + (zero, one, or many output chunks per input chunk). +3. Yield each decompressed chunk verbatim. **No byte counting in the wrapper.** +4. Stop on raw EOF, decoder error (→ `Err(EdgeError::bad_gateway(..))` chunk). +5. `content-encoding` and `content-length` are stripped from + `OutboundResponse.headers` at construction time — the wrapper's output bytes are + the new ground truth. + +Cap ownership is then unambiguous: + +- **Buffered mode:** the adapter drains the decompressed stream inside the + buffered-drain helper with `max_response_bytes` (per-append-checked, §3.4.1). + Cap fires inside the adapter. +- **Streamed mode + `into_bytes_bounded(max)` / `into_bytes_bounded_until(max, + deadline)`:** the helper's own pre-append check enforces `max` against the + decompressed chunks it pulls from the wrapped stream. Cap fires in the helper. +- **Streamed mode + `into_response()` passthrough (proxy-forward):** there is + **deliberately no EdgeZero cap** — the platform's downstream response wire is + the budget, and inserting an EdgeZero cap on a transparent proxy stream would + silently truncate a perfectly valid streamed proxy response. Apps that want to + cap proxied bodies do `into_bytes_bounded` first, then re-emit. + +**Implementation hooks (don't rewrite what already exists).** The async stream +decoders for gzip and brotli **already live in `edgezero-core` at +`compression.rs:15` and `compression.rs:41`** — they are core helpers, not +adapter-local code. (Spin's `decompress.rs` is a separate **buffered slice** +decoder — not the async helper.) The existing helpers' chunk error type is +**`io::Error`** (not `anyhow::Error`); the migration **evolves them in place** to +yield `EdgeError` chunks per the round-15 `Body::Stream` change in §7 — wrap each +`io::Error` with `EdgeError::bad_gateway(..)` (a decode-side IO failure is a 502 +outcome, distinct from EdgeError-typed `gateway_timeout` chunks the wrapper might +inject). No lift or relocation needed. CF/Fastly/Spin response converters call +into these existing core helpers; Axum keeps its buffered path (a non-streaming +decoder is fine there, since the response converter buffers anyway — §4.1). + +In `Streamed` mode no cap is pre-enforced; the caller applies one via +`OutboundResponse::into_bytes_bounded(max)`. That method does **not** delegate to +`Body::into_bytes_bounded` directly — `Body::into_bytes_bounded` maps over-limit to +`bad_request` (400), correct for the inbound body case but wrong for an over-large +upstream response. `OutboundResponse::into_bytes_bounded` performs its own bounded +drain and maps to `bad_gateway` (502). On adapters that decompress, the cap is enforced +against decompressed output here too. + +#### 3.4.2 Inbound request bodies + +Wrap the existing `Body::into_bytes_bounded` with context-level helpers: + +```rust +// crates/edgezero-core/src/context.rs +impl RequestContext { + /// Read the inbound request body into `Bytes`, bounded by `max`. + /// Over-limit yields `Err(EdgeError::bad_request(..))` (400). + /// + /// **Takes `&self`** — `RequestContext` carries an internal body cache + /// (an `unsync::OnceCell` style cell; single-threaded per + /// request, no `tokio` dep). This is deliberate so that existing + /// `FromRequest` extractors that take `&RequestContext` (e.g. `Json`, + /// `ValidatedJson`) can call it without a trait-signature breaking + /// change. The first call drains the underlying `Body::Stream` into + /// the cell; later calls return a cheap clone. The cached size is + /// re-validated against `max` on every call, so a later, stricter cap + /// is still enforced after buffering. The network body is read at most + /// once. + pub async fn body_bytes(&self, max: usize) -> Result; + + /// Call `body_bytes(max)` then deserialize as JSON. Malformed inbound + /// JSON yields `Err(EdgeError::bad_request(..))` (a client bug → 400, + /// in contrast to outbound `OutboundResponse::json` which maps to 502). + /// Same `&self` cache semantics as `body_bytes`. + pub async fn json_within(&self, max: usize) + -> Result; + + /// Call `body_bytes(max)` then deserialize as `application/x-www-form-urlencoded`. + /// Default cap from extractors: `DEFAULT_INBOUND_FORM_BYTES = 1 MiB` + /// (forms are typically small). Malformed form data → `bad_request` (400). + /// Same `&self` cache semantics as `body_bytes`. + pub async fn form_within(&self, max: usize) + -> Result; +} +``` + +#### 3.4.3 New `EdgeError` variants & mapping + +`EdgeError` is `#[non_exhaustive]`, so this is additive. + +```rust +// crates/edgezero-core/src/error.rs — add two variants + constructors +EdgeError::BadGateway { message: String } // -> 502 +EdgeError::GatewayTimeout { message: String } // -> 504 + +pub fn bad_gateway(message: impl Into) -> Self; +pub fn gateway_timeout(message: impl Into) -> Self; +``` + +`EdgeError::status()` gains `BadGateway => 502`, `GatewayTimeout => 504`. + +| Condition | `EdgeError` | HTTP status | +| --- | --- | --- | +| Inbound request body over limit / not valid JSON | `bad_request` | 400 | +| Invalid outbound URI (relative / no authority / bad scheme) | `bad_request` | 400 | +| Outbound transport failure (DNS / TLS / connect) | `bad_gateway` | 502 | +| Outbound response over `max_response_bytes` (decompressed) | `bad_gateway` | 502 | +| Outbound response body not valid JSON / `json::` called on a streamed body | `bad_gateway` | 502 | +| Outbound per-request timeout or batch deadline exceeded | `gateway_timeout` | 504 | +| Outbound completed with a non-2xx status | **not an error** — `Ok(OutboundResponse)` | app decides | + +The non-2xx rule is load-bearing: a target returning 204/400/500 is a normal fan-out batch +outcome, not a transport error. + +#### 3.4.4 Batch memory model (explicit) + +`send_all` does not impose a global allocation ceiling. The bound comes in two parts — +a **persistent collected buffer** term that holds the request payloads and the +buffered response payloads, plus a **transient in-flight chunk** term that +briefly co-exists with the collected buffer per actively-draining slot (per +§3.4.1's pre-append checked accounting, the in-flight chunk is held during the +overflow check before being appended or dropped): + +``` +persistent collected buffer = Σᵢ request_bodyᵢ.len() + + Σᵢ max_response_bytesᵢ (send_all is buffered-only) + +transient in-flight chunks = Σⱼ sizeof(current_chunkⱼ) + // j ranges over slots + // currently inside a drain + // step; typically 8-64 KiB + // per active slot + +worst-case resident memory = persistent + transient + +// Equivalently, when all slots share the same response cap, the persistent term is: +// Σᵢ request_bodyᵢ.len() + N × max_response_bytes +// — but the precise sum is over the per-slot caps, not a single N × max. +// Heterogeneous caps (mix of `.max_response_bytes(small)` and unset slots) bound +// the persistent term by Σᵢ instead of N × max(capᵢ). +``` + +`send_all` rejects streamed request bodies and streamed responses in preflight +(§3.1.1), so a Streamed-mode batch memory model does not exist. Single `send` +with `Streamed` is the path for lazy bodies, where memory is bounded by the +streaming chunk buffer plus whatever the consumer chooses to buffer via +`into_bytes_bounded`. + +EdgeZero's contract — **persistent** (post-append, retained) vs **transient** +(in-flight, dropped after the cap check): + +- **Per-response (Buffered).** *Persistent* memory — the collected buffer — is bounded + by `max_response_bytes`. *Transient* worst-case resident memory during a drain is + `max_response_bytes + sizeof(current_chunk)`, where `sizeof(current_chunk)` is + source-controlled (§3.4.1). The post-check buffer never exceeds `max_response_bytes`. +- **Per-inbound-body.** *Persistent* memory — the cached `Bytes` after a successful + drain — is bounded by the `max` passed to `body_bytes(max)` / `json_within(max)` / + `form_within(max)`. *Transient* worst-case during the drain is the same shape: + `max + sizeof(current_chunk)`, with the in-flight chunk source-controlled + (§3.4.1 / §3.4.5). +- **Batch (N)** memory is the app's responsibility: the app must bound the number of + requests passed to `send_all`. Both terms add up — *persistent* is + `Σᵢ request_bodyᵢ.len() + Σᵢ max_response_bytesᵢ` (`request_bodyᵢ` and + `max_response_bytesᵢ` denote slot `i`'s buffered request body length and its + per-request response cap respectively); *transient* adds + `Σⱼ sizeof(current_chunkⱼ)` over actively-draining slots, source-controlled. + For typical fan-out workloads this is intrinsic — `N` is the fixed, configured target count and + target responses are small JSON. The spec deliberately does **not** add a + `max_concurrency` knob: on Fastly all requests must be in-flight at once for + fan-out to work, so throttling concurrency would defeat the feature. This + requirement is documented in the `send_all` rustdoc and in `docs/`. See §8 risk 11 + for the deferred per-batch transient-chunk cap. + +#### 3.4.5 Inbound body migration + +The body-bound guarantee in §3.4.4 only holds if the adapter does not pre-buffer the +inbound request body before core can apply a cap. Today every adapter pre-buffers +(`crates/edgezero-adapter-axum/src/request.rs:24` buffers JSON with `usize::MAX`; +`crates/edgezero-adapter-cloudflare/src/request.rs:60` calls `req.bytes()`; +the Fastly and Spin paths fully materialize the body too). This migration changes that: + +- **Adapter request conversion** stops pre-buffering. Inbound `Request` is exposed to + core with a `Body::Stream` (or `Body::Once` only when the platform genuinely owns + the bytes already — e.g. an in-process Axum body that arrived buffered). Each + adapter's `request.rs` is updated to wrap the platform body as a stream rather than + drain it eagerly. +- **`RequestContext` is restructured** — today it holds a plain `Request`, which cannot + be safely mutated through `&self`. The new shape: + + ```rust + pub struct RequestContext { + path_params: PathParams, + parts: http::request::Parts, // method, uri, version, headers, extensions + body: BodyCell, // interior-mutable + } + + struct BodyCell(/* unsync */ RefCell); + + enum BodyState { + Initial(Body), // never read; the platform body is still owned + Draining, // body taken out, drain in progress + Cached(Bytes), // body drained successfully + Poisoned(StoredError), // drain failed (over-cap, stream error, drop) + Taken, // body consumed via take_body / into_request + } + + /// Non-consuming snapshot of cell state for app inspection. + pub enum BodyKind { + Initial, + Draining, + Cached { len: usize }, + Poisoned, + Taken, + } + ``` + + `RefCell` (unsync) is fine because a `RequestContext` is owned per-request and + EdgeZero's async traits already use `?Send`. No `tokio` dependency in core. + + **Async drain protocol.** A naive "borrow_mut across .await" implementation would + panic on reentrant access or hold the borrow indefinitely if the future is dropped + mid-drain. The implementation is therefore: + + 1. Briefly borrow the cell, `mem::replace` the state with `Draining` while taking + ownership of the `Body`, drop the borrow. (No borrow held across any `.await`.) + 2. Drive the async drain on the owned `Body`. A drop guard wraps the drain such + that, on success, the cell is set to `Cached(bytes)`; on stream error or cap + overflow, the cell is set to `Poisoned(stored_err)`; on **future-cancellation** + (the drain future is dropped), the guard's `Drop` sets the cell to + `Poisoned(StoredError::cancelled())`. The network body is partially consumed and + unrecoverable in every failure case — poison is sticky. + 3. While the cell is in `Draining`, any reentrant `body_bytes` / `json_within` call + observes that state and returns `Err(EdgeError::internal("body read already in + progress"))` rather than panicking; this would only occur in programmer-error + scenarios but must not crash the host. + + Tested in §5.4: drop-mid-drain → next call yields `cancelled` poison; + reentrant-during-drain → `internal` (no panic); successful drain → reentrant call + during drain is impossible because Phase 1 is non-async, so the test exercises the + paths a real async runtime can produce. + +- **Public methods become coherent with the cache.** Their post-cache behaviour is + explicit so middleware → handler → proxy-forward chains compose: + + | Method | Behaviour | + | --- | --- | + | `method()` / `uri()` / `headers()` / `extensions()` | from `parts` — unaffected by body state | + | `headers_mut()` / `extensions_mut()` | mutates `parts` — unaffected by body state | + | `parts() -> &http::request::Parts` / `parts_mut() -> &mut http::request::Parts` | direct access to the underlying `Parts` for middleware that needs the full snapshot; same body-state-irrelevance as the granular accessors above. These are the migration target for call sites currently doing `ctx.request()` / `ctx.request_mut()` (§6 sweep). | + | `body_kind() -> BodyKind` | a non-consuming snapshot of the cell state — variants enumerated above (`Initial \| Draining \| Cached { len } \| Poisoned \| Taken`). There is **no** `body() -> &Body` / `body() -> Body` accessor — a `&Body` reference cannot span the cell's interior mutability, and a value-returning getter would either consume the stream (single-shot) or require a tee. Callers either buffer via `body_bytes`/`json_within` or consume via `take_body`/`into_request`. | + | `take_body() -> Result` | consume the body out of the context: `Initial` → `Ok(Body::Stream(..))`, set state to `Taken`; `Cached(bytes)` → `Ok(Body::Once(bytes))`, set state to `Taken`; `Draining` → `Err(EdgeError::internal("body read in progress"))` (programmer error); `Poisoned(err)` → `Err(err.clone_as_edge_error())`; `Taken` → `Ok(Body::empty())`. After a successful `take_body`, the body cannot be re-read or buffered. | + | `body_bytes(max)` / `json_within(max)` / `form_within(max)` | from `Initial`: drains → `Cached`, returns clone (or → `Poisoned(err)` on drain failure, then returns that error). From `Cached`: re-validates `max` and returns a clone. From `Poisoned`: returns a fresh `EdgeError` reproduced from the stored error. From `Draining`: `Err(EdgeError::internal("body read in progress"))` — programmer error. From `Taken`: `Err(EdgeError::internal("body already consumed via take_body"))` — buffered helpers cannot resurrect a body that was handed out. | + | `into_request() -> Result` | reassembles a `Request` from `parts` + the cell's body via the same rules as `take_body`: `Cached` → `Ok(Body::Once(bytes))`, `Initial` → `Ok(Body::Stream(..))`, `Draining` → `Err(EdgeError::internal("body read in progress"))` (programmer error), `Poisoned(err)` → `Err(err.clone_as_edge_error())` — **not** `Body::empty()`, because a poisoned read silently turning into an empty proxy-forward would violate the "poison is sticky" rule below, `Taken` → `Ok(Body::empty())` (the caller consumed via `take_body`, the empty is intentional). This is what `OutboundRequest::from_request(ctx.into_request()?, uri)?` uses, so streaming proxy-forward still works **even after middleware has buffered the body** (the cached `Bytes` flow through), and a permissive proxy-forward cannot mask a stricter middleware's poisoned read. | + + The legacy `request()` / `request_mut()` accessors are removed (they leaked the + whole `Request` and made the body cell incoherent); call sites switch to + `parts()` / `parts_mut()` for headers/method/uri/extensions, `body_kind()` for + state inspection, `body_bytes(max)` / `json_within(max)` for buffered consumption, + `take_body()` for one-shot consumption, and `into_request()` for proxy-forward + reassembly. + +- **Poison semantics on failed body reads.** If `body_bytes` fails mid-drain — the cap + is exceeded, the stream errors, or a future cancellation interrupts the drain — the + network body has already been partially consumed and cannot satisfy any later call. + The body cell transitions to `Poisoned(stored_err)`, where `stored_err` is enough + metadata to reproduce a fresh `EdgeError` on every subsequent call (since `EdgeError` + is not `Clone`). All later `body_bytes`/`json_within` calls return that error; + `body_kind()` reports `Poisoned`; `take_body()` and `into_request()` both return + `Err(stored)` — the latter explicitly fallible so a poisoned read cannot silently + become an empty proxy-forward. The network body is **not** + retried. This is the most defensible contract: silently re-reading is impossible, and + silently succeeding with a larger-cap call would let a permissive extractor mask a + stricter middleware's enforcement. The poisoned error variant matches the first + failure (e.g. an over-cap drain returns `bad_request` on call N+1 too). + +- **Existing extractors.** All extractors that consume the inbound body are migrated to + the bounded helpers: + + | Extractor (today) | After migration | + | --- | --- | + | `Json` (uses `ctx.json()`, assumes buffered body) | delegates to `ctx.json_within(DEFAULT_INBOUND_JSON_BYTES)` — `DEFAULT_INBOUND_JSON_BYTES = 8 MiB` | + | `ValidatedJson` | as above + `validator` pass; sibling `ValidatedJsonWithin` for explicit caps | + | `Form` (uses `ctx.form()`, also rejects streams today — `crates/edgezero-core/src/extractor.rs:375`, `crates/edgezero-core/src/context.rs:31`) | delegates to a new `ctx.form_within(max)` helper, default `DEFAULT_INBOUND_FORM_BYTES = 1 MiB` (forms are typically small) | + | `ValidatedForm` | as above + `validator` pass; sibling `ValidatedFormWithin` for explicit caps | + + The legacy `RequestContext::json()` and `RequestContext::form()` are removed; both + required `Body::Once` and would break once adapters stop pre-buffering. + +- **Extractor trait.** No change required — `FromRequest::from_request(&RequestContext, + ..)` continues to take `&RequestContext`, which works because `body_bytes` is now + `&self`-callable through the cache. + +Net effect: per-inbound-body memory is bounded at the boundary of the bounded helper +that actually reads the body; failed reads are sticky so a permissive caller cannot +silently bypass a stricter one; streaming proxy-forward works whether or not middleware +already buffered the body. + +### 3.5 Capability declaration + +#### 3.5.1 Manifest section + +```toml +# edgezero.toml +[capabilities] +required = ["outbound-http", "outbound-deadlines"] +optional = ["config-store"] + +[capabilities.outbound] +hosts = ["*"] # optional plumbing; default ["*"] +``` + +```rust +// crates/edgezero-core/src/capability.rs (new module) + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum Capability { + OutboundHttp, // can issue outbound HTTP at all + OutboundDeadlines, // wall-clock budget on a *single* outbound + // exchange: connect + headers + buffered + // response body AND chunk-yield of a streamed + // response body (§3.3.3). For `send_all`, + // this covers both the headers phase and the + // **active body-drain phase** of each slot — + // a slot's active drain still honours the + // single-slot bound (≤ one between-bytes- + // timeout overshoot per gap on Fastly per + // §3.3.4). The **cross-slot harvest delay** + // (slot k waiting behind earlier slots' + // drains in Fastly Buffered mode) is *not* + // covered here — that is the separate + // `SendAllSlotIsolation` capability below, + // so each label means exactly one thing. + OutboundFlexiblePhaseBudget, // the entire request budget is one elastic + // pool — a slow connect followed by a fast + // headers + body that would together fit + // inside the total budget actually succeeds. + // Native on Axum/CF/Spin (single total + // timeout, no per-phase split); BestEffort on + // Fastly (rigid 1/4 connect + 3/4 first-byte + // split — §4.3 documented deviation). Apps + // with slow-connect-but-fast-rest workloads + // require this and get a hard fail on Fastly. + SendAllSlotIsolation, // in `send_all`, each slot's result reflects + // what it would have produced in isolation — + // sibling-slot timing cannot turn a slot that + // would have completed within its own + // `budget.deadline` into a 504. Native on + // Axum/CF/Spin; BestEffort on Fastly + // (harvest-order false 504s in Buffered mode, + // §3.3.4). + StreamedUploadDeadlines, // can preempt a stalled `stream.next().await` + // while feeding a streamed REQUEST body + // (Fastly = BestEffort) + LazyStreamedResponsePassthrough, // `into_response()` on a streamed body + // delivers chunks without first collecting + // the whole body (Axum = BestEffort, + // see §3.5.2 footnote 3) + ConfigStore, + KvStore, + SecretStore, +} + +impl Capability { + pub fn as_str(&self) -> &'static str; // kebab-case, for messages +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CapabilitySupport { + /// Fully supported with no documented caveats. + Native, + /// Real enforcement with a precisely documented, deterministic bound on any + /// deviation. Used for timing-related degradations (e.g. Fastly + /// outbound-deadlines body phase — overshoot ≤ one between-bytes-timeout + /// interval, §3.3.4). + BoundedCooperative, + /// Available but with a documented limitation that the matrix footnotes + /// describe. The limitation can be timing-related (unbounded cooperative + /// enforcement, e.g. Fastly source-stream-stall in + /// `streamed-upload-deadlines`) **or functional** (deterministic behaviour + /// differs from `Native`, e.g. Axum `lazy-streamed-response-passthrough` + /// buffers rather than streaming). `BestEffort` therefore means + /// "supported, with a real-world deviation you need to read the footnote + /// to understand" — not specifically "unbounded cooperative timing." + BestEffort, + /// Not available. + Unsupported, +} +``` + +The capability is named **`outbound-deadlines`**, not `timers`, and is defined precisely: +"the platform can enforce a wall-clock budget on an outbound HTTP request." It makes no +claim about timing arbitrary guest computation (which EdgeZero does not offer — §3.3.5), +so an app declaring it gets exactly what the name says on every adapter. + +```rust +// crates/edgezero-core/src/manifest.rs — new field on Manifest +#[derive(Debug, Default, Deserialize, Validate)] +pub struct ManifestCapabilities { + #[serde(default)] + pub required: Vec, + #[serde(default)] + pub optional: Vec, + #[serde(default)] + #[validate(nested)] + pub outbound: ManifestOutboundCapability, +} + +#[derive(Debug, Deserialize, Validate)] +pub struct ManifestOutboundCapability { + /// Outbound host plumbing. `["*"]` (the default) means "any host". + /// `length(min = 1)` enforces at least one entry; per-entry validation + /// is the `validate_outbound_hosts` custom validator below, which checks + /// every entry against §3.5.4's accepted forms (wildcard, scheme-prefixed, + /// host:port, bare host, wildcard subdomain). + #[serde(default = "default_outbound_hosts")] + #[validate(length(min = 1), custom(function = "validate_outbound_hosts"))] + pub hosts: Vec, +} + +fn default_outbound_hosts() -> Vec { vec!["*".to_owned()] } + +impl Default for ManifestOutboundCapability { + fn default() -> Self { Self { hosts: default_outbound_hosts() } } +} + +/// Per-entry validation for `[capabilities.outbound].hosts` (§3.5.4). This is +/// **host-authority-only plumbing**, not a URI field — the same rationale as +/// `OutboundRequest`'s userinfo rejection (§3.1.3 — credentials must not leak +/// through the manifest into `allowed_outbound_hosts`). +/// +/// Each entry MUST be one of: +/// - `"*"` (the wildcard). +/// - `scheme://host[:port]` where: +/// - `scheme ∈ {http, https}`, case-**insensitive** at the validator +/// (RFC 3986 §3.1) — `HTTPS`, `https`, `Https` all accepted. The +/// §3.5.4 Spin renderer then canonicalizes to lowercase before emitting +/// `spin.toml`, so the rendered manifest carries one canonical +/// spelling. Other schemes → rejected at the validator. +/// - `host` is a DNS label, IPv4 literal, IPv6 literal in brackets, or +/// `*` / `*.domain.tld` wildcard form. +/// - `port`, if present, is a decimal integer in `1..=65535`. +/// - **NO userinfo, NO path, NO query, NO fragment.** `https://user:pass@x`, +/// `https://x/p`, `https://x?q`, `https://x#f` all reject. +/// - `host[:port]` (no scheme) — same host/port rules as above. +/// +/// Empty entries, schemes other than `http`/`https`, ports outside +/// `1..=65535` or non-numeric, any userinfo / path / query / fragment, and +/// authorities the `Uri` parser rejects all yield a `ValidationError`. `"*"` +/// mixed with specific hosts is allowed; the wildcard renders both schemes +/// (§3.5.4) and specific hosts render alongside. +/// +/// §5.4 has a Tier 1 test row exercising every accept and reject case: +/// empty string, bad scheme (`ftp://x`), missing authority (`https://`), +/// userinfo (`https://u:p@x`), path (`https://x/p`), query (`https://x?q`), +/// fragment (`https://x#f`), out-of-range port (`https://x:0`, +/// `https://x:70000`), non-numeric port (`https://x:abc`), wildcard, +/// wildcard subdomain (`*.example.com`), bare host with port (`x:8443`), +/// IPv6 (`https://[::1]`), and mixed `"*"` + host. +fn validate_outbound_hosts(hosts: &Vec) -> Result<(), ValidationError>; + +// Manifest gains: #[serde(default)] #[validate(nested)] +// pub capabilities: ManifestCapabilities, +``` + +Every field is `#[serde(default)]`, so existing manifests parse unchanged. + +#### 3.5.2 Adapter capability metadata + +The registry `Adapter` trait gains one method (`capability`). The exact shape +depends on whether the codebase is **today's pre-#269 checkout** or **PR-#269**: + +```rust +// crates/edgezero-adapter/src/registry.rs — pre-#269 (today's checkout) +pub trait Adapter: Sync + Send { + fn execute(&self, action: AdapterAction, args: &[String]) -> Result<(), String>; + fn name(&self) -> &'static str; + fn capability(&self, capability: Capability) -> CapabilitySupport; // new +} +``` + +```rust +// crates/edgezero-adapter/src/registry.rs — PR-#269 target baseline +pub trait Adapter: Sync + Send { + fn execute(&self, action: AdapterAction, args: &[String]) -> Result<(), String>; + fn name(&self) -> &'static str; + fn capability(&self, capability: Capability) -> CapabilitySupport; // new + + // The following methods are PR-#269 surface (not in today's checkout): + fn provision(&self, args: &ProvisionArgs) -> Result<(), String>; + fn push_config_entries(&self, args: &ConfigPushArgs) -> Result<(), String>; + fn validate_config(&self, args: &ConfigValidateArgs) -> Result<(), String>; + // …other PR-#269 validation hooks elided here; see crates/edgezero-adapter/src/registry.rs + // in PR-#269 for the full set. They do **not** affect capability metadata — + // `capability(..)` is the only method `ensure_capabilities` consults. +} +``` + +This spec only adds `capability(..)`. Everything else in the trait is owned by +PR #269 (or by today's pre-#269 baseline, accordingly) and is shown above purely +so readers don't misread the `Adapter` reference in §3.5.3 as an exhaustive +declaration. The `Adapter::provision(..)` and config-validation hooks referenced +in §3.5.3 / §6 / §7 are the PR-#269 methods listed in the second block; they are +called from the **sibling pre-dispatch gates** on `run_provision` / +`run_config_push` / `run_config_validate`, not from `Adapter::execute`. On +today's checkout there is no `provision` / `config` surface at all — the +sibling-gate wording in §3.5.3 only applies once PR #269 lands. + +Capability matrix (all four adapters): + +| Capability | Axum | Cloudflare | Fastly | Spin | +| --- | --- | --- | --- | --- | +| `outbound-http` | Native | Native | Native | Native | +| `outbound-deadlines` | Native | Native | BoundedCooperative¹ | Native | +| `outbound-flexible-phase-budget` | Native | Native | BestEffort⁵ | Native | +| `send-all-slot-isolation` | Native | Native | BestEffort⁴ | Native | +| `streamed-upload-deadlines` | Native | Native | BestEffort² | Native | +| `lazy-streamed-response-passthrough` | BestEffort³ | Native | BestEffort⁶ | Native | +| `config-store` | Native | Native | Native | Native | +| `kv-store` | Native | Native | Native | Native | +| `secret-store` | Native | Native | Native | Native | + +¹ Fastly enforcement has **two documented, deterministic overshoot bounds** — +`BoundedCooperative` means real enforcement with a known finite ceiling, not zero +overshoot. All bounds below assume the common-case `total_ms ≥ 4` phase split; the +sub-4 ms degenerate branch adds `total_ms` to each (see §4.3 "Net guarantee" for +both branches explicitly): +- **Single `send`** — `now` is snapshotted inline so there is no batch drift, + but the **same `BATCH_DISPATCH_SLACK_MAX` guard** applies to the gap between + `dispatch_budget(req, now)` and `send_async` (backend lookup, possible + `Backend::builder().finish()`, SDK request construction; see §4.3). Worst-case + dispatch+headers overshoot is `BATCH_DISPATCH_SLACK_MAX + ms_rounding` (the + same bound as `send_all`); the window is typically narrower because there's + no per-slot harvest loop. Body phase overshoot ≤ one between-bytes-timeout + interval (§3.3.4). **Streamed-upload-specific overshoot**: when the request + body is `Body::Stream` and the upload drain leaves a tiny positive + `budget.deadline.remaining()`, the post-upload headers wait can additionally + cost up to one dispatch-time `first_byte_ms` interval before the cooperative + check at the `wait()` boundary or the response-wrapper preemption fires + (§4.3 "Response phase"). That overshoot is **one-shot**, not per-chunk — + the response wrapper preempts at the first post-deadline read. +- **`send_all`** — `batch_now` is shared across slots so dispatch+headers carries + `BATCH_DISPATCH_SLACK_MAX + ms_rounding` (≈ 26 ms when `total_ms ≥ 4`, §4.3 + "Dispatch-overhead slack, hard-bounded"); body phase **once a slot is actively + draining** is still ≤ one between-bytes-timeout — but the slot's **observed + completion** can additionally be delayed by the harvest-order serialization + (preceding slots' drain times). The harvest delay is what the separate + `send-all-slot-isolation` capability owns (footnote 4); the + `outbound-deadlines` bound here is on the active-drain phase only, not on + total observed wall-clock across the batch. + +Both are hard adapter constants, not "scales with preflight." `Native` is reserved for +adapters with no such caveat — this rubric lets future adapters be judged consistently +without quiet downgrading. A new adapter unable to honour a capability declares +`Unsupported` and is caught at build time. The `send_all` *buffered-body* cross-slot +caveat (harvest-order false 504s) is **not** within this capability — that one is +`send-all-slot-isolation` (footnote 4), so each label means exactly one thing. + +² Fastly has no guest primitive to preempt a stalled `stream.next().await` while feeding +a streamed REQUEST body via `send_async_streaming` (§4.3). Once chunks start flowing, +the host's `between-bytes-timeout` still bounds inter-chunk gaps, but a source stream +that never yields the next chunk is unbounded on the guest side. This is `BestEffort` — +no documented preemption bound — and is exposed as the separate +`streamed-upload-deadlines` capability so apps that need real-time enforcement on this +specific path declare it required and get a hard build failure on Fastly per §3.5.3. +Apps that buffer their request bodies before calling `send` are unaffected — buffered +uploads use `Body::Once`, no `stream.next().await`, and fall under `outbound-deadlines` +(BoundedCooperative on Fastly). + +⁵ `outbound-flexible-phase-budget` captures whether the adapter treats the request +budget as one elastic pool. On Axum/CF/Spin there is a single total SDK timeout +(reqwest's `.timeout(..)`, `worker::Delay`, the wasi timer); a slow connect followed +by a fast headers+body inside the total budget succeeds. On Fastly the budget is +**rigidly split** (§4.3 — `connect = budget/4`, `first_byte = 3*budget/4`, +`between_bytes = budget`); a request that takes more than `budget/4` on connect-phase +work fails at the connect timer even though the rest of the budget would have +sufficed. This is a documented `BestEffort` deviation — the platform-level cause is +that Fastly's `BackendBuilder` exposes per-phase timers and no total-budget timer. +Apps that need elastic budget allocation (slow-connect workloads, mixed-latency +upstreams) declare this capability required and get the hard build failure on +Fastly per §3.5.3. + +⁴ `send-all-slot-isolation` is `BestEffort` on Fastly because Fastly's `send_all` +buffered-body drain runs in harvest order (§3.3.4). A slot whose own +`budget.deadline` would have covered its body in isolation can still return +`gateway_timeout` because an earlier slot's body drain monopolised harvest. The +*headers* phase remains correct per-slot (host enforces independently) — only the +body phase loses isolation. Apps that need cross-slot result isolation declare this +capability required and get a hard build failure on Fastly per the round-5 +"required + BestEffort = hard fail" rule (§3.5.3); on Axum/CF/Spin where `join_all` +fans out body drains concurrently, isolation is `Native`. **typical small-body fan-outs are unaffected +because its fan-out response bodies are expected to be small** (the external batch protocol JSON, on +the order of a few KiB) — drain times are sub-millisecond hostcalls, so the +serial-drain wall-clock is negligibly different from concurrent drain and no slot +is starved of its budget. Sharing the same effective deadline across slots does +**not** by itself eliminate the harvest-order false 504s (§3.3.4 spells that out); +small bodies do. + +³ `lazy-streamed-response-passthrough` captures whether +`OutboundResponse::into_response()` delivers a streamed upstream body to the platform +response **without buffering**. On Cloudflare / Spin the platform SDKs accept a +non-`Send` stream natively (WASM single-threaded guest), and the response converter +chains the wrapped `Body::Stream` through — first chunks flow before the upstream stream +ends. On Axum, `axum::body::Body::from_stream` requires `Send + 'static` and core's +`LocalBoxStream` is intentionally non-Send (WASM compat). Rather than spec an +unspecified shim, the Axum response converter buffers `Body::Stream` to `Bytes` within +the adapter-level constant `AXUM_RESPONSE_STREAM_BUFFER_BYTES` (default 16 MiB; the +per-outbound-request `max_response_bytes` is gone by the time the converter runs) +before constructing the axum response — correct, bounded, but first bytes only flow +after full collection. Apps that need true lazy streaming on Axum declare this +capability required and either (a) target a different adapter or (b) wait for a future +mpsc-bridged implementation. Buffered fan-outs are unaffected. See §4.1 and +§7 for the implementation, §8 for the open mpsc-bridge follow-up. + +⁶ `lazy-streamed-response-passthrough` is `BestEffort` on Fastly for an +**entry-point-structural** reason, not a WASM-`Send` one. The Fastly Rust SDK does +not expose a `Response::with_streaming_body` method (that exists on `Request`, for +outbound bodies). Early/lazy response streaming to the downstream client goes +through `Response::stream_to_client(self) -> StreamingBody`, which the SDK +explicitly documents as **incompatible with `#[fastly::main]`** — the attribute +implicitly calls `Response::send_to_client()` on the returned response, and +`stream_to_client()` "cannot be used to send final responses with `#[fastly::main]`." +Apps that want true lazy passthrough on Fastly must: +1. drop the `#[fastly::main]` attribute on the entry function, +2. use an undecorated `main()` plus `Request::from_client()` to receive the + incoming request, +3. construct the `Response`, then call `stream_to_client()` to obtain a + `StreamingBody` they `finish()` manually. + +That is a structural constraint on the Fastly scaffold — `edgezero new --adapter +fastly` today emits a `#[fastly::main]` entry, and `OutboundResponse::into_response()` +on Fastly therefore falls back to **buffered passthrough**: drain the wrapped +`Body::Stream` to `Bytes` within the adapter-level constant +**`FASTLY_RESPONSE_STREAM_BUFFER_BYTES`** (default 16 MiB, mirroring Axum's +`AXUM_RESPONSE_STREAM_BUFFER_BYTES`). The per-outbound-request +`max_response_bytes` is unavailable by the time the response converter runs +(`OutboundResponse` carries only status / headers / body — §3.1.4), so the +adapter-level constant is what the converter uses. Over-cap during the buffered +drain → `bad_gateway` (502) — same shape as Axum. After draining, the buffered +`Bytes` is returned through the normal `#[fastly::main]` flow. Apps that need +lazy passthrough on Fastly declare this capability required and get a hard +build failure; the migration path is either (a) target a different adapter +(CF or Spin) or (b) wait for the §8 risk 12 +follow-up that adds a non-`#[fastly::main]` entry-point template + the +`stream_to_client()` plumbing. Buffered passthrough still works on Fastly +unconditionally — only the *lazy* variant is gated. + +#### 3.5.3 Build / startup enforcement + +`ensure_capabilities` runs as a **pre-dispatch gate at each adapter-selecting +entry point**, not as a per-handler call buried inside a specific `Adapter::*` +impl. The reviewer's pointer at `crates/edgezero-cli/src/adapter.rs:75` is the +controlling fact: in PR #269, `execute(..)` checks for a manifest-defined shell +command first (`manifest_command(..)`), runs it via `run_shell`, and only falls +through to `registry::get_adapter(..).execute(AdapterAction, args)` when no shell +command is configured. A capability gate placed *inside* the registry branch would +not fire for shell-overridden adapters, and a gate placed *inside* a single +`Adapter::execute` impl would not cover `Adapter::provision` or the config-validation +hooks. So the gate sits one level up — at the top of every PR-#269 `run_*` +entry point that selects an adapter. + +In PR #269 there are **five concrete gate sites**, listed below. Earlier drafts of +this section called the set "one + two siblings" and "four gates"; the +controlling count is **five** (one inside `execute(..)`, four siblings on the +PR-#269 entry points that don't flow through `execute(..)`). + +```rust +// 1. crates/edgezero-cli/src/adapter.rs — first statement of execute(..) +pub fn execute( + adapter_name: &str, + action: Action, + manifest_loader: Option<&ManifestLoader>, + adapter_args: &[String], +) -> Result<(), String> { + ensure_capabilities(adapter_name, manifest_loader)?; // ← gate site 1 + // …existing shell-command / registry dispatch follows… +} + +// 2–5. Sibling gates on the PR-#269 entry points that don't flow through execute(..): +pub fn run_provision(args: &ProvisionArgs) -> Result<(), String> { + ensure_capabilities(&args.adapter, args.manifest_loader())?; // ← site 2 + // …existing provision dispatch follows… +} +pub fn run_config_push(args: &ConfigPushArgs) -> Result<(), String> { + ensure_capabilities(&args.adapter, args.manifest_loader())?; // ← site 3 + /* … */ +} +pub fn run_config_validate(args: &ConfigValidateArgs) -> Result<(), String> { + ensure_capabilities(&args.adapter, args.manifest_loader())?; // ← site 4 + /* … */ +} +#[cfg(feature = "demo-example")] +pub fn run_demo() -> Result<(), String> { + ensure_capabilities("axum", manifest_loader())?; // ← site 5 + /* …Axum runner… */ +} +``` + +`run_demo` is feature-gated (`demo-example`) and always selects Axum implicitly, +so its gate is a sibling that hardcodes the adapter name rather than reading it +from args. Sites 1–5 are exhaustive: every PR-#269 command that selects an +adapter enters through one of them. + +`ensure_capabilities` itself reads from the **registry** (not from `Adapter::execute`) +because capability metadata is the trait fact `capability(Capability) -> +CapabilitySupport`, and the registry is where adapter implementations are looked up +by name. That means **shell-overridden adapters still get checked**: even if the +manifest configures `[adapters..commands.build]` so dispatch never reaches +`Adapter::execute`, the gate still consults the registered adapter's `capability(..)` +tuple — the shell override only routes the *action*, it does not opt out of the +*manifest contract*. + +**Missing-from-registry policy.** If `registry::get_adapter(adapter_name)` returns +`None`, the policy depends on whether the manifest declares any required or optional +capabilities: + +| Manifest `[capabilities]` shape | Adapter in registry? | Outcome | +| --- | --- | --- | +| absent or empty (`required = []`, `optional = []`) | no | `log::warn!("adapter '' not in registry; capability check skipped (no capabilities declared)")` — proceed | +| **any** entry in `required` or `optional` | no | **hard failure**: `Err("adapter '' is not in the registry; cannot verify required/optional capabilities. Register an adapter stub that returns capability metadata, or remove the [capabilities] section.")` | +| absent / empty | yes | proceed (loop bodies trivially pass) | +| has entries | yes | check each per the rubric below | + +This preserves the "required capabilities fail early" contract while keeping the +brand-new-shell-only-adapter ergonomics for the *no-capabilities* case (e.g. a +contributor wiring a new edge platform via shell-out, before they've written the +adapter stub). An app that declares any capability requires a registered adapter that +can answer the `capability(Capability) -> CapabilitySupport` question; there is no +silent bypass. + +Commands covered by the five gate sites above (one inside `execute(..)`, four siblings): + +| PR-#269 command | Entry point | Gate site | +| --- | --- | --- | +| `edgezero build` | `run_build` → `execute(Action::Build, ..)` | `execute(..)` | +| `edgezero serve` | `run_serve` → `execute(Action::Serve, ..)` | `execute(..)` | +| `edgezero deploy` | `run_deploy` → `execute(Action::Deploy, ..)` | `execute(..)` | +| `edgezero auth login` / `logout` / `status` | `run_auth` → `execute(Action::AuthLogin/Logout/Status, ..)` | `execute(..)` | +| `edgezero provision` | `run_provision` → `Adapter::provision(..)` | `run_provision(..)` sibling | +| `edgezero config push` | `run_config_push` → adapter push hook (or `--local`) | `run_config_push(..)` sibling | +| `edgezero config validate` | `run_config_validate` → adapter validation hook | `run_config_validate(..)` sibling | +| `edgezero demo` (feature `demo-example`) | `run_demo` → Axum runner | `run_demo(..)` calls `ensure_capabilities("axum", ..)` | + +Commands **not** covered (and why): +- `edgezero new` — generates source files; no adapter is selected, so capabilities + cannot be checked. The scaffold itself is identical across adapters. +- `edgezero auth status` when no manifest is present — `ensure_capabilities` + short-circuits `Ok(())` if `manifest_loader.is_none()`, which is the same + policy the registry-lookup path already uses for "no manifest, no capability + contract." Documented in the rustdoc. + +**Today's checkout (pre-#269) collapses to the same shape with fewer rows:** +`Command::{Build, Serve, Deploy, Dev}` all dispatch through the registry's +`Adapter::execute(AdapterAction::{Build, Serve, Deploy}, ..)` plus `Command::Dev`'s +implicit-Axum runner. The gate goes at the top of each of those four handlers (or +the equivalent helper they call) until PR #269 collapses them into the single +`execute(..)` dispatcher. The wording in rounds 1–43 of the appendices is accurate +against that pre-#269 shape. + +```rust +fn ensure_capabilities( + adapter_name: &str, + manifest: Option<&ManifestLoader>, +) -> Result<(), String> { + let Some(loader) = manifest else { return Ok(()) }; + let caps = &loader.manifest().capabilities; + let Some(adapter) = registry::get_adapter(adapter_name) else { + // Missing-from-registry policy (see §3.5.3 table). If the manifest + // declares no capabilities, we can't verify anything anyway — log + // and proceed so brand-new shell-only adapters work before a stub + // is wired. If it declares any required/optional capabilities, we + // cannot answer `capability(..)` and must fail closed. + if caps.required.is_empty() && caps.optional.is_empty() { + log::warn!( + "adapter '{adapter_name}' not in registry; capability check skipped (no capabilities declared)", + ); + return Ok(()); + } + return Err(format!( + "adapter '{adapter_name}' is not in the registry; cannot verify required/optional capabilities. \ + Register an adapter stub that returns capability metadata, or remove the [capabilities] section.", + )); + }; + + let missing: Vec<_> = caps.required.iter().copied() + .filter(|c| adapter.capability(*c) == CapabilitySupport::Unsupported) + .collect(); + if !missing.is_empty() { + return Err(format!( + "adapter '{adapter_name}' does not support required capabilities: {}", + missing.iter().map(Capability::as_str).collect::>().join(", "), + )); + } + let degraded: Vec<_> = caps.required.iter().copied() + .filter(|c| adapter.capability(*c) == CapabilitySupport::BestEffort) + .collect(); + if !degraded.is_empty() { + return Err(format!( + "adapter '{adapter_name}': required capabilities are only best-effort: {}. \ + best-effort means a documented limitation applies — timing (e.g. \ + unbounded cooperative enforcement) or functional (e.g. lazy streaming \ + becomes buffered). See the capability matrix footnotes. Declare them \ + `optional` if the documented limitation is acceptable.", + degraded.iter().map(Capability::as_str).collect::>().join(", "), + )); + } + for cap in caps.required.iter().copied() + .filter(|c| adapter.capability(*c) == CapabilitySupport::BoundedCooperative) + { + log::info!( + "adapter '{adapter_name}': required capability '{}' is bounded-cooperative; see capability docs for the bound", + cap.as_str(), + ); + } + // Adapter-specific service-config reminders. Capability values are static + // adapter facts (§4.3); some adapters additionally require deployment-time + // service configuration that EdgeZero cannot validate from the CLI. + if adapter_name == "fastly" + && caps.required.contains(&Capability::OutboundHttp) + { + log::info!( + "adapter 'fastly': required capability 'outbound-http' additionally \ + requires dynamic backends to be enabled on the Fastly service. \ + EdgeZero cannot validate this from the CLI; ensure the service \ + configuration is correct before deploying." + ); + } + for cap in caps.optional.iter().copied() + .filter(|c| adapter.capability(*c) == CapabilitySupport::Unsupported) + { + log::warn!( + "adapter '{adapter_name}': optional capability '{}' unavailable", + cap.as_str(), + ); + } + Ok(()) +} +``` + +- **Required + `Unsupported` → hard failure** with an explicit message. +- **Required + `BestEffort` → hard failure.** `BestEffort` means a **documented + deviation from `Native`** — that can be timing (e.g. Fastly's unbounded source-stall + in `streamed-upload-deadlines`) or functional (e.g. Axum's buffering of streamed + responses in `lazy-streamed-response-passthrough`). Either way the deviation is + real, the matrix footnotes describe it, and "required" should mean the deviation + is unacceptable. If degradation is acceptable, declare the capability `optional` + instead — the principle is "required means the matrix footnote's deviation is not + acceptable for this deployment." +- Required + `BoundedCooperative` → informational log (works, with a documented bound). +- Optional + `Unsupported` → warning. `config-store` and friends stay optional. + +#### 3.5.4 Outbound host plumbing — not policy + +`[capabilities.outbound].hosts` is **plumbing**, not a security allowlist (non-goal §1.3). +Apps still enforce their own target allowlist in handler code. Adapter use of `hosts`: + +- **Spin** requires `allowed_outbound_hosts` in `spin.toml`. The Spin adapter renders + each entry per the rules below. (`spin.toml.hbs:13` currently hardcodes + `["https://*:*"]`; that template line is replaced by a render of this list.) + + Every entry is **first canonicalized** by the host-authority subset of + `OutboundRequest`'s URI rules (§3.1.3): scheme and host are lowercased; + default ports (`:443` for `https`, `:80` for `http`) are stripped; userinfo + and fragment are rejected. **Manifest host entries diverge from + `OutboundRequest` URIs on path/query**: request URIs pass path/query through + verbatim (the wire-level request target), but manifest host entries are + host-authority-only declarations, so path/query are also rejected by the + manifest-host validator (§3.5.1). This divergence is intentional — host + entries declare "which hosts the app may talk to," not "which paths." + Sharing the lowercase-scheme / lowercase-host / strip-default-port / + reject-userinfo / reject-fragment rules with §3.1.3 keeps the canonical + spelling identical across the two surfaces; the path/query divergence is + the only difference and is enforced by the validator, not by quietly + dropping path/query at render time. The render table then takes a + *canonicalized* input — there is no second normalisation step to drift + from §3.1.3's spelling. + + | Input form (after canonicalization) | Example | Spin output | + | --- | --- | --- | + | wildcard | `"*"` | `["https://*:*", "http://*:*"]` (renders **both** schemes so the "any host" claim and the `http` loopback contract tests (§3.1.3) match the rendered manifest) | + | scheme-prefixed | `"http://localhost:3000"`, `"https://api.example.com:8443"` | rendered as-is (canonical: scheme/host lowercased, default port stripped) | + | `host:port` (no scheme) | `"api.example.com:8443"`, `"localhost:3000"` | `"https://:"` — default scheme is https; for http, write the scheme explicitly | + | bare host (no scheme, no port) | `"api.example.com"` | `"https://"` — **https + Spin default port only**; explicit non-default ports or `http` require writing the full form | + | wildcard subdomain | `"*.example.com"` | `"https://*.example.com"` | + + The §3.5.1 validator is authoritative — there is no "fallback" branch that + accepts other `scheme://authority` strings Spin happens to like. Mixing `"*"` + with specific hosts is allowed (Spin treats `"*"` as fully permissive). Bare + hosts deliberately mean "https + default port only" — defaulting tight rather + than promiscuous. Hosts that the canonicalization would change (e.g. uppercase + `EXAMPLE.com`, default-port `https://x:443`) are accepted and silently + canonicalized; the rendered `spin.toml` reflects the canonical form, so what + apps see matches what `OutboundRequest::uri()` reports. +- **Fastly** uses runtime **dynamic backends** that work for any host, so it does not + need the list at build time; `hosts` is informational for Fastly. +- **Axum / Cloudflare** ignore the list (no host pre-declaration needed). + +## 4. Adapter-by-adapter implementation notes + +Each adapter renames `src/proxy.rs` → `src/outbound.rs`, replaces its `ProxyClient` +impl with an `OutboundHttpClient` impl, adds `capability()`, and gains a +`tests/contract.rs`. + +### 4.1 Axum — `crates/edgezero-adapter-axum` + +- `AxumProxyClient` → `AxumOutboundClient`; keeps the pooled `reqwest::Client`. +- `send_all` first runs a **preflight** per slot: any request whose `body` is + `Body::Stream` OR whose `response_mode` is `Streamed` is converted in place to + `Err(EdgeError::bad_request(..))` (§3.1.1) so the trait contract holds identically + on every adapter. The Buffered-mode buffered-body survivors are fanned out via + `futures::future::join_all` over a private `send_one(req, batch_now)`; index + alignment is preserved by tracking the original positions while building the + future set. **`send_all` snapshots `let batch_now = web_time::Instant::now()` once** + before fanning out and passes the same value to every per-slot + `dispatch_budget(req, batch_now)` — see §3.3.2 / §4.3 for why a per-slot + `Instant::now()` would drift the shared-deadline `duration` and (on Fastly) the + backend identity. +- `send_one(req, now)` flow, in this order: + 1. **Compute the budget.** `let budget = dispatch_budget(req, now)?` (§3.3.2 — + never an adapter-local formula, so `DEFAULT_NO_DEADLINE_BUDGET = 30 s` is + applied uniformly when no deadline is set). On expiry-before-dispatch this + returns `Err(gateway_timeout)` for the slot immediately. For a single `send`, + `now = web_time::Instant::now()` is taken inline. + 2. **If the request body is `Body::Stream`, drain it to `Bytes` first.** Core + `Body::Stream` is `LocalBoxStream` (not the `Send + 'static` stream + `reqwest::Body::wrap_stream` requires), so Axum drains a streamed request body + into `Bytes` up to `req.max_request_body_bytes` (default 8 MiB) **before** + constructing the reqwest request. Pre-append checked accounting per §3.4.1; + over-cap → `bad_request`. The drain itself is raced against `budget.deadline` + using `tokio::time::timeout`-per-chunk-pull — a stalled upload yields + `gateway_timeout` rather than consuming the budget silently. Adding reqwest's + `stream` feature is **not** required. + 3. **Construct the reqwest request.** Build the `reqwest::Request` / + `RequestBuilder` from the buffered (or now-buffered) body, URI, method, + and normalized headers. Do not arm the timeout yet — it gets re-read + at the very last moment in step 4. + 4. **Arm the reqwest timeout and send.** Immediately before + `.send().await`, re-read `budget.deadline.remaining()`. If `None` (drain + + construction consumed the budget) → `gateway_timeout` without + sending. Otherwise `.timeout(remaining)` is set from this + just-re-read value, **not** from the cached value at end-of-drain and + **not** from the original `budget.duration`. Re-reading at arming time + (matching Spin's "at the moment the race starts" — round 21) closes + the construction-time gap that would otherwise let a 100 ms build + phase silently extend the SDK timeout past the absolute deadline. + reqwest's timeout covers the response-body read, so a `Buffered` + drain inherits the deadline. `Buffered` mode drains the response + body with a running decompressed-byte counter against `max_bytes` + (pre-append check per §3.4.1). `Streamed` mode wraps `reqwest`'s + byte stream with a `tokio::time::timeout`-per-chunk wrapper bounded + by `budget.deadline`; the wrapper yields a `gateway_timeout` error + chunk past the deadline so the streamed body honours the deadline + end-to-end per §3.3.3. +- Errors: `reqwest` timeout → `gateway_timeout`; connect/DNS/TLS → `bad_gateway`; + over-cap → `bad_gateway`. Any completed exchange (incl. non-2xx) → `Ok`. +- `capability()` per §3.5.2: `outbound-http` = `Native`, `outbound-deadlines` = `Native`, + `outbound-flexible-phase-budget` = `Native` (Axum's reqwest exposes a single total + timeout, not a phase split), `send-all-slot-isolation` = `Native`, + `streamed-upload-deadlines` = `Native`, `lazy-streamed-response-passthrough` = + `BestEffort` (footnote 3 — Axum buffers, see `response.rs` task in §7), + `config-store` / `kv-store` / `secret-store` = `Native`. **Nine** capabilities total. +- Reference adapter for the contract (§5): real loopback HTTP. + +### 4.2 Cloudflare — `crates/edgezero-adapter-cloudflare` + +- `CloudflareProxyClient` → `CloudflareOutboundClient` (stays stateless). +- `send_all` first runs a **preflight** per slot: any request with `Body::Stream` + OR `response_mode = Streamed` is converted to `Err(EdgeError::bad_request(..))` + per §3.1.1 *before* `send_one` is invoked. **`send_all` snapshots `let batch_now = + web_time::Instant::now()` once** before fanning out and passes it to every + `send_one(req, batch_now)`. Buffered-mode buffered-body survivors are fanned out + via `join_all`; the Workers JS event loop provides the concurrency. Index + alignment is preserved. +- `send_one(req, now)` flow, in this order: + 1. **Compute the budget.** `let budget = dispatch_budget(req, now)?` (§3.3.2). + Expiry before dispatch returns `Err(gateway_timeout)` for the slot. + 2. **If the request body is `Body::Stream`, drain it to `Bytes` first.** Up to + `req.max_request_body_bytes` (default 8 MiB), pre-append checked accounting; + over-cap → `bad_request`. The drain is raced against `budget.deadline` using + a per-chunk-pull `worker::Delay` race — a stalled upload yields + `gateway_timeout` rather than consuming the budget silently. + 3. **Construct the `worker::Request`.** Build the request from the + buffered (or now-buffered) body, URI, method, and normalized headers. + Do not start the `worker::Delay` race yet. + 4. **Arm the race and send.** Immediately before issuing fetch and starting + the `worker::Delay`, re-read `budget.deadline.remaining()`. `None` → + `gateway_timeout` without sending. Otherwise race the fetch **and**, in + `Buffered` mode, the body drain against `worker::Delay(remaining)` using + this just-re-read value (matching Spin and the round-38 Axum step). On + expiry drop the future (`gateway_timeout`). The existing gzip/br + decompression path is kept; the decompressed-byte cap is enforced + incrementally while decompressing (§3.4.1), with pre-append checked + accounting. +- **Streamed responses honour the effective-budget deadline.** Wrap the response body + as `Body::Stream`, with a per-chunk race against a `worker::Delay` bounded by + `budget.deadline` (the synthetic-if-absent absolute deadline from + `dispatch_budget`). The wrapper yields a `gateway_timeout` error chunk past the + deadline so the streamed body honours the deadline end-to-end per §3.3.3. +- `capability()` per §3.5.2: `Native` for **all nine** capabilities + (`outbound-http`, `outbound-deadlines`, `outbound-flexible-phase-budget` (single + `worker::Delay` for the total race, no per-phase split), `send-all-slot-isolation`, + `streamed-upload-deadlines`, `lazy-streamed-response-passthrough`, `config-store`, + `kv-store`, `secret-store`). Cloudflare's WASM single-threaded guest carries no + `Send` constraint, so `worker::Body::from_stream` consumes the core `Body::Stream` + directly **in the response-out direction** + (`lazy-streamed-response-passthrough` — see §7 `src/response.rs`). The + **outbound-request upload direction** still drains `Body::Stream` to `Bytes` + first (bounded by `max_request_body_bytes`, raced against `budget.deadline`), + because `send_async`-style streamed uploads aren't part of this migration and + the worker SDK's request-body shape differs from `Body::from_stream`. Don't + conflate the two — `send_one`'s flow above is the request side; this bullet is + the response side. + +### 4.3 Fastly — `crates/edgezero-adapter-fastly` + +The critical adapter. The current code (`proxy.rs:30-35`) does +`send_async_streaming()` then `pending_request.wait()` inside one `send()`, so a +`join_all` of `send()` is fully serial. The fix is **dispatch-all-then-harvest**. + +Confirmed `fastly` 0.12.1 API: + +```rust +// fastly::http::request +pub fn select>(pending_reqs: I) + -> (Result, Vec); // no index returned +pub enum PollResult { Pending(PendingRequest), Done(Result) } +// PendingRequest::poll(self) -> PollResult (non-blocking) +// PendingRequest::wait(self) -> Result (blocks on one) +// Request::send_async(self, backend) -> Result +``` + +`select` does not report which request completed, so it cannot preserve request↔slot +identity — and the application must know which target answered. The adapter harvests by **indexed +slot** with `wait()` / `poll()`: + +```rust +// Each Pending slot carries the metadata `harvest` needs — without these, the +// post-`wait()` body buffering / cap / deadline contract would have nothing to +// work from. (`send_all` rejects streamed REQUEST bodies AND streamed responses +// per §3.1.1 in preflight, so the slot only ever has to handle Buffered +// responses with a max_bytes cap.) +struct PendingSlot { + pending: PendingRequest, + budget: DispatchBudget, // duration + absolute deadline (§3.3.2) + max_bytes: usize, // from ResponseMode::Buffered { max_bytes } +} + +enum Slot { + Pending(PendingSlot), + Done(Result), + Taken, +} + +async fn send_all( + &self, + reqs: Vec, +) -> Vec> { + let n = reqs.len(); + + // Single batch-level `now` snapshot — same value passed to every per-slot + // dispatch_budget so a shared caller Deadline produces the same `duration` + // and ceiled `budget_ms`, and therefore one dynamic-backend identity per host + // in a homogeneous-budget batch (§3.3.2 / §4.3). + let batch_now = web_time::Instant::now(); + + // Phase 0 — preflight. send_all rejects streamed REQUEST bodies and streamed + // RESPONSES per §3.1.1 BEFORE dispatch. Other slots fall through to Phase 1. + let reqs: Vec> = reqs.into_iter() + .map(|req| { + if req.is_stream_body() { + return Err(EdgeError::bad_request( + "send_all requires buffered request bodies")); + } + if req.is_stream_response() { + return Err(EdgeError::bad_request( + "send_all requires buffered responses")); + } + Ok(req) + }) + .collect(); + + // Phase 1 — dispatch. Every request is in-flight at the host concurrently. + // dispatch() returns Err for an expired/zero deadline (§3.3.2) so those slots + // never enter Phase 2. The host connect/first-byte/between-bytes timeouts are + // set from budget.duration; budget.deadline governs the body-phase cooperative + // check below. + let mut slots: Vec = reqs.into_iter() + .map(|maybe_req| match maybe_req { + Err(e) => Slot::Done(Err(e)), + Ok(req) => match dispatch(req, batch_now) { + // dispatch(req, now) -> Result<(PendingRequest, DispatchBudget, usize), EdgeError> + // where the third field is max_bytes from ResponseMode::Buffered. + Ok((pending, budget, max_bytes)) => Slot::Pending(PendingSlot { + pending, budget, max_bytes, + }), + Err(e) => Slot::Done(Err(e)), + }, + }) + .collect(); + + // Phase 2 — harvest. wait() blocks on one slot; siblings keep progressing at + // the host. For the headers phase, wall-clock is ~max(header_arrivals), not + // the sum. Buffered body drain runs *serially* in harvest order, so total + // wall-clock is ~max(header_arrivals) + Σ body_drain_times — see §3.3.4 + // "Buffered body drain runs in harvest order". poll() opportunistically + // collects siblings that already finished headers. Only Buffered responses + // reach this point — Streamed responses were rejected in Phase 0 preflight. + let mut out: Vec>> = + (0..n).map(|_| None).collect(); + for i in 0..n { + match std::mem::replace(&mut slots[i], Slot::Taken) { + Slot::Done(r) => out[i] = Some(r), + Slot::Taken => { /* already harvested by an earlier poll() */ } + Slot::Pending(s) => { + out[i] = Some(harvest(s.pending.wait(), &s.budget, s.max_bytes)); + for j in (i + 1)..n { + // Carefully preserve every variant; the bug we are + // avoiding here is "take a Slot::Done(Err(..)) from + // preflight or dispatch and replace it with Slot::Taken, + // which then drops the Err on the floor and the outer + // loop reports a generic 'slot unresolved' internal + // error." + match std::mem::replace(&mut slots[j], Slot::Taken) { + Slot::Done(r) => out[j] = Some(r), // preserve preflight / dispatch error + Slot::Taken => { /* already harvested */ } + Slot::Pending(s2) => match s2.pending.poll() { + PollResult::Done(r) => out[j] = Some(harvest(r, &s2.budget, s2.max_bytes)), + PollResult::Pending(pr2) => slots[j] = Slot::Pending(PendingSlot { + pending: pr2, + budget: s2.budget, + max_bytes: s2.max_bytes, + }), + }, + } + } + } + } + } + // Invariant: every slot resolved above. Map any unfilled slot to an + // internal error rather than panic — adapter boundaries must never + // crash the host on a contract bug. + out.into_iter() + .enumerate() + .map(|(i, r)| r.unwrap_or_else(|| Err(EdgeError::internal(anyhow::anyhow!( + "fastly outbound: slot {i} unresolved by harvest loop (adapter bug)" + ))))) + .collect() +} +``` + +- **`.wait()` is not the problem** — calling it before all requests are dispatched was. + After Phase 1 every request runs at the host; Phase 2 only collects results. +- **Deadline:** each request's host timeouts are set to the effective budget at dispatch, + so connect+headers cannot block past it. The body phase checks `budget.deadline` + **after every blocking body read returns, including the EOF read** (per §3.3.4 — + the read that discovers EOF can itself cross the deadline and would otherwise + slip through with `Ok(resp)`). Streamed bodies are wrapped to check before and + after each underlying read. Bounded overshoot per §3.3.4. +- **Dynamic backends.** Arbitrary HTTPS hosts use Fastly dynamic backends + (`Backend::builder`). Per Fastly's + [`BackendBuilder` docs](https://docs.rs/fastly/latest/fastly/backend/struct.BackendBuilder.html), + the **session-uniqueness rule is unconditional** — a dynamic backend name must + not match the name of any static service backend nor any other dynamic backend + built during this session. `NameInUse` carries no property-comparison + semantics: the SDK signals only "this name is taken in this session," and its + documented recovery (`Backend::from_str(name)`) returns a handle without + exposing the registered properties. EdgeZero therefore owns the entire + uniqueness story **at the guest layer**: an adapter-local cache + (`Mutex>` on + `FastlyOutboundClient`) holds the identity → backend mapping, and a hit + reuses the cached `Backend` while a miss calls `Backend::builder(..).finish()` + exactly once. Because EdgeZero hashes every relevant property into the + backend name (`ez_{sha256_128(identity)}`), distinct identities map to + distinct names — so a 50 ms slot and a 3 s slot to the same host get distinct + backends by construction, not by SDK-side property comparison. A + `NameInUse` on a name **not** in the adapter's collision map can therefore + only mean an externally-registered backend (another component, a prior + session) is squatting the name — fail-closed `EdgeError::internal` because + the SDK does not let us prove identity match. The precise collision-detection + protocol is in the §4.3 algorithm later in this section. + + Identity tuple: + `scheme + ":" + host + ":" + resolved_port + ":" + tls_mode + ":" + budget_ms`, + where: + - `resolved_port` is the URI port or scheme default (`80`/`443`). + - `tls_mode` is `"tls"` for `https` or `"plain"` for `http`. + - `budget_ms` is the **true ceil-to-ms** of `dispatch_budget(req).duration` — + `((duration.as_nanos() + 999_999) / 1_000_000).max(1) as u64`. `as_millis()` + *floors*, which would turn a 1.9 ms budget into a 1 ms host timeout and + produce premature Fastly timeouts; ceiling guarantees the host timeout is + never tighter than the caller's intended budget. The same ceiled value is + fed into `connect-timeout` / `first-byte-timeout` / `between-bytes-timeout`, + so the identity tuple and the actual host configuration always match. (Apps + really wanting a sub-ms wall-clock should not target Fastly — host + timeouts themselves are millisecond-granular.) §3.3.4's "host timeouts = + `budget.duration`" is therefore an abbreviation for "host timeouts = + ceil-to-ms of `budget.duration`"; the body-phase cooperative + `budget.deadline.is_expired()` check still uses the exact original + `Deadline`, so the wall-clock contract is unchanged. + + Name = `format!("ez_{:032x}", sha256_128(identity))` — the first 128 bits of a + SHA-256 digest, collision-resistant in any realistic deployment (the previous + 64-bit FNV-1a draft was not). The name fits inside Fastly's backend-name length + limit (`ez_` + 32 hex chars = 35 chars) and is valid for any host. In a + homogeneous-budget batch all slots targeting the same host + share one backend — **but only because `send_all` takes a single `now` snapshot + and passes it to every per-slot `dispatch_budget` call** (§3.3.2). Without that, + sequential `Instant::now()` per slot would derive slightly different `duration`s + for the same shared caller `Deadline`, which would produce slightly different + ceiled `budget_ms` values and therefore different identities for the same host + under one batch deadline. The shared-`now` snapshot is a normative requirement + of the `send_all` flow, not an implementation hint. In heterogeneous-budget + fan-out each distinct budget gets its own backend, by design. Per-handler + backend count is bounded by `unique(host, port, tls, budget_ms)` tuples; apps + that mix wildly varying budgets should be aware of the dynamic-backend limit on + their Fastly service. + + **Dispatch-overhead slack, hard-bounded.** Because `batch_now` is captured + *before* preflight, dynamic-backend creation, and `send_async`, the `budget_ms` + baked into the backend identity is a *bucketed* timeout — not the exact remaining + wall-clock at the moment the SDK timer is armed. The Fastly host enforces + `budget_ms` from the moment it sees the request, so a request can in principle + complete up to `(now_at_send_async − batch_now) ms` after the absolute fan-out batch + deadline before the host fires its timeout. To keep this slack + **deterministically bounded** (so `outbound-deadlines = BoundedCooperative` on + Fastly is actually true, not just usually-tight): + + - The adapter caps `(now_at_send_async − batch_now)` at + `pub const BATCH_DISPATCH_SLACK_MAX: Duration = Duration::from_millis(25);` + (defined alongside `DEADLINE_FAR_FUTURE` in `src/time.rs`, §7). + - Before each slot's `send_async`, the adapter checks + `Instant::now() - batch_now <= BATCH_DISPATCH_SLACK_MAX`. If exceeded, the + remaining slots fail closed with + `Err(EdgeError::internal("Fastly send_all adapter overhead between batch_now \ + and SDK arming (preflight + dynamic-backend lookup/creation + SDK setup) \ + exceeded BATCH_DISPATCH_SLACK_MAX; refusing to arm SDK timers with stale \ + duration"))`. This is an internal diagnostic about **adapter-side** work, + not a handler-side complaint — handler code runs before `send_all` is even + invoked, so it runs before `batch_now` is captured and cannot exhaust this + budget. The interval measured here is adapter overhead: per-slot preflight + validation, dynamic-backend lookup/creation host calls, and SDK setup + before `send_async`. If this fires in production, the operator looks at + backend-creation hostcall latency or a noisy neighbour, not at handler + code. + - The cooperative `budget.deadline.is_expired()` check during body drain still + catches body-phase overshoot per §3.3.4 (one between-bytes-timeout bound). + + Net guarantee, with the explicit **sub-4 ms branch** broken out separately: + + - **`total_ms ≥ 4` (the common case)**: a Fastly slot can complete at most + **`BATCH_DISPATCH_SLACK_MAX + ms_rounding`** past the absolute fan-out batch + deadline on the dispatch+headers phase. Because connect and first-byte are + *separate* host timers (Fastly docs), the budget is split — `connect_ms = + total_ms / 4`, `first_byte_ms = total_ms - connect_ms` — so their sum equals + `total_ms` exactly and the dispatch+headers host enforcement is bounded by + `budget.duration`. If dispatch happens at `batch_now + Δ` with + `Δ ≤ BATCH_DISPATCH_SLACK_MAX`, the host fires at + `(batch_now + Δ) + (connect_ms + first_byte_ms) = (batch_now + Δ) + total_ms`, + which is `Δ + ms_rounding` past the absolute deadline. Setting *both* timers + to the full budget would have made the worst case ~2× — explicitly *not* what + this design does (see §3.3.4 / §4.3 code block). + - **`total_ms < 4` (the sub-4 ms degenerate case)**: §4.3 sets both + `connect_ms = first_byte_ms = total_ms`, so the dispatch+headers host + enforcement is bounded by `2 × total_ms` (≤ 6 ms total at the edge). The + post-deadline slack is therefore up to `BATCH_DISPATCH_SLACK_MAX + total_ms + + ms_rounding` (strict upper bound `25 + (≤ 3) + (≤ 1) < 29 ms` wall-clock). + At this scale ms-rounding already + dominates a meaningful deadline; sub-4 ms outbound budgets are degenerate + inputs, not a normal operating point. The test row asserts the 2× bound + explicitly rather than the `=` invariant. + + The body-phase cooperative check still adds up to one between-bytes-timeout + overshoot during drain (§3.3.4) in either case, but that's the only other + source. All terms are hard adapter constants, not "scales with preflight." + + Single `send` snapshots `now` inline at `send_one` entry — there is no + `batch_now` shared across slots — but time still passes between + `dispatch_budget(req, now)` and `send_async` (backend lookup, possible + `Backend::builder().finish()` host call, SDK request construction). The + **same `BATCH_DISPATCH_SLACK_MAX` guard** applies: immediately before + `send_async`, the adapter checks `Instant::now() - now <= + BATCH_DISPATCH_SLACK_MAX`; on excess, the single `send` returns + `EdgeError::internal(..)` with the same "adapter overhead between + dispatch_budget and SDK arming" diagnostic as `send_all`. The slack window is + typically narrower for single `send` (no per-slot harvest loop), but the + bound is the same hard constant; the previous "structurally 0" wording was + incorrect. The phase-budget split and sub-4 ms branch apply identically. + + §5.4 has a row that locks this. The test cannot use a handler-side sleep before + `send_all` — that runs *before* the adapter captures `batch_now`, so it never + exercises the slack guard. The test instead uses an **adapter-internal injection + hook** (a `#[cfg(test)]` `Fn` slot on `FastlyOutboundClient` invoked between + `batch_now` capture and per-slot `dispatch()`) to introduce a synthetic delay + exceeding `BATCH_DISPATCH_SLACK_MAX`. With the hook set, late slots return + `internal("Fastly send_all adapter overhead between batch_now and SDK arming \ + (preflight + dynamic-backend lookup/creation + SDK setup) exceeded \ + BATCH_DISPATCH_SLACK_MAX; refusing to arm SDK timers with stale duration")`; + without it, no slot ever returns that error. Apps that need exact + absolute-deadline enforcement on the dispatch+headers phase target a different + adapter (Axum/CF/Spin all use `budget.deadline.remaining()` at arming time — + see §4.1 / §4.2 / §4.4 step 3). **Collision detection** is + belt-and-suspenders. The collision-detection map lives on the + `FastlyOutboundClient` itself, not per call. Because `OutboundHttpClient` methods + take `&self` and the trait is `Send + Sync`, the field is + `Mutex>` — interior mutability with + thread-safe access. The simplest race-free protocol: + + 1. Acquire the outer lock. + 2. If the name maps to a stored entry `(stored_identity, cached)`: + - **`stored_identity == identity`**: clone the cached `Backend`, drop the + lock, dispatch. + - **`stored_identity != identity`** (an in-adapter SHA-256-128 collision + between two distinct identities mapping to the same name): fail closed with + `EdgeError::internal("Fastly dynamic backend name collision in this + adapter's map — two distinct identities hashed to the same backend name; + refusing to silently swap settings")`. The previous-round wording reused + the cached backend by name alone, which would have silently bound a new + request to whichever identity got cached first — that bug is fixed by the + explicit identity comparison here. Drop the lock. §5.4 has a row that + exercises this path via an injectable hash collision under `#[cfg(test)]`. + 3. Otherwise (name is absent), call `Backend::builder(..).finish()` **with the + lock still held**. The earlier "lock-not-across-host-call" rule from round 20 + is reversed here: Fastly's `finish()` is a short host call that never blocks + on guest I/O, so holding the lock through it is safe (single-threaded WASM + has no contention; multi-threaded hosts pay short per-`FastlyOutboundClient` + serialization, which is one instance per request context). + 4. On `Ok(backend)`: insert `(identity, backend.clone())` into the map and + return the `Backend`. + 5. On `Err(NameInUse)`: per Fastly's + [`BackendBuilder` docs](https://docs.rs/fastly/latest/fastly/backend/struct.BackendBuilder.html), + the **session-uniqueness rule is unconditional** — "a dynamic backend name + must not match the name of any static service backend nor match any other + dynamic backend built during this session." `NameInUse` does **not** carry + property-comparison semantics ("same identity → returns Ok" was a false + premise in earlier drafts); the SDK signals only "this name is taken in + this session," period. The SDK's documented recovery pattern is to call + `Backend::from_str(name)` (alias `Backend::from_name`) to obtain a handle + to the already-registered backend — but `from_str` returns a handle only + and **does not expose the registered backend's properties** to the guest + for comparison. + + Because the outer lock is held continuously through steps 2–4, no other + thread under this `FastlyOutboundClient` can have registered the name + without showing up in step 2. A `NameInUse` here therefore means the name + is registered by an **external party** (another component, a prior + session) — and since the SDK does not let us inspect that external + backend's properties, we cannot prove its identity matches ours. Fail + closed with `EdgeError::internal("Fastly Backend::builder returned + NameInUse for a name not in this adapter's collision map; the SDK does + not expose the externally-registered backend's properties, so we cannot + prove identity match — refusing to dispatch to a backend with possibly + mismatched TLS / timeout / SNI configuration")`. Drop the lock. + + The alternative — falling back to `Backend::from_str(name)` and trusting + the external registration — is exactly the "you should be careful to only + use this capability in situations in which you are 100% sure that this + name will always lead to the same place" caveat that Fastly's docs + attach to `from_str`. Since EdgeZero owns the `ez_{sha256_128(identity)}` + naming scheme, a `NameInUse` on a name we didn't register can only mean + an unrelated component picked the same hashed name (vanishingly unlikely + given the 128-bit identity space) or our session is sharing a Fastly + edge dictionary with another EdgeZero deployment that uses a different + identity tuple — neither case is safe to silently inherit. + 6. On any other `Backend::builder` error, **map to `EdgeError::bad_gateway`** — + these are service/backend setup failures (dynamic backends disabled on the + service per §4.3 "Service prerequisite," DNS resolution failure for the + target, TLS misconfiguration, or any other Fastly-side rejection that + reaches the guest). Specifically: + `Err(EdgeError::bad_gateway(format!("Fastly dynamic backend setup failed: {e}")))`. + `EdgeError::internal` is reserved for **adapter contract bugs** — invariant + violations the adapter itself should have prevented (the unfilled-slot case + in the harvest loop, the `BATCH_DISPATCH_SLACK_MAX` overshoot, this + section's `NameInUse` external-registration case). Drop the lock. + + There is no `BackendSlot::Building` / `Failed` variant and no condvar — holding + the outer lock through the build means no other thread can observe an + intermediate state, so the race the round-34 review flagged is structurally + impossible. A per-name reservation with finer-grained locking is more + concurrent but only matters on multi-threaded hosts where the Fastly adapter + isn't used. It applies to: + + - **`send_all`** — each slot looks up its name; if the name already maps to its own + identity, reuse; if it maps to a *different* identity, fail closed with + `EdgeError::internal("dynamic backend name collision — refusing to reuse")`. + - **Single `send`** — same lookup path; same fail-closed behaviour. + - **Across calls** — the map persists for the lifetime of the + `FastlyOutboundClient` (one per request context), so a second `send` in the same + handler reuses the same backend cheaply and a SHA-256-128 collision against an + earlier call is still caught. + - **`Backend::builder` returns `NameInUse`** — the adapter cannot fully verify + the registered identity. Fastly's `Backend::from_name` returns a handle to the + existing backend but its public getters do not round-trip every builder field + (SNI hostname / certificate hostname are notably opaque per the + `BackendBuilder` / `Backend` docs). So the adapter **fails closed** with + `EdgeError::internal("Fastly Backend::builder returned NameInUse for a name \ + not in this adapter's collision map — refusing to reuse an externally \ + registered backend")`. Names already in the adapter's own map are reused + cheaply with no `Backend::builder` call (the in-memory `Backend` handle is + already present); only an *external* registration of a colliding name + triggers this path, and the safest response is to surface it rather than + guess. This makes the adapter's collision map authoritative. + + Backends are deduplicated by full identity within and across calls. Requires + dynamic backends enabled on the service (surfaced via the `outbound-http` + capability and the service prerequisite below). +- Requests in `send_all` are required to have buffered request bodies AND buffered + response mode per the trait contract (§3.1.1). A `Body::Stream` request body + yields `out[i] = Err(EdgeError::bad_request(..))`; a request with + `response_mode = Streamed` also yields `out[i] = Err(EdgeError::bad_request(..))`. + This keeps Fastly's dispatch-all-then-harvest model from serializing on slow + request uploads and removes the cross-slot streamed-response deadline-lifetime + problem (§3.1.1), identically on every adapter. +- **Streamed request bodies in single `send`.** The single-request path accepts + `Body::Stream` and uses `Request::send_async_streaming(&backend) -> (StreamingBody, + PendingRequest)`. The adapter then feeds chunks from the core stream to the + `StreamingBody`, with these rules: + - **Byte count cap.** Pre-append checked accounting against + `req.max_request_body_bytes` (default 8 MiB). Over-cap → `bad_request` (400) — + the `StreamingBody` is dropped without `finish()`, the `PendingRequest` is + dropped, and the slot returns the error. + - **Deadline enforcement has two phases with different bounds:** + - *Source-stream yield* (`stream.next().await`): **unbounded on Fastly** — no + guest async primitive can preempt a stalled `stream.next()` waiting for the + app's source stream to yield. This is the `BestEffort` aspect of + `streamed-upload-deadlines` on Fastly. Apps that need real-time enforcement + against an untrusted upload source must pass a buffered request body + (`Body::Once`) where the bytes are already in hand and no `stream.next().await` + is involved. + - *Host write* (`StreamingBody::write_all` / `flush()` on a yielded chunk): these + are synchronous host calls. **Fastly's `between_bytes_timeout` applies only to + received bytes (the gap between bytes Fastly receives from the origin), not + to guest-to-origin writes** — see the [Fastly Backend API + docs](https://www.fastly.com/documentation/reference/api/services/backend/), + which describe `between_bytes_timeout` as "maximum duration … that Fastly will + wait while receiving no data on a download from a backend." No published + Fastly backend-timeout field bounds the host-side write of guest-supplied + bytes to origin. **BestEffort** for the write phase: a `StreamingBody::write_all` + whose host TCP buffer is full because origin stopped acking has no + adapter-configurable timeout. The adapter's only recourse is the + cooperative `budget.deadline.is_expired()` check **between** chunks (point + (i)/(ii) below), which catches the deadline only between writes, not during + a single blocked write. Apps that need real-time enforcement against a slow + origin **read path** rely on `between_bytes_timeout` once the response body + starts flowing; apps that need real-time enforcement against a slow origin + **write path** for streamed uploads need to size `max_request_body_bytes` + small enough that a stalled write cannot exceed the auction tolerance, + *or* target a different adapter. + - *Around each chunk*: the adapter checks `budget.deadline.is_expired()` at + **two** points per iteration — (i) immediately after `stream.next().await` + returns and **before** `write_all`, so a `stream.next()` that stalled past + the deadline and *then* finally yielded cannot still write the chunk it just + produced; and (ii) after the successful `write_all` / `flush()`, so a write + that pushed the budget over is caught before the next pull. On expiry at + either point the `StreamingBody` is dropped without `finish()` and the slot + returns `gateway_timeout`. + + Net: the capability matrix entry `streamed-upload-deadlines = BestEffort` for + Fastly reflects the worst phase (source-stream yield). The risk section (§8) + spells out the two-phase decomposition so apps don't assume the BoundedCooperative + write-side bound covers source stalls. + - **Response phase: host timeouts are *not* adjustable mid-flight.** The Fastly + SDK sets connect / first-byte / between-bytes timeouts once before `send_async` + (§3.3.4) and does not expose post-dispatch mutation. For + `send_async_streaming`, dispatch happens **before** chunks are fed, so the + response-phase host timeouts are locked to the phase-split values computed at + dispatch (`first_byte_ms` for the headers wait, `between_ms` for inter-chunk + gaps once the response body flows). After the upload `finish()`es the adapter + checks `budget.deadline.remaining()` cooperatively before calling `wait()` — + if `None`, drop the `PendingRequest` and return `gateway_timeout` without + waiting. **If the upload leaves a tiny positive remaining budget**, the + cooperative check at this boundary passes, and the host then waits up to + its dispatch-time `first_byte_ms` for headers even though only the tiny + remainder of batch budget is left. **The headers wait is bounded by at + most one dispatch-time `first_byte_ms` interval past `budget.deadline`** — + a single, one-shot overshoot, not a per-chunk accumulator. + + Once headers arrive, the **response body** flows through the cooperative + deadline-aware wrapper (§4.3 "Streamed-response wrapping"), whose + `is_expired()` check fires before and after **each** underlying read. + Because the wrapper checks after the read that delivered the first body + chunk, and the deadline is already expired by construction in this + scenario, **the very next deadline-check yields `Err(gateway_timeout)`** — + the wrapper does **not** wait another `between_bytes_timeout` per chunk + indefinitely. **Total post-deadline overshoot for a single streamed + upload + response on Fastly is therefore bounded by `first_byte_ms` + (the headers wait) plus one `between_bytes_timeout` (the worst-case + interval during which the host is mid-read of the *first* body chunk + when the wrapper fires)** — a closed-form bound, not a per-chunk + accumulator. The previous "plus one between-bytes-timeout per body-chunk + gap" wording in earlier drafts was wrong; the wrapper preempts after the + first post-deadline read returns. + + This is a deliberate, documented Fastly-specific behaviour of streamed + uploads: apps that need tight end-to-end wall-clock should pass a buffered + request body (`Body::Once`) so the timeouts are set with the full budget + known and no upload-time eating happens. +- `capability()` per §3.5.2: `outbound-http` = `Native`, `outbound-deadlines` = + `BoundedCooperative` (footnote 1 — covers single `send`, plus `send_all` headers + phase AND active body-drain phase per slot; cross-slot harvest-order delay is + the separate `send-all-slot-isolation` story), + `outbound-flexible-phase-budget` = `BestEffort` (footnote 5 — rigid 1/4 connect + + 3/4 first-byte split per §4.3 can fail a request that would have fit within the + total budget), `send-all-slot-isolation` = `BestEffort` (footnote 4 — + buffered-body harvest order can produce false 504s), + `streamed-upload-deadlines` = `BestEffort` (footnote 2 — no preemption of a + stalled `stream.next().await`), `lazy-streamed-response-passthrough` = + `BestEffort` (footnote 6 — Fastly's `Response::stream_to_client()` is + incompatible with `#[fastly::main]`, so the default scaffold falls back to + buffered passthrough; lazy streaming requires a non-`#[fastly::main]` entry), + `config-store` / `kv-store` / `secret-store` = `Native`. **Nine** capabilities + total. This is the exact tuple `Adapter::capability()` returns on Fastly. + +**Streamed-response wrapping.** Even without a guest async timer, the Fastly adapter +wraps streamed response bodies with a **cooperative deadline-aware stream**. Each +`Stream::next` checks `budget.deadline.is_expired()` **both before issuing the +underlying body read and again after it returns** (including the read that +discovers EOF and would otherwise complete the stream cleanly). On expiry at +either check it yields `Err(EdgeError::gateway_timeout(..))` instead of `Ok(chunk)` +or stream-end. This applies to *every* consumer of the wrapped body — +`into_bytes_bounded`, `into_bytes_bounded_until`, `into_response()` proxy +passthrough — so the deadline cannot be bypassed by choosing a non-helper +consumption path or by riding the final blocking read to EOF. Bounded-cooperative +semantics apply: a chunk gap (including the gap before EOF) is bounded by the +host's `between-bytes-timeout` (set to `budget.duration` at dispatch), so per-gap +overshoot ≤ one between-bytes-timeout interval. + +**Limitation, stated explicitly.** The harvest loop blocks the single-threaded guest in +`wait()`. This is correct and concurrent (all requests progress at the host in parallel), +but the guest cannot do other work while blocked — the intended behaviour for a fan-out batch. +`wait()` parks efficiently; there is no busy-polling. + +**Service prerequisite — dynamic backends.** Fastly outbound HTTP to arbitrary hosts +requires **dynamic backends to be enabled on the Fastly service**. That is a +deployment-time service configuration, not adapter code, and the adapter itself cannot +turn it on. EdgeZero handles the gap as: + +1. **Build / deploy:** `ensure_capabilities` emits an informational log when Fastly is + the target adapter and `outbound-http` is required, reminding the operator to enable + dynamic backends on the service. EdgeZero deliberately does not pull in the Fastly + management API to validate this from the CLI. +2. **Runtime:** if dispatch fails because dynamic backends are disabled, the adapter + surfaces `EdgeError::bad_gateway("Fastly dynamic backends are not enabled on this + service; enable them in the service configuration")`. Apps see a clear 502 with a + diagnostic that points at the fix. + +So Fastly's static `outbound-http = Native` describes **adapter** support; achieving +runtime success additionally requires the service-side toggle. The capability matrix is +a static contract over adapter behaviour, not a runtime health guarantee for a deployed +service — this distinction is explicit so a green capability check is not misread. + + + +### 4.4 Spin — `crates/edgezero-adapter-spin` + +- `SpinProxyClient` → `SpinOutboundClient` (stays stateless). +- `send_all` first runs a **preflight** per slot: any request with `Body::Stream` + OR `response_mode = Streamed` is converted to `Err(EdgeError::bad_request(..))` + per §3.1.1 *before* `send_one` is invoked. **`send_all` snapshots `let batch_now = + web_time::Instant::now()` once** before fanning out and passes it to every + `send_one(req, batch_now)`. Buffered-mode buffered-body survivors are fanned out + via `join_all` over `send_one` (`spin_sdk::http::send`); the wasi async reactor + fans out. Concurrency materialises only under the real Spin/wasi executor — see + §5.3 for the test consequence. +- `send_one(req, now)`: build a `spin_sdk::http::Request`; compute the budget via the + core helper `dispatch_budget(req, now)` (§3.3.2); race the **whole** operation + (send **and**, in `Buffered` mode, body collect) against a wasi monotonic-clock + timer for **`budget.deadline.remaining()` at the moment the race starts** — + *not* the snapshot-time `budget.duration`. The two differ by however long + preflight + builder construction took since `batch_now`; using `remaining()` + pins the SDK timer to the absolute batch deadline, matching Axum/CF (§4.1 / + §4.2 step 3). If `remaining()` is `None`, return `gateway_timeout` without + issuing the request. Single `send` snapshots `now = web_time::Instant::now()` + inline. +- **Streamed responses honour the effective-budget deadline.** Wrap the response body as + `Body::Stream`, with a per-chunk race against a wasi monotonic-clock timer bounded + by `budget.deadline`; the wrapper yields a `gateway_timeout` error chunk past the + deadline so the streamed body honours the deadline end-to-end per §3.3.3. +- **Streamed request bodies.** Spin/WASI outgoing-body supports streamed writes; the + adapter feeds chunks from `Body::Stream` to the WASI outgoing-body up to + `req.max_request_body_bytes` (default 8 MiB), with pre-append checked accounting and + `bad_request` on overflow. **Two distinct races bound the upload** so + `streamed-upload-deadlines = Native` is real on Spin (not just claimed): + 1. *Source-pull race*: `futures::select!` between `source_stream.next()` and a + wasi monotonic-clock timer for `budget.deadline.remaining()`. A + `stream.next().await` that never yields is preempted at the deadline and the + slot returns `gateway_timeout` — this is what makes Spin Native for the + capability (vs. Fastly BestEffort, which cannot preempt this path, §4.3). + 2. *Host-write race*: WASI `output-stream` is **nonblocking + readiness-polled**, + not blocking. For each chunk the adapter has from the source, the + implementable pattern is: + a. obtain the stream's `subscribe()` pollable (one `Pollable` per + `OutputStream`); + b. `futures::select!` the pollable's ready signal against a wasi + monotonic-clock timer for `budget.deadline.remaining()`; + c. if the timer fires first → drop the outgoing handle and return + `gateway_timeout`; + d. if the pollable fires first → call nonblocking `check_write()` to obtain + the **permitted byte count** the stream can accept now, then call + `write(bytes)` with at most that many bytes; loop until the current + chunk is fully written, repeating step (a) when `check_write()` returns + zero or the chunk has remainder. + + A slow host that never accepts bytes therefore stalls at the pollable + subscription, where the timer preempts at the deadline — `gateway_timeout` + within one timer-resolution tick of `budget.deadline`, not unbounded. + `write()` itself never blocks on host I/O so there is no in-progress + `write()` to race against the timer. + After upload completion the adapter calls `budget.deadline.remaining()`; if + `None`, the outgoing handle is dropped and the slot returns `gateway_timeout` + immediately — no response wait. Otherwise the remaining duration governs the + response race, so upload time is included in the batch budget rather than + added on top. +- Existing gzip/br decompression is kept; decompressed-byte cap enforced incrementally + (§3.4.1). `Streamed` mode wraps the response body as `Body::Stream`. +- Spin requires `allowed_outbound_hosts`; the adapter renders it from + `[capabilities.outbound].hosts` per §3.5.4 when generating `spin.toml`. +- `capability()` per §3.5.2: `Native` for **all nine** capabilities. Spin's wasi + monotonic-clock timer covers `outbound-deadlines` and `streamed-upload-deadlines`; + the single wasi-timer race is one total budget (no per-phase split), so + `outbound-flexible-phase-budget` is `Native` too; the WASI outgoing-body sink + accepts a non-Send stream so `lazy-streamed-response-passthrough` is `Native`; + and `join_all` of `spin_sdk::http::send` futures fans out body drains + concurrently so `send-all-slot-isolation` is `Native`. `config-store` / `kv-store` + / `secret-store` are `Native` for Spin too. + +## 5. Test plan + +CLAUDE.md forbids tests needing a network connection or platform credentials. "Network" +means the public internet — a **locally spawned mock origin** is allowed and is how +concurrency and timing are proven. Tests are tiered. + +### 5.1 Tier 1 — core contract suite + +Location: `crates/edgezero-core/src/outbound.rs` `#[cfg(test)]`, plus a +`MockOutboundClient` exposed behind the existing `test-utils` feature. Runs on native and +wasm targets; async tests use `futures::executor::block_on`. + +`MockOutboundClient` is scripted per request: status, headers, body, byte size, simulated +failure, simulated latency, and compressed-payload simulation. It validates the **shared** +logic — `send_all` aggregation, index alignment, `send_all(vec![])` → `vec![]`, +partial-failure isolation, deadline cutoff, decompressed-byte cap, error mapping, non-2xx +passthrough, URI validation, fallible header construction. + +### 5.2 Tier 2 — per-adapter translation tests + +Location: `tests/contract.rs` in each adapter crate (**created for Axum**; extended for +the other three). No network. Covers request→platform and platform→response conversion, +header preservation, non-2xx mapping, buffered vs. streamed body handling, and +compressed-body decompression, using each adapter's existing harness (`#[tokio::test]`, +`#[wasm_bindgen_test]`, `block_on`). + +### 5.3 Tier 3 — per-adapter live behaviour + +Proves real fan-out and timing against a locally spawned mock origin. + +- **Axum** — implemented now. A `tokio` mock server with configurable per-route delay, + body size, compression, and chunk pacing. +- **Fastly** — a Viceroy-run test with a backend pointed at the local mock origin. +- **Cloudflare** — a `workerd`/miniflare integration test against the local mock origin. +- **Spin** — a `spin`-runtime test against the local mock origin; the only place Spin's + `join_all` concurrency runs under the real wasi executor (bare `block_on` will not fan + out). + +Each wasm Tier 3 test is a dedicated CI job. Axum's lands with the implementation; the +three runtime-backed jobs land as the matching runtimes are wired into CI. Until then, +that adapter's behaviour is still covered by Tier 1 (logic) and Tier 2 (translation); the +gap is the live wall-clock/timing proof only, and it is tracked, not silently skipped. + +Reference concurrency assertion (Axum): + +```rust +#[tokio::test] +async fn send_all_runs_requests_concurrently() { + let server = MockServer::start_with_delay(Duration::from_millis(200)).await; + let client = AxumOutboundClient::try_new().unwrap(); + let reqs: Vec<_> = (0..10) + .map(|_| OutboundRequest::get(server.url("/")).unwrap()) + .collect(); + + let start = web_time::Instant::now(); + let results = client.send_all(reqs).await; + let elapsed = start.elapsed(); + + assert!(results.iter().all(Result::is_ok)); + assert!(elapsed < Duration::from_millis(800), "fan-out not concurrent: {elapsed:?}"); +} +``` + +### 5.4 Required test cases → tiers + +| Test case | Tier 1 | Tier 2 | Tier 3 | +| --- | --- | --- | --- | +| One outbound request | yes | yes | — | +| Many concurrent outbound requests (wall-clock ≪ sum) | aggregation | — | yes | +| Empty `send_all(vec![])` → empty vec | yes | — | — | +| Response body buffering (`Buffered` mode) | yes | yes | — | +| Streamed response body passthrough (`Streamed` mode) | yes | yes | yes | +| Max response size exceeded → 502 | yes | yes | — | +| Compressed body expands past cap → 502 (decompressed count) | yes | yes | yes | +| Slow streaming body vs. deadline (bounded overshoot) | — | — | yes | +| Headers arrive, deadline expires during body buffering → 504 | — | — | yes | +| Per-request timeout / batch deadline exceeded → 504 | logic | — | yes | +| Partial timeout: one slot 504s, other slots still `Ok` | yes | — | yes | +| Headers preserved (request and response) | yes | yes | — | +| Non-2xx returned as `Ok`, not a transport error | yes | yes | — | +| Invalid outbound URI rejected → 400 | yes | — | — | +| Fallible header construction surfaces `EdgeError` | yes | — | — | +| Streamed request body in `send_all` → per-slot `bad_request` (400) | yes | yes | — | +| Streamed request body in `send` (proxy-forward) succeeds | yes | yes | yes | +| `send(buffered_req)` ≡ `send_all(vec![buffered_req]).pop()` — equivalence over status, headers, body cap, deadline classification, decompression, error mapping | yes | yes | — | +| 3xx upstream response delivered as `Ok` with `Location` (no auto-follow) | yes | yes | yes | +| Non-UTF-8 outbound request header rejected at construction → 400 | yes | — | — | +| Non-UTF-8 upstream response header value dropped with `warn!` diagnostic, **valid sibling values preserved** (multi-value `set-cookie` with one invalid duplicate keeps every valid entry) | yes | yes | — | +| `OutboundRequest::header(name, "café")` (valid non-ASCII UTF-8) succeeds — builder uses `HeaderValue::from_bytes`, not `from_str` | yes | yes | — | +| `OutboundRequest::header(name, "foo\nbar")` and `header(name, "x\0y")` (valid UTF-8 strings with HTTP-forbidden control bytes) → `bad_request("header value contains forbidden bytes: ")`. Tests both header-injection vectors (newline / null) explicitly | yes | yes | — | +| `OutboundResponse::into_bytes_bounded_until` (streamed) — **helper-cooperative half (Tier 1):** the helper's `is_expired()` check fires before/after each underlying read against a `MockOutboundClient` stream that simulates a slow source; once `until_deadline` is expired and the next yield boundary is hit, the helper returns 504. Asserts cooperative-only contract per §3.1.4 — no wrapper insertion, no platform timer | yes | — | — | +| `OutboundResponse::into_bytes_bounded_until` (streamed) — **adapter wrapper half (Tier 2 / Tier 3):** the deadline-aware wrapper the adapter installs at response construction time (Axum tokio / CF `worker::Delay` / Spin wasi monotonic-clock / Fastly bounded-cooperative between-bytes-timeout) returns a `gateway_timeout` error chunk past `dispatch_budget(req).deadline` in real time, so a slow source preempts via the wrapper rather than the helper. Asserts wrapper insertion at the response-conversion boundary in each adapter crate | — | yes | yes | +| Streamed body stalls after one chunk; deadline expires → wrapped stream yields error chunk on Axum/CF/Spin; bounded overshoot on Fastly. **Adapter-specific** — the wrapper insertion and platform timer behaviour live in each adapter's response converter; Tier 1's `MockOutboundClient` has no wrapper layer to test. The corresponding cross-adapter contract (helper returns 504 on stall, slot index preserved) is covered by the helper-cooperative row above | — | yes | yes | +| `normalize_for_dispatch` strips `host`, `content-length`, `transfer-encoding`, hop-by-hop on a `headers_mut()`-built request | yes | yes | — | +| Multi-value response headers preserved (e.g. duplicate `set-cookie`) | yes | yes | yes | +| Multi-value outbound request headers preserved on the wire | yes | yes | yes | +| Inbound body: adapter exposes `Body::Stream`; `body_bytes(max)` drains and caches; second call returns clone without re-reading | yes | yes | — | +| Required `BestEffort` capability → **every adapter-selecting CLI command** (`edgezero build`, `edgezero serve`, `edgezero deploy`, `edgezero auth login` / `logout` / `status`, `edgezero provision`, `edgezero config push` / `config validate`, `edgezero demo`) exits non-zero with a clear message — matches the §3.5.3 enforcement set (PR #269: pre-dispatch gate inside `execute(..)` for `build`/`serve`/`deploy`/`auth`, plus sibling gates at the top of `run_provision`, `run_config_push`, `run_config_validate`, and `run_demo`). `edgezero dev` is gone; `demo` is its contributor-only replacement | yes | — | — | +| Axum response converter mapping for a wrapped streamed body: `Err(GatewayTimeout)` chunk during buffered drain → axum response **504**; `Err(BadGateway)` chunk → **502**; over-cap → **502**; `Ok` chunks under cap append normally. The buffering boundary lets Axum preserve the correct status code (no silent coalesce to 502) | — | yes | yes | +| `OutboundRequest::into_parts` / `OutboundResponse::new` / `OutboundResponse::into_parts` round-trip every field (adapter API completeness) | yes | yes | — | +| `body_bytes` cap exceeded → subsequent `body_bytes` / `json_within` / `form_within` calls return the same stored error (poison semantics); `into_request()` returns `Err(stored_err)` (per §3.4.5 round-18 / round-19 — **not** an empty body) | yes | yes | — | +| `into_request()` after middleware buffered body yields `Body::Once(cached)` (proxy-forward still works) | yes | yes | yes | +| Multi-value `set-cookie` round-trips through every adapter's response path (`get_header_all` on Fastly; not `get`) | — | yes | yes | +| Multi-value outbound request header round-trips through every adapter's request path (`append_header` on Fastly; `Headers::append` on CF; WASI `fields` on Spin) | — | yes | yes | +| `DEFAULT_NO_DEADLINE_BUDGET` core constant (Tier 1): `dispatch_budget(no-deadline-no-timeout-request, now)` returns `DispatchBudget { duration: 30 s, deadline: now + 30 s }` per §3.3.2 table. Pure core-logic assertion on the helper, no adapter | yes | — | — | +| Axum no-deadline request budgeted at 30 s end-to-end (Tier 2 / Tier 3): with a real Axum dev server + mock origin, a request without `timeout`/`deadline` actually times out at 30 s via the adapter's wrapper. Adapter-specific wall-clock behaviour | — | yes | yes | +| `OutboundResponse::json_bounded(max)` / `json_bounded_until(max, deadline)` on a streamed body — **helper-cooperative half (Tier 1):** the helpers delegate to `into_bytes_bounded` / `into_bytes_bounded_until` then `serde_json::from_slice`; mock-driven test asserts the helper's cap + cooperative `until_deadline` check + malformed-JSON → 502 mapping. No wrapper insertion | yes | — | — | +| `OutboundResponse::json_bounded_until(max, deadline)` adapter-wrapper half (Tier 2 / Tier 3): the wrapper installed at response construction enforces `dispatch_budget(req).deadline` in real time on Axum / CF / Spin; the caller-supplied `deadline` argument is cooperative only (§3.1.4). Asserts wrapper insertion preserves the JSON outcome | — | yes | yes | +| Streamed body honours `dispatch_budget(req).deadline` end-to-end on Axum/CF/Spin via wrapped stream (including the no-`req.deadline` synthetic-30 s case); bounded-cooperative on Fastly. **Adapter-specific** — the wrapper is installed per-adapter at response-conversion time; Tier 1's mock has no wrapper layer. The cross-adapter contract (`EdgeError::gateway_timeout` chunk past the deadline) is the same row as the cooperative `into_bytes_bounded_until` Tier 1 assertion | — | yes | yes | +| `BodyState::Draining`: drain future dropped mid-flight → cell becomes `Poisoned(cancelled)`; next `body_bytes` returns the stored cancelled error | yes | yes | — | +| Reentrant `body_bytes` while `Draining` returns `Err(EdgeError::internal(..))` without panic | yes | — | — | +| Pre-append cap accounting: a single oversized chunk on a small cap errors **without extending the collected buffer past `max`** (the in-flight chunk briefly co-exists with the buffer during the overflow check, per §3.4.1 / §3.4.4 — the test asserts the *persistent* buffer never grows past `max`, not that the in-flight `current_chunk` is never received). Inbound and outbound bounded drains both covered | yes | yes | — | +| `Form` / `ValidatedForm` migrated to `form_within(DEFAULT_INBOUND_FORM_BYTES = 1 MiB)`; over-cap → 400 | yes | yes | — | +| Adapter `dispatch_budget(req)` everywhere: each adapter calls the core `dispatch_budget(req, now)` helper and threads the resulting `DispatchBudget` to its platform timer. The **core helper** is Tier 1 (covered by the row above); the "every adapter actually calls it" assertion is Tier 2 (contract crate inspects the call site) / Tier 3 (real runtime observes the 30 s cap) | — | yes | yes | +| `.timeout(short).deadline(long)` honours the *shorter* effective — **dispatch_budget classification (Tier 1):** the core helper returns `DispatchBudget { duration: short, deadline: now + short }`. Mock-driven test asserts the classification | yes | — | — | +| `.timeout(short).deadline(long)` honours the *shorter* effective deadline end-to-end (streamed body returns 504 at `now + short`, not `now + long`) — **adapter wrapper (Tier 2 / Tier 3):** wrapper armed with `budget.duration` actually fires at `now + short` against a real platform timer | — | yes | yes | +| Streamed request body over `max_request_body_bytes` → per-slot `bad_request` (400) on every adapter | yes | yes | — | +| Stalled streamed-request-body upload, mechanics differ per adapter — this row is **Tier 2/3 only** because Tier 1's `MockOutboundClient` cannot prove the Axum tokio / Cloudflare `worker::Delay` / Spin WASI-readiness / Fastly host-timer behaviour; Tier 1 covers the cross-adapter *contract* (504 on stall, index alignment) via the mock, marked separately. **Axum / Cloudflare** drain `Body::Stream` into `Bytes` **before** constructing the platform request (§4.1 / §4.2), so the relevant stall is the *source-pull* during the drain — tokio / `worker::Delay` races it against `budget.deadline` and returns 504 at the deadline (no separate "host-write" race because by the time the SDK request is constructed the body is already in hand). **Spin** has both phases explicit per §4.4: (a) source-pull race — `futures::select!` of `source_stream.next()` against a wasi monotonic-clock timer for the remaining deadline; (b) host-write race — subscribe to the WASI output-stream readiness pollable, race the pollable's ready signal against the wasi timer, then call nonblocking `check_write()` to obtain the permitted byte count and `write()` within that bound (WASI output streams are nonblocking / readiness-polled). Both phases return 504 at the deadline. **Fastly** has a single phase where source-pull cannot be preempted (BestEffort per `streamed-upload-deadlines`); the cooperative `budget.deadline.is_expired()` check **between** chunks is the only adapter-side bound, and Fastly's `between_bytes_timeout` is documented as receive-side only — it does **not** bound guest-to-origin writes (BestEffort for the write phase too, no per-chunk-gap claim). The slot returns 504 at the next inter-chunk check after `budget.deadline` expires. Test asserts per-adapter mechanics | — | yes | yes | +| Stalled streamed-request-body upload **contract only** (Tier 1, via `MockOutboundClient` with scripted stalls): on the **preemptible-source** adapters (Axum / Cloudflare / Spin) a stalled upload returns `Err(EdgeError::gateway_timeout(..))` to the caller within the configured deadline, slot index alignment is preserved, and other slots are unaffected. **Fastly is excluded from the "within the configured deadline" half of this contract** because `streamed-upload-deadlines` is `BestEffort` on Fastly (§3.5.1 / §3.5.2): a source-pull stall (`stream.next().await` that never yields) is unbounded on Fastly per §4.3, so Tier 1 cannot assert wall-clock containment there. Fastly still observes the index-alignment + partial-failure-isolation half of the contract. The `MockOutboundClient` sets the adapter under test on the mock so this row's Fastly invocation skips the wall-clock assertion and runs only the structural assertions. Mechanics-level wall-clock assertions for all four adapters (including Fastly's `BoundedCooperative` between-chunk bound) live in the Tier 2/3 row above | yes | — | — | +| `body_bytes` / `json_within` / `form_within` after `take_body()` → `internal("body already consumed via take_body")` (no body resurrection) | yes | — | — | +| Valid non-ASCII UTF-8 header (e.g. `x-app-display-name: café`) round-trips through every adapter on request and response | yes | yes | yes | +| Header containing a `\x80` byte is rejected on outbound request (400) and dropped on inbound-of-outbound response with a `warn!` naming the header | yes | yes | — | +| RFC 7230 hop-by-hop strip removes `trailer` (singular) end-to-end; an inbound `trailer: foo` never reaches the outbound wire | yes | yes | — | +| Fastly `send` with `Body::Stream` request body: over `max_request_body_bytes` mid-upload → 400; stalled upload **between** yielded chunks (next cooperative `budget.deadline.is_expired()` check fires) → 504 within one chunk-iteration of `budget.deadline`; stalled `stream.next()` AND stalled in-progress `StreamingBody::write_all` are **both BestEffort gaps** on Fastly (no preemption, and `between_bytes_timeout` is documented as *receive-side only* — it does not bound guest-to-origin writes); upload time reduces remaining budget for response. **Adapter-specific mechanics (cooperative inter-chunk check, source-pull and host-write non-preemption) live in Tier 2 / Tier 3 only** — Tier 1's `MockOutboundClient` cannot reproduce Fastly's chunk-iteration timing | — | yes | yes | +| `dispatch_budget(req)` table: every row of §3.3.2 holds (timeout-only, deadline-only, both, expired, zero-effective, no-deadline-no-timeout) | yes | — | — | +| Fastly `send_all` with mixed budgets, **headers phase**: short-budget slot's *headers* result reflects its own budget (host enforces independently); but its wall-clock-observed *delivery* can be delayed behind an earlier `wait()` (harvest order). **Adapter-specific** — harvest order and per-slot host-timer behaviour belong to Tier 2 (Fastly contract crate) and Tier 3 (Viceroy) | — | yes | yes | +| Fastly `send_all` Buffered mode, **body phase**: a slot whose own `budget.deadline` would have covered its body in isolation can still return `gateway_timeout` because an earlier slot's body drain monopolised harvest. The contract explicitly admits these harvest-order-induced 504s on Fastly Buffered. **Adapter-specific harvest mechanics** — Tier 1's mock has no harvest queue and cannot reproduce the head-of-line block; covered by Tier 2 (deterministic harvest ordering against a host-side fake) and Tier 3 (Viceroy wall-clock) | — | yes | yes | +| `[capabilities] required = ["send-all-slot-isolation"]` on a Fastly target → **every adapter-selecting CLI command** (`build` / `serve` / `deploy` / `auth` / `provision` / `config push` / `config validate` / `demo`) exits non-zero with the BestEffort + required hard-fail message via the §3.5.3 pre-dispatch gates (one inside `execute(..)`, siblings on `run_provision` / `run_config_*` / `run_demo`, PR #269); same manifest on Axum/CF/Spin passes | yes | — | — | +| Fastly mixed-budget `send_all` to the **same host**: slots with `50 ms` and `3 s` budgets create **distinct** dynamic backends (identity tuple includes `budget_ms`); the 50 ms slot's host timeout is not silently inherited by the 3 s slot or vice versa. **Asserts the Fastly identity tuple** — Tier 1's mock has no dynamic-backend abstraction; Tier 2 (Fastly contract crate) inspects the registered-backend map and Tier 3 (Viceroy) observes the wall-clock divergence | — | yes | yes | +| `RequestContext::into_request()` after `body_bytes` poison: returns `Err(stored_err)`, not `Ok(Request)` — a permissive proxy-forward cannot mask a stricter middleware's poisoned read | yes | — | — | +| Fastly + `outbound-http = required`: `ensure_capabilities` emits the dynamic-backends informational log | yes | — | — | +| Fastly `Backend::builder().finish()` returns a non-`NameInUse` error (dynamic backends disabled on the service; DNS resolution failure; TLS misconfiguration; any other Fastly-side rejection reaching the guest): adapter maps to **`EdgeError::bad_gateway(..)` (502)**, NOT `internal`. Tests cover each branch via a host-side fake / Viceroy harness | — | yes | yes | +| Fastly `EdgeError::internal` is reserved for **adapter contract bugs only** — not service/backend setup failures. The test inspects the error chain for each Fastly `Err` and asserts that `internal` appears only for: (a) `BATCH_DISPATCH_SLACK_MAX` overshoot, (b) `NameInUse` external-registration collision, (c) the unfilled-slot harvest invariant. Every other Fastly error path is `bad_gateway`, `gateway_timeout`, or `bad_request` | — | yes | yes | +| `Deadline::after(Duration::MAX)` clamps to `DEADLINE_FAR_FUTURE = 7 days` (round 24, down from 365 d to stay under Fastly's u32-ms ceiling); subsequent `dispatch_budget` round-trip still produces a usable budget; no panic | yes | — | — | +| Inbound body `form_within(max)` over-cap → 400; cache + poison behaviour identical to `body_bytes` / `json_within` | yes | yes | — | +| Required `streamed-upload-deadlines` on Fastly → hard build failure (BestEffort + required, per §3.5.3) | yes | — | — | +| Upload consumes the budget — **contract shape (Tier 1, Axum / Cloudflare semantics only):** the cross-adapter contract that `budget.deadline.remaining()` is consulted after the upload drain completes, and that `None` returns `gateway_timeout` *without* dispatching the platform request, is asserted against `MockOutboundClient` configured in **drain-first** mode (the Axum / Cloudflare shape — drain into `Bytes` first, then dispatch). The mock exposes a `did_dispatch()` flag and the assertion is "deadline expired during drain → 504 returned AND `did_dispatch() == false`." **This row covers Axum / Cloudflare only**; Spin and Fastly are explicitly excluded because their adapters dispatch concurrently with (or before) the upload drain and the §3.1.1 contract documents partial upstream sends as possible / expected on those adapters — see the per-adapter Tier 2 / Tier 3 rows below. The mock's drain-first mode is a property of the test harness, not a cross-adapter contract; the Tier 1 row asserts only what the Axum / Cloudflare adapters guarantee | yes | — | — | +| Upload consumes the budget on **Axum** / **Cloudflare** — **adapter mechanics (Tier 2 / Tier 3):** the adapter drains the streamed request body into `Bytes` *before* constructing the platform request, so `budget.deadline.remaining() == None` after the drain → adapter returns `gateway_timeout` **before** constructing/sending the actual `reqwest`/`worker` request. No partial upstream send. Asserted via `crates/edgezero-adapter-{axum,cloudflare}/tests/contract.rs` (Tier 2: inspect the platform-SDK send-call counter on a fake / no-network harness) + Tier 3 against a mock origin (the origin observes zero connections from the timed-out slot) | — | yes | yes | +| Upload consumes the budget on **Spin** — **adapter mechanics (Tier 2 / Tier 3):** the adapter feeds chunks to the WASI outgoing-body; after the upload completes, `budget.deadline.remaining()` is checked. If exhausted, the response future is dropped → `gateway_timeout`. **Partial upstream send is possible** because chunks were flowing — distinct from Axum / Cloudflare. Asserted via the Spin contract crate (Tier 2: WASI outgoing-body chunk-count observation) + Tier 3 against a mock origin under the real Spin runtime (origin observes the partial upload) | — | yes | yes | +| Upload consumes the budget on **Fastly** (`send_async_streaming`): dispatch happens **before** chunks flow, so request bytes have already started reaching the upstream by the time the budget is exhausted. Adapter detects `budget.deadline.remaining() == None`, drops the `StreamingBody` and `PendingRequest` without `wait()`, and returns `gateway_timeout`. **Partial upstream send is expected** — the documented Fastly-specific limitation of streamed uploads. The test asserts this contract honestly. **Adapter-specific** — the `send_async_streaming` + `wait()`-drop sequence is Fastly SDK behaviour Tier 1's mock has no analogue for; covered by Tier 2 (Fastly contract crate) and Tier 3 (Viceroy) | — | yes | yes | +| Fastly streamed-upload **tiny-positive-remainder edge case** — the upload drain completes with `budget.deadline.remaining() == Some(small)` (say 10 ms left out of a 200 ms budget). The cooperative check at the `wait()` boundary passes (remaining is positive), and the host then waits up to the dispatch-time `first_byte_ms` (150 ms in this example, 3/4 of `budget.duration`) for the upstream's response headers. The test asserts (a) total wall-clock from dispatch to return is bounded by `budget.duration + first_byte_ms + between_bytes_timeout` (closed-form, **not** per-chunk-accumulating), (b) the response wrapper's `is_expired()` check preempts after the first body chunk read returns rather than waiting another `between_bytes_timeout` per chunk, (c) the slot ultimately returns `gateway_timeout` with a `partial_send = true` diagnostic in the error chain. Fastly-specific (response-phase overshoot is the documented behaviour of `send_async_streaming`); Tier 2 (contract crate, time-injection hook) + Tier 3 (Viceroy wall-clock observation) | — | yes | yes | +| `batch_deadline = Deadline::after(batch_deadline_ms)` computed once and copied into every target request → all targets share one absolute wall-clock cap (no drift); recomputing `Deadline::after(batch_deadline_ms)` per target would let later targets drift past the batch deadline (counter-example test) | yes | — | yes | +| Outbound request header from `headers_mut()` containing a non-UTF-8 value is **dropped with `warn!`** by `normalize_for_dispatch` (lossy proxy-forward path) — distinct from `header(..)` which **rejects** with 400 (loud construction path) | yes | yes | — | +| Adapter response-out converter (`response.rs`) on **CF / Spin**: `OutboundResponse::into_response()` with a streamed body yields first bytes before the upstream stream ends (no buffer-then-return); driven by a `MockOutboundClient`-fed stream in-process, no platform runtime needed. **Fastly is excluded from this row** — `Response::stream_to_client()` is incompatible with `#[fastly::main]` (capability footnote 6), so Fastly's converter falls back to buffered passthrough (see the Axum/Fastly row below) | — | yes | yes | +| Adapter response-out converter on **CF / Spin**: stream errors after headers **abort the downstream response stream** — once headers have been written, HTTP cannot change status to 502/504, so the adapter aborts the chunked body (TCP close on HTTP/1.1, RST_STREAM on HTTP/2) and emits a `log::warn!` naming the originating `EdgeError` variant (`gateway_timeout` or `bad_gateway`). Clients observe an early connection close, not a synthetic 502/504. The originating EdgeError is in the server log. **Fastly is excluded** because it never reaches "headers already written" — its buffered fallback materialises the whole body before the response is returned via `#[fastly::main]`, so a mid-stream error becomes a clean 502/504 in the buffered drain | — | yes | yes | +| Adapter response-out converter buffered fallback on **Axum and Fastly**: streamed body is buffered to `Bytes` within the adapter-level constant (`AXUM_RESPONSE_STREAM_BUFFER_BYTES` on Axum, `FASTLY_RESPONSE_STREAM_BUFFER_BYTES` on Fastly — both default 16 MiB, documented adapter-specific limitations). First bytes only flow after full collection. Over-cap → 502. The per-outbound-request `max_response_bytes` is unavailable by the time the converter runs (`OutboundResponse` carries only status / headers / body); the adapter-level constant is what the converter uses. Apps needing lazy passthrough declare `lazy-streamed-response-passthrough` required and target CF / Spin (both adapters Native; Axum + Fastly BestEffort) | — | yes | yes | +| `Deadline::after(d)` and `dispatch_budget`'s `saturating(d)` clamp at `DEADLINE_FAR_FUTURE` (7 d) — `Duration::MAX` does not panic, never produces an `Instant` past the clamp, and `fastly_timeout_ms` of the clamped value fits within Fastly's `u32` ms ceiling without rejection | yes | yes | — | +| `OutboundRequest::is_stream_body()` returns `true` for `Body::Stream` requests and `false` for `Body::Once`; `send_all` preflight uses this to reject without consuming | yes | — | — | +| `OutboundRequest::is_stream_response()` returns `true` for `stream_response()`-marked requests; `send_all` preflight uses this to reject with `bad_request` without consuming, on every adapter | yes | yes | — | +| `send_all` with `stream_response()` returns per-slot `bad_request` (400) on every adapter; single `send` with the same request succeeds (streamed bodies are only valid via `send`) | yes | yes | — | +| `[capabilities.outbound].hosts` validation: rejected — empty string, `ftp://x` (bad scheme), `https://` (missing authority), `https://u:p@x` (userinfo), `https://x/p` (path), `https://x?q` (query), `https://x#f` (fragment), `https://x:0` and `https://x:70000` (out-of-range port), `https://x:abc` (non-numeric port). Accepted — `"*"`, `"*.example.com"`, `"x:8443"`, `"https://[::1]"`, `["*", "api.example.com"]`. Manifest load surfaces every error before the build | yes | — | — | +| `send_all` shared-`now` snapshot: a homogeneous-budget Fastly fan-out batch to one host creates **exactly one** dynamic backend (per the §4.3 identity guarantee); replacing `batch_now` with per-slot `Instant::now()` in a test fork creates distinct backends, catching the drift bug. **Asserts Fastly-specific identity tuple including `budget_ms`** — Tier 1's `MockOutboundClient` has no dynamic-backend abstraction, so this row is Tier 2 (Fastly contract crate) + Tier 3 (Viceroy) only | — | yes | yes | +| Outbound `Host` header includes the explicit port for non-default-port URIs: `http://localhost:3000` → `Host: localhost:3000`; `https://example.com:8443` → `Host: example.com:8443`; `https://example.com` → `Host: example.com` (no port). Adapters never copy `host` from the inbound `req.headers()` | yes | yes | yes | +| **Core URI canonicalization → four-value split (Tier 1 half).** The four accessors `backend_target()` / `host_authority()` / `sni_hostname()` / `cert_host()` are tested in `crates/edgezero-core/src/outbound.rs` `#[cfg(test)]` against a matrix of inputs, with per-scheme expectations (no adapter dependency). **HTTPS DNS-host inputs** (`https://example.com`, `https://example.com:443`, `https://example.com:8443`): `backend_target() == "example.com:443"` / `"example.com:443"` / `"example.com:8443"`; `host_authority() == "example.com"` / `"example.com"` / `"example.com:8443"`; `sni_hostname() == Some("example.com")` on all three; `cert_host() == Some("example.com")` on all three. **HTTPS IP-literal inputs** (`https://127.0.0.1`, `https://[::1]:8443`): `sni_hostname() == None` (RFC 6066 §3); `cert_host() == Some("127.0.0.1")` / `Some("::1")` (bracket-stripped). **HTTP DNS-host inputs** (`http://example.com`, `http://example.com:80`, `http://example.com:8443`): `backend_target() == "example.com:80"` / `"example.com:80"` / `"example.com:8443"`; `host_authority() == "example.com"` / `"example.com"` / `"example.com:8443"`; `sni_hostname() == None` (no TLS, no SNI); `cert_host() == None` (no TLS, no certificate). The HTTPS-only `cert_host()` `Some` is the canonical reason an adapter calls `.disable_ssl()` vs `.enable_ssl()` / `.check_certificate(..)`. This is the core-side guarantee the Fastly row below assumes | yes | — | — | +| **Fastly adapter consumes the four canonical accessors, DNS-name HTTPS path (Tier 2 / Tier 3 half).** For a DNS-name HTTPS host where `req.sni_hostname()` returns `Some(sni)` and `req.cert_host()` returns `Some(cert)`, Fastly dynamic backend construction calls `Backend::builder(name, req.backend_target()).override_host(req.host_authority()).sni_hostname(sni).check_certificate(cert)` (with `sni == cert` because both accessors return the same host string for the DNS-name case). For HTTP (`req.cert_host()` returns `None`), it calls `Backend::builder(name, req.backend_target()).override_host(req.host_authority()).disable_ssl()`. A Tier 2 test (`crates/edgezero-adapter-fastly/tests/contract.rs`, no network — inspects the registered-backend map produced by `FastlyOutboundClient`) and a Tier 3 test (Viceroy round-trip) build `https://example.com:8443` and `http://example.com:8443` and assert: connection target = `example.com:8443` on both; Host = `example.com:8443` on both; SSL enabled with SNI = cert = `example.com` on the first, disabled on the second; identity hashes differ (distinct backends). **DNS-name HTTPS only** — IP-literal HTTPS (where `sni_hostname()` is `None` but `cert_host()` is `Some(ip)`) is the dedicated "Fastly HTTPS to IP literals" row below, which asserts the **distinct** behaviour of skipping `.sni_hostname(..)` while still passing `cert_host()` to `.check_certificate(..)`. **Adapter-specific** — Tier 1's mock has no `Backend::builder` analogue | — | yes | yes | +| URI canonicalization — **core accessor half (Tier 1):** `OutboundRequest::get("https://example.com")` and `OutboundRequest::get("https://example.com:443")` produce identical `backend_target()` / `host_authority()` / `cert_host()` / `sni_hostname()` outputs (`"example.com:443"`, `"example.com"`, `Some("example.com")`, `Some("example.com")` respectively). `http://example.com:80` likewise normalises against `http://example.com`. Explicit non-default ports (`:8443`) are preserved in `backend_target()` and `host_authority()` but stripped from `cert_host()` / `sni_hostname()`. Asserted in `crates/edgezero-core/src/outbound.rs` `#[cfg(test)]` — no adapter | yes | — | — | +| URI canonicalization — **Fastly backend identity half (Tier 2 / Tier 3):** building the canonical inputs above through the Fastly adapter yields **one dynamic backend** per canonical tuple — the identity hash collapses `https://example.com` and `https://example.com:443` into the same `Backend` entry in the registered-backend map. Tier 2 inspects the map; Tier 3 (Viceroy) observes the single backend across both URI spellings | — | yes | yes | +| URI scheme + host case normalisation — **core accessor half (Tier 1):** `OutboundRequest::get("https://EXAMPLE.com")`, `OutboundRequest::get("HTTPS://example.com")`, and `OutboundRequest::get("https://example.com")` produce identical `uri().host()`, `uri().scheme()`, `backend_target()`, `host_authority()`, and `cert_host()` outputs (all lowercase). Path / query are case-preserving (fragments are rejected upstream — round 29). Asserted in core | yes | — | — | +| URI scheme + host case normalisation — **Fastly identity half (Tier 2 / Tier 3):** same canonical inputs produce identical Fastly backend identity across the three case variants — one registered backend, same identity hash | — | yes | yes | +| `OutboundRequest::get("https://example.com/p#anchor")` and `::post(..)` return `bad_request("outbound URI must not contain a fragment")` — fragment detected on the raw input string *before* `http::Uri` truncates at `#`. `OutboundRequest::new(method, uri)` accepts a `Uri` that has already lost the fragment (documented asymmetry per §3.1.3) | yes | — | — | +| Capability enforcement: a manifest requiring `lazy-streamed-response-passthrough` causes the **`edgezero demo` runner** (contributor-only, the PR-#269 replacement for the removed `dev` command) to exit non-zero with the Axum BestEffort hard-fail message — via `run_demo(..)`'s sibling pre-dispatch gate against the Axum adapter, *not* via the `execute(..)` path (`demo` does not flow through it). The same hard-fail also fires via `execute(..)`'s pre-dispatch gate on `build` / `serve` / `deploy` / `auth`, and via the `run_config_*` / `run_provision` siblings for those commands. Test asserts every command exits non-zero | yes | — | — | +| `[capabilities.outbound].hosts` Spin render output is canonicalized: `["HTTPS://EXAMPLE.com:443", "api.example.com"]` → rendered `spin.toml` shows `["https://example.com", "https://api.example.com"]` (lowercase scheme/host, default port stripped, default-scheme https for bare hosts) | yes | — | — | +| Fastly `send_all` dispatch-overhead slack hard-bounded: with the adapter's `#[cfg(test)]` injection hook set to `Duration::from_millis(50)`, a `send_all` of N requests returns an `EdgeError::internal` whose message **contains the stable substring `"BATCH_DISPATCH_SLACK_MAX"`** (the full normative diagnostic per §4.3 is `"Fastly send_all adapter overhead between batch_now and SDK arming (preflight + dynamic-backend lookup/creation + SDK setup) exceeded BATCH_DISPATCH_SLACK_MAX; refusing to arm SDK timers with stale duration"`) for the slots dispatched after the cumulative delay crosses `BATCH_DISPATCH_SLACK_MAX` (25 ms). Without the hook, no slot ever returns that error. A handler-side `thread::sleep` before `send_all` is **not** sufficient — it runs before `batch_now` is captured and cannot exercise the guard. Tests assert against the substring, not the full string, so future wording polish doesn't break them. **The hook lives in the Fastly adapter crate**, so this row is Tier 2 (substring assertion in `crates/edgezero-adapter-fastly/tests/contract.rs`) + Tier 3 (Viceroy with hook) — not Tier 1 (Tier 1's `MockOutboundClient` has no SDK arming step to wrap) | — | yes | yes | +| Fastly dispatch+headers phase-budget split **(common case, `total_ms ≥ 4`)**: a single `send` to a target that never returns headers fires the host timeout at `connect_ms + first_byte_ms = budget.duration`, **not** `2 × budget.duration`. Two separate test fakes — one that hangs the TCP connect, one that hangs after request bytes are sent — each return 504 within `budget.duration + BATCH_DISPATCH_SLACK_MAX + ms_rounding` (< 29 + budget ms), never twice the budget. The sub-4 ms degenerate branch is covered by the row below | — | yes | yes | +| Fastly single-`send` dispatch-overhead slack guard: the same `#[cfg(test)]` injection hook used for `send_all` (round 31) also wraps the single-send path between `dispatch_budget` and `send_async`; with the hook set to 50 ms, a single `send` returns `internal("Fastly send adapter overhead between dispatch_budget and SDK arming exceeded BATCH_DISPATCH_SLACK_MAX; …")`. Single send is **not** "structurally 0 slack" — the same hard constant applies (round 38) | — | yes | yes | +| Fastly body-phase EOF deadline: an upstream that sends headers + N-1 chunks within budget but holds the final read so EOF arrives *after* `budget.deadline` returns `gateway_timeout`, not `Ok(resp)`. Buffered drain checks `is_expired()` after every blocking read including EOF; streamed wrapper checks before and after each underlying read so the consumer sees an `Err` chunk instead of clean stream-end | — | yes | yes | +| `OutboundResponse::into_bytes_bounded_until(max, until)` with `until` **tighter** than `dispatch_budget(req).deadline`: the helper drives a streamed body whose adapter wrapper has 500 ms of effective budget left, but the caller passes `until = now + 100 ms`. The upstream sends data for 90 ms then holds the final read; EOF arrives at 110 ms. The helper returns `gateway_timeout` (not `Ok(bytes)`) because its `until_deadline.is_expired()` check fires before and after the EOF read. (`OutboundResponse` carries no effective-deadline state; the wrapper enforces the request budget separately — whichever fires first wins) | — | yes | yes | +| Fastly phase-split trade-off, documented: a 1 s `send` to a target that takes 300 ms to connect and 10 ms to send first-byte **fails** at the `connect_ms = 250 ms` timer (1/4 of budget) even though the entire exchange would have fit within 1 s. This is the explicit deviation §4.3 documents — preferring the absolute-deadline bound over the "every legal slow-connect request succeeds" property. The `outbound-flexible-phase-budget` capability is `BestEffort` on Fastly (§3.5.1 / §3.5.2 footnote 5); apps that need elastic phase budget declare it required and get the hard build failure on Fastly. §8 risk 9 tracks the configurable-split follow-up | — | yes | yes | +| Required `outbound-flexible-phase-budget` on Fastly → every adapter-selecting CLI command (`build` / `serve` / `deploy` / `auth` / `provision` / `config push` / `config validate` / `demo`) exits non-zero with the BestEffort hard-fail message via the §3.5.3 pre-dispatch gates (one inside `execute(..)`, siblings on `run_provision` / `run_config_*` / `run_demo`, PR #269); same manifest on Axum / Cloudflare / Spin passes | yes | — | — | +| Sub-4 ms Fastly budget: `total_ms = 3` produces `connect_ms = first_byte_ms = 3` (sum 6, not 3) by the explicit `total_ms < 4` degenerate branch in §4.3 code. The absolute-deadline bound shifts to 2× total_ms at this scale; ms rounding already dominates so the test asserts ≤ 2× rather than = | — | yes | yes | +| URI userinfo is rejected at construction: `OutboundRequest::get("https://user:pass@example.com")` → `Err(EdgeError::bad_request("outbound URI must not contain userinfo; pass credentials via the `authorization` header"))`. Credentials never reach `override_host` or any platform SDK | yes | — | — | +| Fastly HTTPS to IP literals: `https://127.0.0.1` and `https://[::1]` build dynamic backends with `.enable_ssl().check_certificate("127.0.0.1")` / `.check_certificate("::1")` (brackets stripped) and **skip** `.sni_hostname()` (SNI is DNS-only per RFC 6066). HTTPS to a DNS host still calls both setters. Identity-tuple round-trip works for both | — | yes | yes | + +### 5.5 CI gate impact + +The five existing gates in `CLAUDE.md` still apply by **count and shape** — +`cargo fmt --check`, `cargo clippy ... -D warnings`, `cargo test --workspace +--all-targets`, the feature-combination `cargo check`, and the Spin +`cargo check --target `. `cargo test --workspace --all-targets` now +also runs the Axum `tests/contract.rs` and the Tier 1 suite. The Tier 3 +runtime jobs are added to `.github/workflows/test.yml` as separate jobs so a +missing runtime never blocks the core gate. + +**Spin gate triple — pre-#269 vs PR-#269.** The fifth gate's literal command +string is checkout-dependent and **not preserved verbatim** across PR #269: + +- **Pre-#269 (today's checkout):** `cargo check -p edgezero-adapter-spin --target + wasm32-wasip1 --features spin` — matches `crates/edgezero-adapter-spin`'s + current SDK 5 / wasip1 target. This is the form `CLAUDE.md` currently + quotes. +- **PR-#269 (target baseline):** `cargo check -p edgezero-adapter-spin --target + wasm32-wasip2 --features spin` — Spin SDK 6 / wasip2 (status-header bullet). + Implementers landing this spec **after** PR #269 must update the gate quote + in `CLAUDE.md` and `.github/workflows/*.yml` to `wasm32-wasip2`; preserving + the stale `wasm32-wasip1` quote would silently break the Spin build. §8 + risk 10 tracks the CLAUDE.md / CI quote refresh. + +The other four gates are unaffected by PR #269 and apply identically in +both worlds. + +## 6. Migration impact + +No back-compat shims. All renames are mechanical. + +| Before | After | +| --- | --- | +| `crates/edgezero-core/src/proxy.rs` | `crates/edgezero-core/src/outbound.rs` | +| `ProxyClient` (trait) | `OutboundHttpClient` | +| `ProxyHandle` | `HttpClient` | +| `ProxyRequest` | `OutboundRequest` | +| `ProxyResponse` | `OutboundResponse` | +| `ProxyService` | removed (use `HttpClient`) | +| `RequestContext::proxy_handle()` | `RequestContext::http_client()` | +| `*ProxyClient` in each adapter | `*OutboundClient` | + +Other changes: + +- **Body stays unified.** `OutboundRequest`/`OutboundResponse` use the core `Body` type; + buffered is the default, streaming is opt-in via `stream_response()`. Streaming + proxy-forward (`from_request`) is **preserved** — no public capability is lost. +- **Adapters** set `HttpClient` (not `ProxyHandle`) into request extensions — same + mechanism, new type. +- **`EdgeError`** gains `BadGateway` / `GatewayTimeout` — additive (`#[non_exhaustive]`). +- **`Manifest`** gains `capabilities` (with nested `outbound`) — additive + (`#[serde(default)]`); existing manifests parse unchanged. +- **`Adapter` trait** gains `capability()` — all four registered adapters implement it. +- **CLI** dispatch in the PR-#269 world: `ensure_capabilities` is wired in at + **five pre-dispatch gate sites** (§3.5.3) — one inside + `edgezero_cli::adapter::execute(..)` (covering `build` / `serve` / `deploy` / + `auth login` / `auth logout` / `auth status`, *before* the manifest-shell-command + branch and *before* the registry lookup), and **four siblings** at the top of + `run_provision`, `run_config_push`, `run_config_validate`, and the + contributor-only `run_demo`. Every adapter-selecting command runs the + capability check exactly once at its entry point. `dev` is gone; `demo` is the + contributor-only replacement that routes through Axum via its own sibling gate. +- **Scaffolding templates** — `handlers.rs.hbs` and any adapter templates that emit + proxy code are updated to the new types; `spin.toml.hbs:13` renders + `allowed_outbound_hosts` from `[capabilities.outbound].hosts` instead of the hardcoded + `["https://*:*"]`. Without this, `edgezero new` would scaffold code against removed + APIs. +- **Public docs (VitePress under `docs/guide/`)** — rewrite every page referencing + `ProxyService` / `ProxyRequest` / `ProxyResponse` / `ProxyHandle` / `proxy_handle` / + the deprecated `ProxyClient`. Known hits at the time of writing: + `docs/guide/proxying.md`, `docs/guide/handlers.md`, `docs/guide/architecture.md`, + `docs/guide/what-is-edgezero.md`, the per-adapter pages under `docs/guide/adapters/`, + and the streaming docs. The new streaming proxy-forward example uses + `OutboundRequest::from_request` + `HttpClient::send`. As a safety net the migration + runs **two** repo-wide sweeps and reconciles every hit, including scaffold README + templates and `examples/app-demo/`: + + 1. Proxy-API sweep: + `rg "Proxy|proxy_handle|ProxyRequest|ProxyResponse|ProxyService|ProxyHandle"`. + 2. `RequestContext` sweep — the round-6 restructure removes `ctx.request()` / + `ctx.request_mut()` / `ctx.json()` / `ctx.form()` and changes the body API: + `rg "ctx\.request\(|ctx\.request_mut\(|ctx\.body\(|ctx\.json\(|ctx\.form\(|RequestContext::request\b|RequestContext::request_mut\b|RequestContext::json\b|RequestContext::form\b|fn request\(&self\) -> &Request|fn request_mut\(&mut self\) -> &mut Request|fn json<\|fn form<"`. + Current callers include `crates/edgezero-core/src/middleware.rs` (the + `RequestLogger` reads `ctx.request()`), `crates/edgezero-core/src/extractor.rs` + (the `Json` / `ValidatedJson` / `Form` / `ValidatedForm` extractors call + `ctx.json()` / `ctx.form()`), `crates/edgezero-core/src/context.rs` itself + (definitions of `json` / `form` are removed), per-adapter `request.rs` modules + that materialise `RequestContext`, and doc pages under `docs/guide/`. Each site + moves to `ctx.parts()` / `ctx.parts_mut()` / `ctx.body_kind()` / + `ctx.body_bytes(max)` / `ctx.json_within(max)` / `ctx.form_within(max)` / + `ctx.take_body()` / `ctx.into_request()` per §3.4.5. +- **Consumers** — `examples/app-demo` and downstream consumers migrate call sites: rename types, + `proxy_handle()` → `http_client()`, adopt `send_all`. + +## 7. File-by-file change summary + +**`crates/edgezero-core`** +- `src/proxy.rs` → `src/outbound.rs` — `OutboundHttpClient`, `HttpClient`, + `OutboundRequest`, `OutboundResponse`, `ResponseMode`; drop `ProxyService`. Also + exposes the public response/request-body cap constants: + `pub const DEFAULT_MAX_RESPONSE_BYTES: usize = 1 * 1024 * 1024;` and + `pub const DEFAULT_OUTBOUND_REQUEST_BODY_BYTES: usize = 8 * 1024 * 1024;`. +- `src/time.rs` — new module. Contents: + - `Deadline` (value type, §3.3.1) + - `DispatchBudget { duration: Duration, deadline: Deadline }` (§3.3.2) + - `pub fn dispatch_budget(req: &OutboundRequest, now: web_time::Instant) -> Result` (§3.3.2) + - Constants (§3.3.1, §3.3.4, §4.3): + - `pub const DEFAULT_NO_DEADLINE_BUDGET: Duration = Duration::from_secs(30);` + - `pub const DEADLINE_FAR_FUTURE: Duration = Duration::from_secs(7 * 24 * 60 * 60);` (round 24) + - `pub const BATCH_DISPATCH_SLACK_MAX: Duration = Duration::from_millis(25);` (round 29) + + The earlier "value type only" wording was stale before round 23 introduced + `DispatchBudget` and the explicit `now` parameter; this is the complete + current contents of the file. +- `src/capability.rs` — new: `Capability`, `CapabilitySupport`. +- `src/error.rs` — add `BadGateway` (502), `GatewayTimeout` (504) + constructors; + extend `status()`. +- `src/extractor.rs` — extractor migration per §3.4.5: `Json` / + `ValidatedJson` route through `ctx.json_within(DEFAULT_INBOUND_JSON_BYTES)`; + `Form` / `ValidatedForm` route through `ctx.form_within(DEFAULT_INBOUND_FORM_BYTES)`; + add `ValidatedJsonWithin` and `ValidatedFormWithin` for explicit + caps. Constants exposed: `pub const DEFAULT_INBOUND_JSON_BYTES: usize = 8 * 1024 * 1024;` + and `pub const DEFAULT_INBOUND_FORM_BYTES: usize = 1 * 1024 * 1024;`. +- `src/compression.rs` — evolve the existing core async stream decoders (§3.4.1): + change the chunk error type from `io::Error` to `EdgeError` (wrap each + `io::Error` with `EdgeError::bad_gateway(..)`). CF/Fastly/Spin response + converters call into the same module rather than carrying parallel + decompressor copies. +- `src/context.rs` — `RequestContext` restructured to `{ path_params, parts: + http::request::Parts, body: BodyCell }` (§3.4.5); `proxy_handle()` → + `http_client()`; `request()` / `request_mut()` removed, replaced with + `parts()` / `parts_mut()`; add `body_kind()`, `take_body()`, `body_bytes`, + `json_within`, `form_within`, and `into_request()`; legacy `json()` and + `form()` removed. +- `src/body.rs` — **change `Body::Stream`'s error type from `anyhow::Error` to + `EdgeError`**: `Stream(LocalBoxStream<'static, Result>)`. The + deadline-aware stream wrappers (§4.1/§4.2/§4.3/§4.4) yield `gateway_timeout` + chunks, and response converters now downstream-map error chunks without an + `anyhow::Error → EdgeError` downcast dance — a wrapper that produces a + `gateway_timeout` chunk can no longer be silently rewritten to `internal` by a + consumer that maps every stream error to 500. Existing in-tree call sites (proxy + forwarding, body draining) are updated mechanically; external streams supplied to + `Body::from_stream` map their source errors into `EdgeError::internal(..)` (the + honest mapping for an unknown stream-source error). Also implement the pre-append + checked accounting and bounded-byte rewrite of `into_bytes_bounded` (§3.4.1). +- `src/manifest.rs` — add `ManifestCapabilities` + `ManifestOutboundCapability` + + `Manifest::capabilities`. +- `src/lib.rs` — re-export new modules; drop proxy re-exports. +- `Cargo.toml` — `MockOutboundClient` under the existing `test-utils` feature. + +**`crates/edgezero-adapter`** +- `Cargo.toml` — **add `edgezero-core` as a workspace dependency.** `Capability` / + `CapabilitySupport` live in `edgezero-core` (so manifest parsing can use them), and + the `Adapter` trait references them; the crate currently has no dependency on core + and that must be added. The direction (adapter → core) is the standard one and + introduces no cycle. +- `src/registry.rs` — add `Adapter::capability()`. + +**`crates/edgezero-adapter-{axum,cloudflare,fastly,spin}`** +- `src/proxy.rs` → `src/outbound.rs` — `*OutboundClient` implementing + `OutboundHttpClient::send` and `send_all`, buffered + streamed modes, + decompressed-byte cap, header normalization for decompressed responses + (strip `content-encoding` / `content-length`). +- `src/response.rs` — **per-adapter streaming policy.** Today each adapter's + response converter (`crates/edgezero-adapter-{axum,fastly,spin}/src/response.rs`) + buffers `Body::Stream` before producing the platform response. The migration + preserves lazy streaming **where the platform allows it without violating core's + `LocalBoxStream` (non-Send) invariant**: + + - **Cloudflare** — WASM, single-threaded JS event loop, no `Send` requirement on + response bodies. `worker::Body::from_stream` consumes the `Body::Stream` + directly; chunks flow without buffering. + - **Fastly** — WASM, single-threaded guest, no `Send` requirement, **but** + Fastly's lazy/early-streaming API (`Response::stream_to_client`) is + incompatible with `#[fastly::main]` (Fastly SDK docs, capability footnote 6). + The default scaffold therefore performs **buffered passthrough**: drain the + wrapped `Body::Stream` to `Bytes` within `max_response_bytes`, then return + through the normal `#[fastly::main]` flow. Apps that need lazy passthrough + on Fastly declare `lazy-streamed-response-passthrough` required and get a + hard build failure (Fastly = `BestEffort` for this capability). The + deadline-aware stream wrapper still runs on the buffered drain path — only + the *passthrough* is buffered. + - **Spin** — WASM, WASI async, no `Send` requirement. The WASI outgoing-body + chunk-write path consumes the `Body::Stream` directly. + - **Axum** — native, multi-threaded tokio. `axum::body::Body::from_stream` requires + `Send + 'static`, which conflicts with core `Body::Stream = LocalBoxStream` + (intentionally non-Send for WASM compat — `body.rs:14`). Designing a real + `LocalBoxStream → Send` bridge (e.g. `spawn_local` + tokio mpsc) is non-trivial + and out of scope for this migration. **The Axum response converter therefore + buffers `Body::Stream` into `Bytes` (bounded, pre-append-checked) before + constructing the axum response.** The cap is a defined Axum-adapter constant + `AXUM_RESPONSE_STREAM_BUFFER_BYTES = 16 MiB` (a **fixed compile-time constant**; + no `AxumOutboundConfig` plumbing in this migration). The per-outbound-request + `max_response_bytes` is unavailable at this stage because the app has already + consumed `OutboundResponse::into_response()` into a core `Response` and the + original cap was attached to the now-discarded `OutboundRequest`. Apps that need + a different ceiling either edit the constant in their fork, carry the bytes + through a buffered path explicitly, or wait for the configurable follow-up + tracked in §8 risk 6. + + **Stream-error handling during buffered drain.** Because the Axum response + converter buffers `Body::Stream` *before* writing any downstream response + headers, it can map a stream error to a clean HTTP status (unlike the + streaming-passthrough adapters, which would have to abort the wire because + headers had already been sent — §3.1.1 post-header rule). The mapping is: + + | Stream chunk yields | Axum response | + | --- | --- | + | `Ok(bytes)`, buffer + bytes.len() ≤ cap | append, continue | + | `Ok(bytes)`, buffer + bytes.len() > cap | abort drain → axum response status **502** with body `"response body exceeded N bytes"` | + | `Err(EdgeError::GatewayTimeout(..))` | abort drain → axum response status **504** with the error message | + | `Err(EdgeError::BadGateway(..))` | abort drain → axum response status **502** with the error message | + | `Err(other EdgeError)` | abort drain → axum response with the `EdgeError::status()` for that variant (`internal` → 500, etc.) | + + Source: the wrapped streamed body's `EdgeError` chunks already encode the + intended status; Axum just lifts them to the response. No silent + coalescing-to-502, no panic. This is the documented Axum-specific + limitation: lazy streaming proxy-forward works on Cloudflare, Fastly, and + Spin; Axum buffers, *but the buffering boundary lets it preserve the + correct status code*. For fan-out handlers and most edge-shaped + apps this is a non-issue; if true lazy streaming on Axum becomes a + requirement later, an mpsc bridge is a separate follow-up. Capability text + and risk section reflect this (see §3.5.2 footnote 3 and §8). + + Buffering is reserved for `Body::Once` on the three WASM adapters; on Axum, the + buffering path also applies to `Body::Stream`. +- adapter entry — register `HttpClient`; declare `capability()`. +- **Axum `Cargo.toml`** — enable `gzip` and `brotli` features on `reqwest` so + transparent decompression matches the other three adapters (the workspace + reqwest dep is `default-features = false` today; the Axum adapter opts these + features in directly). +- Fastly: + - Hash-based dynamic-backend naming (`format!("ez_{:032x}", sha256_128(identity))`, + §4.3). The `edgezero-adapter-fastly/Cargo.toml` adds **`sha2` workspace + dependency** for the SHA-256 digest; the 128-bit truncation is `&digest[..16]`. + Alternatively, if a SHA-256 helper already exists in `edgezero-core` (audit step + in the same sweep), the adapter uses that; either way the dep is declared + explicitly in this migration, not assumed transitive. + - Dispatch-time host timeouts and SSL configuration on `BackendBuilder` per + §3.3.4 / §4.3, using the **four canonical URI accessors** introduced in + rounds 25 / 46 / 47: + `Backend::builder(name, req.backend_target())` for the connection target; + `.override_host(req.host_authority())` for the outgoing `Host` header (the + accessor encodes the canonicalization — userinfo rejected, default ports + stripped per §3.1.3, explicit non-default ports preserved); timeouts via + `connect_timeout` / `first_byte_timeout` / `between_bytes_timeout` with the + §3.3.4 phase split (1/4 connect, 3/4 first-byte, full budget between-bytes; + degenerate to `both = total_ms` for sub-4 ms budgets); HTTPS → `.enable_ssl()` + plus `.check_certificate(req.cert_host().unwrap())` (`cert_host()` is `Some` + on any HTTPS scheme and pre-strips brackets); `.sni_hostname(sni)` is called + **only when `req.sni_hostname()` is `Some(sni)`** (DNS-name hosts); IP-literal + hosts return `sni_hostname() == None` per RFC 6066 §3, so the adapter omits + `.sni_hostname()` entirely while still passing `cert_host()` to + `.check_certificate(..)`. HTTP (`cert_host() == None`) → `.disable_ssl()`. + **The four accessors are the only canonical source** — adapters MUST NOT + re-derive from `req.uri()` directly, the local `is_ip_literal` parse + + `trim_start_matches('[')` shape from earlier rounds is gone (round 47). + The backend is passed to `send_async` / `send_async_streaming` at send time + via `impl ToBackend`; there is no + `with_backend(..)` setter on `Request`. +- Spin: render `allowed_outbound_hosts` from the manifest per §3.5.4. +- `tests/contract.rs` — created for Axum; extended for the other three (§5). + +**`crates/edgezero-cli`** +- `src/adapter.rs` — wire `ensure_capabilities` as the **first statement** of + `edgezero_cli::adapter::execute(adapter_name, action, manifest_loader, args)` + (PR #269), *before* `manifest_command(..)` is consulted and *before* the + registry lookup. This covers `run_build`, `run_serve`, `run_deploy`, and the + three `run_auth` sub-actions (which all dispatch through `execute(..)`). The + three commands that don't flow through `execute(..)` — `run_provision`, + `run_config_push`, `run_config_validate` — get **sibling pre-dispatch gates**: + each is the first statement of its `run_*` function and calls the same + `ensure_capabilities` helper. The contributor-only `run_demo` also calls + `ensure_capabilities("axum", ..)` at its top before the Axum runner starts. + **All five gate sites** (one inside `execute(..)`, the four siblings on + `run_provision` / `run_config_push` / `run_config_validate` / `run_demo`) are + documented in §3.5.3's gate table. The legacy `handle_build` / `handle_serve` + / `handle_deploy` / `handle_dev` functions referenced in earlier appendices + were removed by PR #269. +- scaffolding templates (`handlers.rs.hbs`, `spin.toml.hbs`, adapter templates) — update + to the new API and manifest-driven outbound hosts. + +**`examples/app-demo`** +- migrate to the new types and `send_all` across the per-adapter binaries. + PR #269 added a separate `examples/app-demo/crates/app-demo-cli/` integration + crate that drives the typed CLI (`auth`, `provision`, `config push/validate`, + `demo`) against the demo manifest; update that crate's fixtures alongside the + adapter binaries so the new outbound types compile end-to-end. The demo + manifest's `[stores.*]` blocks (PR #269's `ManifestStores { config, kv, + secrets }` shape) are unchanged — outbound capabilities sit in + `[capabilities.outbound]` and compose additively with the store sections. + +**`docs/`** +- `proxying.md`, `adapters/overview.md`, `handlers.md` (and any other proxy references) — + rewrite for the outbound API. + +**`.github/workflows/test.yml`** +- add Tier 3 runtime jobs (Axum now; Fastly/Cloudflare/Spin as runtimes are wired). + +## 8. Open questions / risks + +1. **`DEFAULT_MAX_RESPONSE_BYTES` = 1 MiB.** Trivially overridable per request via + `max_response_bytes`. Confirm the default suits expected target responses. +2. **Tier 3 CI runtimes.** Viceroy / `workerd` / `spin` jobs add CI cost and + maintenance. The design degrades safely (Tier 1 + Tier 2 always run); the risk is + schedule, not correctness. +3. **Cloudflare cancellation.** Dropping the raced future to enforce a timeout relies on + the Workers runtime reclaiming the subrequest. Effective in practice; the Tier 3 CF + test verifies wall-clock behaviour. +4. **Fastly body-phase overshoot.** The deadline overshoot on Fastly is bounded by one + between-bytes-timeout interval (§3.3.4). If a stricter guarantee is ever required, the + adapter would need to cap total body-read attempts — out of scope here. +5. **Naming.** `OutboundHttpClient` (trait) vs. `HttpClient` (handle) are close. They + never co-occur in app code — handlers see only `HttpClient` — so the overlap is + low-risk, but a rename of the handle is cheap if preferred. +6. **Axum lazy streaming follow-up.** The Axum response converter buffers `Body::Stream` + into `Bytes` because core `Body::Stream = LocalBoxStream` is non-Send and Axum's + `Body::from_stream` requires `Send + 'static` (§3.5.2 footnote 3, §4.1, §7). A real + bridge — e.g. a `tokio::task::spawn_local` driving a `tokio::sync::mpsc` Send channel + read by Axum — is implementable but non-trivial and is **deferred**. Apps that need + lazy streaming on Axum declare the `lazy-streamed-response-passthrough` capability + required and get a hard build failure today; lifting the limitation is a separate + future change with its own design + tests. +7. **Fastly streamed-upload write-phase has no SDK-configurable bound.** + Fastly's `between_bytes_timeout` is documented as receive-side only — it + bounds the gap between bytes received from origin, not the host-side write + of guest-supplied bytes to origin (Fastly Backend API docs; round 50). No + published Fastly backend-timeout field bounds the guest-to-origin write + direction. Streamed-upload write-phase is therefore `BestEffort` on + Fastly (alongside the source-stream-yield `BestEffort`); the cooperative + `budget.deadline.is_expired()` check **between** chunks is the only + adapter-side bound. Apps that need real-time enforcement against a slow + origin on the write path either pass a buffered request body (`Body::Once`, + no `StreamingBody` involved) or target a different adapter. If a future + Fastly platform release adds a documented guest-write timeout, the + write-phase claim could upgrade to `BoundedCooperative` — track Fastly + host docs. +8. **Fastly buffered-body-drain serialization in `send_all`.** Harvest reads bodies in + slot order, so wall-clock = `max(header_arrivals) + Σ buffered_body_drain_times` + on Fastly vs. `max(header_arrivals + body_drain_times)` on Axum/CF/Spin (§3.3.4). + For small JSON bodies (fan-out batches) the difference is negligible; for ≥ few-MiB + responses Fastly is suboptimal. **There is no current EdgeZero mitigation** — + and Streamed mode is not the workaround (it's rejected by `send_all` preflight + per §3.1.1, and even via single `send` Fastly has no concurrent + chunk-consumption primitive). Apps that need concurrent large-body fan-out on + Fastly should (a) target a different adapter for that workload, (b) restructure + the topology so parallel large-body drains aren't required, or (c) wait for the + interleaved-drain follow-up. The follow-up — interleaved chunk reads across + in-flight Fastly `Response` bodies, driven from a single guest harvest loop — is + non-trivial without an async reactor and is **deferred**. The + `send-all-slot-isolation` capability (§3.5.1 footnote 4) lets apps declare the + requirement explicitly and get a hard build failure on Fastly until this lands. +9. **Fastly configurable phase split.** The fixed 1/4 connect + 3/4 first-byte + split (§4.3) produces premature connect failures for slow-connect upstreams + even when the total budget would have sufficed. Apps that hit this require + `outbound-flexible-phase-budget` (§3.5.1 footnote 5) and fall through to the + hard build failure on Fastly. The follow-up would either expose a per-request + `fastly_phase_split(connect_ratio: f32)` setter, a per-`OutboundRequest` + configuration field, or a per-adapter config knob on `FastlyOutboundClient`. + Each option has a memory-model and capability impact, so it's left **deferred** + pending a real use case. +10. **CLAUDE.md / CI command-quote refresh for Spin SDK 6 + wasip2.** PR #269 + bumps the Spin adapter to `spin-sdk = "6"` and the target triple to + `wasm32-wasip2`; the project `CLAUDE.md` and `.github/workflows/*.yml` + snippets still quote `cargo check -p edgezero-adapter-spin --target + wasm32-wasip1 --features spin` in several places. The spec itself doesn't + pin a target triple (it references `spin_sdk::http::send` symbolically, + which is SDK-6-compatible), so no §3 / §4 / §5 change is needed — but the + CI gate quotes and the CLAUDE.md table need a follow-up refresh so + contributors don't paste the old triple. Tracked here so the spec rebase + appendix (Appendix AR) has a one-line forward pointer. +11. **Per-batch transient-memory cap against adversarial chunking.** §3.4.1's + `sizeof(current_chunk)` term is source-controlled — an upstream peer that + yields one large `Bytes` produces a transient resident footprint equal to + that chunk size plus the persistent buffer cap. EdgeZero currently does not + rechunk. The follow-up would either: (a) add an opt-in + `OutboundRequest::max_chunk_bytes(usize)` builder field that wraps the + upstream stream with a rechunker on the consumer side (lazy, opt-in, no + perf cost when unset); (b) add a fixed `MAX_TRANSIENT_CHUNK_BYTES` constant + in `edgezero-core` that every adapter's incoming-body stream must respect + by rechunking at ingest (eager, breaks lazy passthrough on CF/Fastly/Spin + when the upstream's natural chunk size exceeds the constant); or (c) leave + it source-controlled and document the bound at the adapter level + (`hyper`'s 16 KiB, WASI's 64 KiB, etc.) as the operational floor. Each + option has a perf and lazy-streaming trade-off; deferred until a + fan-out batch or downstream consumer reports actual OOM behaviour from + adversarial chunking. The §3.4.1 / §3.4.4 docs already call out the + caveat so apps aren't surprised. +12. **Fastly lazy-streamed-response-passthrough via non-`#[fastly::main]` + entry point.** Today's Fastly scaffold uses `#[fastly::main]`, which + implicitly calls `Response::send_to_client()` on the returned response. + Fastly's `Response::stream_to_client()` — the only API that flushes + response bytes to the client lazily — is documented as incompatible + with `#[fastly::main]`. As a result, the Fastly adapter currently + falls back to buffered passthrough (drain `Body::Stream` to `Bytes` + within `max_response_bytes` before returning), and + `lazy-streamed-response-passthrough` is `BestEffort` on Fastly per + footnote 6. The follow-up would either: (a) scaffold a non-attribute + entry (`fn main() { let req = Request::from_client(); … resp.stream_to_client() … }`) + and route the EdgeZero handler through it, with `stream_to_client()` + feeding chunks from the wrapped `Body::Stream`; (b) keep + `#[fastly::main]` for buffered handlers and add a separate + `#[edgezero::stream_main]` attribute that expands to the + non-attribute form when the manifest declares + `lazy-streamed-response-passthrough` required; (c) leave the + `BestEffort` downgrade and document the migration path. Each option + affects scaffolding templates, `edgezero new`, and contributor + docs. **Deferred** until an app explicitly requires lazy Fastly + passthrough; the §3.5.2 footnote 6 documents the exact constraint + so adopters aren't surprised. + +Appendices A through the last `## Appendix` heading in the document (use that +heading as the canonical upper bound — the index doesn't pin an exact letter +because every round adds another one and the index would otherwise drift) +record the round-by-round evolution of the spec. **The +authoritative normative content is §1–§8**; appendix entries are kept as a paper +trail of what changed and why. Entries in earlier rounds may have been superseded +by later rounds — for example, round-6's "into_request returns Body::empty() after +poison" was changed to a fallible Err in round 18, and round-15's "configurable at +adapter init for `AXUM_RESPONSE_STREAM_BUFFER_BYTES`" was tightened to a fixed +compile-time constant in round 16. When the active sections and an older appendix +disagree, the active sections win. Round 20 (Appendix T) does **not** re-walk every +prior entry; the index note here is the disclaimer for the whole history. + +## Appendix A — Review round 1 resolutions + +| Review finding | Resolution | +| --- | --- | +| Deadline semantics too strong for Fastly / buffering after exchange | §3.3.3–§3.3.4: deadline scope defined per `ResponseMode`; buffering happens inside the deadline-bounded region; Fastly body phase documented as bounded-cooperative | +| `time::timeout()` cannot live in core | §3.3.5: general combinator removed; core ships only the `Deadline` value type | +| `timers` capability misrepresents Fastly | §3.5.1: renamed `outbound-deadlines`, defined precisely; no general-timer claim | +| Memory bounded per-response, not per-batch | §3.4.4: explicit batch memory model; app bounds N; §1.1 goal reworded | +| Outbound URI validation underspecified | §3.1.3: constructors validate scheme (`http`/`https`) + authority; invalid → 400 | +| Header builder cannot be infallible | §3.1.3: `header(..)` is `Result`; `headers_mut()` for pre-validated values | +| Compressed cap before/after decompression | §3.4.1: cap is decompressed bytes, enforced incrementally during decompression | +| `[capabilities.outbound]` not modeled | §3.5.1/§3.5.4: `ManifestOutboundCapability` struct, default `["*"]`, Spin render rules | +| Migration misses templates and docs | §6/§7: scaffolding templates and `docs/` pages added to the migration checklist | +| "only outbound type app code touches" inaccurate | §3.1.2: reworded to "only outbound client/handle type" | +| Fastly dynamic backend naming not robust | §4.3: hash-based stable names (`ez_<16hex>`, FNV-1a of authority) | +| Test plan misses riskiest deadline behaviour | §5.4: added slow streaming bodies, compressed expansion, headers-then-deadline, partial timeout, empty input | +| Residual risk: dropping streaming forward | Resolved by decision §1.4 — unified body; streaming proxy-forward preserved | + +## Appendix B — Review round 2 resolutions + +| Review finding | Resolution | +| --- | --- | +| Fastly streamed request bodies would break dispatch-all | §3.1.1 + §4.3: `send_all` rejects `Body::Stream` request bodies on every adapter (per-slot `bad_request`, 400); streamed uploads use `send` | +| "None budget fails immediately" conflicted with optional timeouts | §3.3.2: precise `dispatch_budget` rule — `None` means no deadline; only an expired deadline or `Duration::ZERO` fails immediately | +| Fastly omitted from decompression-cap obligation | §3.4.1: cap obligation explicitly applies to Axum (reqwest), Cloudflare, Fastly, and Spin | +| `Streamed` mode weakened `Ok` semantics | §3.1.1 trait rustdoc differentiates `Ok` semantics — full exchange completion in `Buffered`, headers-only in `Streamed`, with body-phase failures surfacing on consumption | +| Outbound JSON parse error mapping unspecified | §3.1.3 + §3.4.3: malformed upstream JSON / `json::` on a streamed body → `bad_gateway` (502) | +| `Body::into_bytes_bounded` maps to 400 but outbound wants 502 | §3.1.3 + §3.4.1: `OutboundResponse::into_bytes_bounded` does its own bounded drain mapping over-limit to `bad_gateway` (502); it does not delegate to the core helper | +| `Native` overstated for Fastly outbound-deadlines | §3.5.1/2: new `BoundedCooperative` support level added; Fastly `outbound-deadlines` = `BoundedCooperative`; rubric documented so future adapters are judged consistently | +| Test plan missed streamed request bodies in fan-out | §5.4: per-slot 400 rejection test added (Tier 1 + Tier 2); streamed-`send` proxy-forward success test added across tiers | +| Spin host render rules too lossy | §3.5.4: explicit accepted-form table with per-form output and load-time validation rules | + +## Appendix C — Review round 3 resolutions + +| Review finding | Resolution | +| --- | --- | +| Axum decompression claim didn't hold with current `reqwest` features | §3.4.1 + §7: the Axum adapter's `Cargo.toml` opts in `reqwest`'s `gzip` and `brotli` features so decompression actually happens and the cap obligation applies | +| `header(..)` signature wasn't implementable as written | §3.1.3: signature now has explicit `Display` bounds on the `TryInto::Error` associated types so the impl can format conversion failures into `EdgeError::bad_request` | +| Capability types in core created an unstated crate dependency | §7: `crates/edgezero-adapter/Cargo.toml` adds `edgezero-core` as a workspace dep — direction is adapter → core, no cycle | +| `deploy` skipped capability enforcement | §3.5.3 + §7: `ensure_capabilities` runs in `handle_build`, `handle_serve`, **and** `handle_deploy` | +| `from_request` didn't define header normalization | §3.1.3: explicit rules — strip hop-by-hop headers (RFC 7230 §6.1 list + per-connection-header), replace `host`, drop `content-length`. Defined once in core so adapters don't diverge | +| Streamed-mode response header normalization for decompression unspecified | §3.4.1: when an adapter decompresses, the returned `OutboundResponse.headers` must have `content-encoding` and `content-length` stripped — applies to both `Buffered` and `Streamed` | +| `body_bytes` / `json_within` consumption semantics missing | §3.4.2: first call drains a `Body::Stream` and replaces the context body with `Body::Once(bytes)`; subsequent calls return a cheap clone, re-checking the cap. Network body read at most once | +| Fastly bounded-overshoot calculation depended on implicit timeout state | §3.3.4 + §7: the bound is on `between-bytes-timeout` set *at dispatch* to `effective_at_dispatch`; the Fastly SDK exposes no per-chunk timeout update, so the bound does not shrink while a slot waits behind earlier harvest work. Spec now states this explicitly | + +## Appendix D — Review round 4 resolutions + +| Review finding | Resolution | +| --- | --- | +| Redirect behaviour could bypass app allowlists | §3.1.4: adapters never auto-follow redirects; 3xx is delivered as `Ok` with `Location` preserved; per-adapter mechanics tabulated; app re-runs its allowlist against `Location` before issuing a new request | +| `Streamed` deadlines lacked a deadline-aware body-drain helper | §3.1.3: `OutboundResponse::into_bytes_bounded_until(max, deadline)` added; §5.4 has a contract test | +| Header preservation conflicted with Spin/WASI UTF-8 limitation | §3.1.4: uniform UTF-8 rule across all adapters — request headers rejected at construction (`bad_request`), upstream response headers dropped with `warn!` diagnostic; ASCII-only headers (auth/tracing/cache/conneg) unaffected | +| Fastly capability conflated adapter support with service config | §4.3: new "Service prerequisite — dynamic backends" subsection; `ensure_capabilities` emits an informational log; runtime failure surfaces as `bad_gateway` with a remediation message; capability matrix is explicitly an adapter-support contract, not a runtime health guarantee | +| `send` / `send_all` equivalence was prose-only | §5.4: explicit equivalence contract test (Tier 1 + Tier 2) — status, headers, body cap, deadline classification, decompression, error mapping all asserted identical | +| Fastly pseudocode contained a production-hostile panic | §4.3: replaced `expect("every slot resolved")` with a graceful per-slot `EdgeError::internal(..)` — adapter boundaries never panic the host on a contract bug | +| `json` helper Content-Type behaviour unspecified | §3.1.3: sets `content-type: application/json` only when absent; caller-set value preserved; `content-length` left to adapter; serialization failure → `internal` | + +## Appendix E — Review round 5 resolutions + +| Review finding | Resolution | +| --- | --- | +| `into_bytes_bounded_until` promised timer behaviour core cannot implement | §3.1.3: the helper is explicitly cooperative on every adapter. Real-time enforcement comes from adapters with a platform timer (Axum / Cloudflare / Spin) wrapping streamed response bodies with a deadline-aware stream at construction time; Fastly is bounded-cooperative with the same overshoot bound as §3.3.4. §5.4 has a stalled-chunk test | +| Inbound body boundedness wasn't actually covered by the migration | §3.4.2 + new §3.4.5: adapters stop pre-buffering and expose `Body::Stream`; `RequestContext::body_bytes` / `json_within` are `&self`-callable via an internal cache so existing `FromRequest` extractors compile unchanged; `Json` / `ValidatedJson` delegate to `json_within(DEFAULT_INBOUND_BODY_BYTES = 8 MiB)`, with `ValidatedJsonWithin` for tighter caps | +| Request-header safety rules were bypassable | §3.1.4: new `outbound::normalize_for_dispatch` core helper that adapters MUST call before dispatch — drops non-UTF-8, strips hop-by-hop, removes `host` / `content-length` / `transfer-encoding`. Idempotent. `headers_mut()` and `from_request` are safe to use freely; the final sweep guarantees portability and framing | +| Fastly backend hash key omitted scheme and resolved port | §4.3: identity = `scheme + ":" + host + ":" + resolved_port + ":" + tls_mode`; backends deduplicated by full identity, so `http://x` and `https://x` are not conflated | +| Required + `BestEffort` weakened the capability contract | §3.5.3: required + `BestEffort` is now a **hard failure**; if degradation is acceptable, declare the capability `optional` instead. Required means real enforcement (`Native` or `BoundedCooperative`) | +| Multi-value header preservation not specified or tested | §3.1.4: explicit "preserve every entry" contract — `HeaderMap::append` / `get_all`; §5.4 covers repeated `set-cookie` and repeated outbound request headers | +| Migration doc paths stale | §7: paths corrected to `docs/guide/...`; known hits enumerated (`docs/guide/proxying.md`, `handlers.md`, `architecture.md`, `what-is-edgezero.md`, per-adapter pages, streaming docs); `rg "Proxy\|proxy_handle\|ProxyRequest\|ProxyResponse\|ProxyService\|ProxyHandle"` repo-wide as a safety net | + +## Appendix F — Review round 6 resolutions + +| Review finding | Resolution | +| --- | --- | +| `OutboundRequest`/`OutboundResponse` API was not implementable by adapters | §3.1.3: added `OutboundRequest::into_parts() -> OutboundRequestParts` (struct exposes every field including `body`, `timeout`, `deadline`, `response_mode`); `OutboundResponse::new`, `headers_mut`, and `into_parts(self) -> (StatusCode, HeaderMap, Body)` for adapter assembly | +| Inbound body cache `request()` / `body()` / `into_request()` semantics undefined | §3.4.5: `RequestContext` is restructured to `{ path_params, parts, body: BodyCell }`; explicit behaviour table for every method post-cache; `into_request()` reassembles with `Body::Once(cached)` so streaming proxy-forward composes with middleware that already buffered | +| Failed inbound body reads had no cache/poison semantics | §3.4.5: new `BodyState::Poisoned(StoredError)` variant — after a failed drain, all subsequent `body_bytes`/`json_within` return the same stored error; `body()` returns `Body::empty()`; the network body is not retried (silent re-read is impossible) | +| Multi-value header preservation lacked per-adapter mechanics | §3.1.4: per-adapter table naming the exact SDK calls — `Fastly::append_header`/`get_header_all`, `worker::Headers::append`, `spin_sdk::Headers::append` (WASI `fields`), reqwest's native append. Spec downgrade path documented if a future SDK breaks round-tripping | +| Axum no-deadline behaviour was ambiguous | §3.3.2: `DEFAULT_NO_DEADLINE_BUDGET = 30 s` is the documented EdgeZero default applied by every adapter when neither `timeout` nor `deadline` is set, preserving the existing Axum 30 s ceiling and making "no deadline" mean the same finite thing everywhere | +| `from_request` and `normalize_for_dispatch` disagreed about `Host` | §3.1.3: `from_request` now **drops** `host`; `normalize_for_dispatch` (§3.1.4) sets it from `req.uri()` at dispatch — single source of truth | +| Streamed JSON ergonomics were misleading | §3.1.3: added `OutboundResponse::json_bounded(self, max)` and `json_bounded_until(self, max, deadline)` consuming convenience methods; the `&self` `json` error text directs callers to those | +| Migration summary had stale bullets | §6 short bullet + §2 summary table updated to include `handle_deploy` and `docs/guide/...` paths; no longer contradict the detailed sections | + +## Appendix G — Review round 7 resolutions + +| Review finding | Resolution | +| --- | --- | +| Streamed deadline semantics were internally inconsistent | §3.3.3 rewritten: the originating `Deadline` covers the entire exchange end-to-end in both modes. In `Streamed`, adapters wrap the response body with a deadline-aware stream so chunk reads honour the same deadline; `Ok(resp)` returns earliest-possible (headers) but the body still errors past the deadline. `into_bytes_bounded_until` is for tightening below the originating deadline, not for re-applying it | +| Async body cache needed an in-flight state | §3.4.5: `BodyState` adds `Draining`; explicit non-async take/replace protocol; drop-guard turns dropped drain futures into `Poisoned(cancelled)`; reentrant calls during `Draining` return `EdgeError::internal` without panic. §5.4 tests drop-mid-drain and reentrant access | +| Bounded-memory still leaned on a helper that over-allocates by one chunk | §3.4.1: explicit "pre-append checked length accounting" rule for both inbound (`RequestContext::body_bytes`) and outbound (`OutboundResponse::into_bytes_bounded`); `Body::into_bytes_bounded` in `crates/edgezero-core/src/body.rs:84` is rewritten to check before extending. Memory is bounded by `max`, with no per-chunk overshoot | +| `RequestContext::body()` was unimplementable as specified | §3.4.5: `body()` removed. Replaced by `body_kind() -> BodyKind` for non-consuming state inspection and `take_body() -> Body` for consuming extraction. `body_bytes` / `json_within` / `take_body` / `into_request` are the only ways to actually access the body | +| Inbound migration missed `Form` / `ValidatedForm` | §3.4.5: extractor migration table now includes `Form` and `ValidatedForm` — both delegate to a new `ctx.form_within(max)` helper with `DEFAULT_INBOUND_FORM_BYTES = 1 MiB`; `ValidatedFormWithin` added for explicit caps; legacy `RequestContext::form()` removed | +| Adapter notes bypassed `DEFAULT_NO_DEADLINE_BUDGET` | §4.1 + §4.3 rewritten to compute the budget via `dispatch_budget(req)` (§3.3.2) instead of an adapter-local `min(..)` formula, so no-deadline requests are uniformly bounded to 30 s on every adapter | +| Migration sweep was too proxy-focused | §7 docs migration now documents **two** sweeps: the proxy-API sweep and a new `RequestContext` sweep for `ctx.request()` / `request_mut()` / `ctx.body()` / `fn request(..) -> &Request` patterns, with the known core sites (`middleware.rs`, `extractor.rs`, per-adapter `request.rs`) called out | +| Host normalization wording still disagreed | §3.1.3 + §3.1.4 unified: `from_request` drops `host`; `normalize_for_dispatch` is the sole single-source-of-truth strip; the adapter derives the final `Host` (or SDK equivalent) directly from `req.uri()` at SDK-construction time without re-reading `req.headers()` | + +## Appendix H — Review round 8 resolutions + +| Review finding | Resolution | +| --- | --- | +| Axum can't stream request bodies through reqwest as previously implied | §3.1.3 adds `OutboundRequest::max_request_body_bytes(n)` with `DEFAULT_OUTBOUND_REQUEST_BODY_BYTES = 8 MiB`; §4.1 specifies that Axum drains streamed request bodies into `Bytes` up to that cap (pre-append checked accounting, `bad_request` on overflow) before issuing the reqwest request. No `reqwest` `stream` feature required. Bounded, predictable, WASM-compatible across the board. CF / Spin notes (§4.2 / §4.4) updated to apply the same cap | +| BodyCell state/API not type-checkable | §3.4.5: `BodyState` adds `Taken`; new public `BodyKind` enum (variants `Initial \| Draining \| Cached { len } \| Poisoned \| Taken`); `take_body() -> Result` (Err on `Draining` programmer error and on `Poisoned`) — all referenced variants are now real | +| CF/Spin streamed deadline notes lagged the contract | §4.2 + §4.4: both adapters now wrap streamed response bodies with per-chunk platform-timer races bounded by `budget.deadline`, so the streamed body honours the originating deadline end-to-end per §3.3.3. Both also reference `dispatch_budget(req)` rather than an adapter-local formula | +| 30 s no-deadline needed a synthetic absolute deadline | §3.3.2: `dispatch_budget(req) -> DispatchBudget { duration, deadline }` returns **both** the SDK timeout duration AND an absolute `Deadline` — synthetic via `Deadline::after(duration)` if `req.deadline` was `None`. Fastly's between-chunk `is_expired()` check (§3.3.4) and the streamed-body wrappers in §4.1/§4.2/§4.4 all use `budget.deadline`, so cooperative enforcement works uniformly whether or not the caller supplied a deadline | +| `into_bytes_bounded` doc contradicted the streamed-deadline model | §3.1.3 rewritten: the doc now says explicitly that the originating deadline is already honoured by the adapter-wrapped stream, so `into_bytes_bounded` returns 504 on stalled streams without the caller threading the deadline. `_until` is documented as "tighten below the originating deadline," not "re-apply" | +| Hop-by-hop list said `trailers` instead of `trailer` | Replaced everywhere — `from_request` (§3.1.3) and `normalize_for_dispatch` (§3.1.4) now strip `trailer` per RFC 7230 §6.1 | +| UTF-8 header policy needed an implementation guardrail | §3.1.4: validation must use `std::str::from_utf8(value.as_bytes())`, not `HeaderValue::to_str()` (which is stricter than UTF-8 and would drop valid non-ASCII headers like `café`). §5.4 test asserts a valid non-ASCII UTF-8 header survives round-trip plus a `\x80`-byte header is dropped/rejected | +| Stale API references after body rewrite | `http_client()` snippet (§3.1.2) uses `self.parts.extensions.get(..)`; §3.4.5 stale "switch to `body()`" line replaced with the correct `body_kind` / `body_bytes` / `take_body` / `into_request` set; poison semantics use `body_kind() == Poisoned` and `take_body()` semantics; §7 `src/context.rs` file-summary line lists `body_kind`, `take_body`, `form_within`, `into_request`, and the removal of legacy `request()` / `request_mut()` / `json()` / `form()` | + +## Appendix I — Review round 9 resolutions + +| Review finding | Resolution | +| --- | --- | +| `DispatchBudget.deadline` didn't track the effective budget when both `timeout` and `deadline` were set | §3.3.2 step 5: `deadline` is **always** `Deadline::after(duration)` — i.e. `now + effective_duration` — never the original `req.deadline`. `.timeout(50ms).deadline(5s)` now produces an absolute deadline of `now + 50ms`, and the streamed body / Fastly body-phase use that. New §5.4 test asserts the short-timeout-long-deadline case | +| Streamed request-body drain/write wasn't clearly inside the deadline | §4.1 / §4.2 / §4.4: every adapter races the request-body drain/write against `budget.deadline` (stalled upload → `gateway_timeout`), and **recomputes** the remaining duration from `budget.deadline.remaining()` after the drain — so upload time counts against the budget rather than adding on top. New §5.4 tests for over-cap → 400, stalled upload → 504, drain reduces remaining budget | +| `body_bytes` / `json_within` behaviour after `take_body()` was unspecified | §3.4.5 row: from `Taken`, all buffered helpers return `Err(EdgeError::internal("body already consumed via take_body"))`. New §5.4 test | +| Fastly notes still had stale `min(timeout, deadline.remaining())` and bare `deadline.is_expired()` | §3.3.4 row + Fastly precision paragraph + Fastly pseudocode all updated to `budget.duration` / `budget.deadline.is_expired()`. The synthetic 30 s deadline is honoured uniformly | +| Test plan missed streamed request-body cap and deadline behaviour | §5.4 adds `max_request_body_bytes` over-cap → 400; stalled upload → `budget.deadline` (504); drain time reduces remaining SDK budget | +| Migration sweep missed `ctx.json()` / `ctx.form()` removals | §7 sweep regex updated to include `ctx.json(`, `ctx.form(`, `RequestContext::json`, `RequestContext::form`; known call sites in `context.rs` and `extractor.rs` enumerated | +| Test plan missed valid-non-ASCII-UTF-8 and explicit `trailer` cases | §5.4 adds non-ASCII UTF-8 round-trip row, `\x80` rejection row, and an explicit RFC 7230 `trailer` strip row | +| Stale doc surfaces | §3.1.1 heading changed to "two required methods"; §3.1.3 builder-surface list includes `max_request_body_bytes`; document status header updated to "revised through review rounds 1–8" with the current date | + +## Appendix J — Review round 10 resolutions + +| Review finding | Resolution | +| --- | --- | +| `dispatch_budget` timeout-only contradiction | §3.3.2 rewritten end-to-end: a single `now` snapshot, candidate **absolute** deadlines (`from_timeout`, `from_caller`, `from_default_only`), effective deadline = min of candidates, duration = `deadline.at - now`. `.timeout(50ms)` with no batch deadline yields `now + 50ms` (not 30 s). Full behaviour table inline | +| Fastly single-`send` streamed request bodies lacked cap/deadline mechanics | §4.3 new bullet — pre-append byte counting against `req.max_request_body_bytes` (over-cap → 400, `StreamingBody` dropped without `finish()`); cooperative between-chunk `budget.deadline.is_expired()` check during upload (stalled → 504, same bounded-cooperative story as the body-read phase); post-upload duration recomputed from `budget.deadline.remaining()` so upload time counts against the budget | +| Fastly `send_all` wall-clock-observed bound overstated for ordered harvest | §3.3.4 new paragraph distinguishing per-slot **result correctness** (host-side, bounded by the slot's own budget) from per-slot **wall-clock-observed delivery** (bounded by `max_over_remaining_slots(effective_at_dispatch)` because harvest is ordered). For uniform-budget fan-outs the bounds coincide; heterogeneous-budget callers are warned | +| `dispatch_budget` could extend an original absolute deadline; `remaining() == None` ambiguity | §3.3.2: single `now` snapshot; expired-deadline check uses `dl.at <= now` directly (no `remaining()` round-trip); duration derived from the chosen absolute deadline and the same `now`, never `Deadline::after(duration)` from a later moment | +| `OutboundRequest` struct snippet missed `max_request_body_bytes` | §3.1.3 struct now lists the field with its default annotation | +| Fastly dynamic-backend warning promised but missing from `ensure_capabilities` | §3.5.3: explicit `if adapter_name == "fastly" && caps.required.contains(&Capability::OutboundHttp)` block in the pseudocode that emits the dynamic-backends `log::info!` reminder | +| Stale "originating deadline" wording | §3.1.3 (`into_bytes_bounded`), §3.3.3 (Streamed body paragraph + practical-implications bullets), and §4.2 / §4.4 / §4.3 adapter notes all rephrased to "**effective-budget deadline**" — wrappers apply for every request regardless of whether `req.deadline` was set | +| Stale "body phase checks `deadline`" line | §3.3.4: replaced with "body phase checks `budget.deadline`" | + +## Appendix K — Review round 11 resolutions + +| Review finding | Resolution | +| --- | --- | +| `dispatch_budget` pseudocode wouldn't compile against a `Deadline` with a private field | §3.3.1: `Deadline` gains `pub fn instant() -> web_time::Instant` and `pub fn at_instant(instant)`; the pseudocode uses `dl.instant()` / `Deadline::at_instant(now + d)` / `.min_by_key(\|d\| d.instant())` | +| Fastly streamed-upload deadline was overstated | §4.3: deadline enforcement on Fastly streamed uploads is now explicitly **bounded-cooperative *between* yielded chunks only** — a stalled `stream.next().await` cannot be preempted on Fastly (no guest async timer). Apps that need real-time enforcement against an untrusted upload source must use `Body::Once` on Fastly. The capability matrix marks Fastly streamed-upload deadline as `BestEffort` for the stream-source-stall case. §5.4 test row updated to "stalled upload **between** yielded chunks → 504" and explicitly names the BestEffort gap | +| Axum / CF `send_one` had stale operation ordering | §4.1 + §4.2 rewritten as numbered flows: (1) compute budget, (2) drain streamed request body under `budget.deadline`, (3) recompute remaining from `budget.deadline.remaining()`, (4) construct and send platform request. Stale "set timeout then drain later" wording removed | +| Appendix J test rows were outside the §5.4 markdown table | Blank line that broke the table removed at the trailer row → Fastly-upload row boundary | +| Stale "originating deadline" wording in normative areas | `into_bytes_bounded_until` docs, §3.3.2 streamed-mode line, and the §5.4 row all changed to `dispatch_budget(req).deadline` / "effective-budget deadline," explicitly noting the wrapping is unconditional (not gated on `req.deadline.is_some()`) | + +## Appendix L — Review round 12 resolutions + +| Review finding | Resolution | +| --- | --- | +| Fastly `send_all` dropped metadata needed by harvest | §4.3 pseudocode: `Slot::Pending` is now `PendingSlot { pending, budget, response_mode }`; `dispatch(req)` returns `(PendingRequest, DispatchBudget, ResponseMode)`; `harvest(result, &budget, &response_mode)` has everything it needs to enforce body deadline, decompressed-byte cap, and Buffered-vs-Streamed handling per slot | +| Fastly streamed-response deadline was contradictory | §3.1.3 + §4.3: Fastly now wraps streamed response bodies with a **cooperative deadline-aware stream** that checks `budget.deadline.is_expired()` before each yielded chunk and emits `gateway_timeout` past the deadline. Applies to every consumer — `into_bytes_bounded`, `into_bytes_bounded_until`, `into_response()` proxy passthrough — so the deadline cannot be bypassed by choosing a non-helper consumption path | +| Fastly streamed-upload BestEffort gap had no capability hook | §3.5.1 + §3.5.2: new `Capability::StreamedUploadDeadlines` enum variant and `streamed-upload-deadlines` matrix row — `Native` on Axum/CF/Spin, `BestEffort` on Fastly. Apps that need real-time enforcement of stalled `stream.next().await` on uploads declare this required and get a hard build failure on Fastly per the round-5 "required + BestEffort = hard fail" rule | +| `budget.deadline.remaining() == None` after upload was unspecified | §4.1 / §4.2 / §4.3 / §4.4: every adapter explicitly returns `gateway_timeout` *before* constructing/fetching/sending the platform request when the upload consumed the budget | +| the external batch deadline mapping could re-anchor per target | §3.3.2 row rewritten: compute `batch_deadline = Deadline::after(batch_deadline_ms)` **once** at handler entry, then copy that absolute `Deadline` into every target request. The field comment on `OutboundRequest.deadline` (§3.1.3) reinforces the rule. §5.4 has a drift counter-example test | +| RequestContext migration still incomplete around `form_within` and sweep | §3.4.2 API block adds `form_within` (default `1 MiB`, same cache semantics); §7 sweep regex extended to include `fn json<` and `fn form<` for definition sites | +| `Deadline::after` overflow/panic risk | §3.3.1: `Deadline::after(d)` is **saturating** — `Duration::MAX` clamps to the largest representable instant rather than panicking. §5.4 row asserts this | +| Non-UTF-8 request-header policy was split inconsistently | §3.1.4: split is explicit — `OutboundRequest::header(..)` rejects with `bad_request` at construction (loud), `headers_mut()` / `from_request(..)` paths use `normalize_for_dispatch` which **drops + `warn!`** (lossy — doesn't fail an otherwise-good forward over an exotic header). §5.4 covers both paths | + +## Appendix M — Review round 13 resolutions + +| Review finding | Resolution | +| --- | --- | +| `send_all` contradicted the trait contract for streamed request bodies on Axum/CF/Spin | §4.1 / §4.2 / §4.4: each adapter's `send_all` runs a **preflight** that converts any `Body::Stream` slot to `Err(bad_request)` *before* calling `send_one`. The trait contract (§3.1.1) now holds identically on every adapter — `send_all([stream])` never invokes the single-send drain path; index alignment is preserved | +| Streaming proxy-forward depended on adapter response converters not currently streaming | §7 file-by-file: new `src/response.rs` task per adapter. Replaces today's buffer-then-return paths with platform-native streaming sinks (`axum::body::Body::from_stream`, `worker::Body::from_stream`, Fastly `Response::with_streaming_body`, Spin WASI outgoing-body chunk-writes). Buffering is reserved for `Body::Once` | +| `dispatch_budget` still used raw `now + d` (panic path) | §3.3.2: `saturating(dur)` helper uses `now.checked_add(dur).unwrap_or_else(\|\| now + DEADLINE_FAR_FUTURE)` for every candidate (`from_timeout`, `from_default_only`). `Duration::MAX` no longer panics. §5.4 test on `OutboundRequest::timeout(Duration::MAX)` | +| Adapter capability notes were stale ("Native for all five") | §4.1 / §4.2 / §4.3 / §4.4: each adapter's `capability()` line now enumerates the **six** capabilities (`outbound-http`, `outbound-deadlines`, `streamed-upload-deadlines`, `config-store`, `kv-store`, `secret-store`). Fastly's exact tuple is spelled out: `outbound-deadlines` = `BoundedCooperative`, `streamed-upload-deadlines` = `BestEffort`, the rest `Native` | +| `OutboundDeadlines` enum comment misleadingly excluded streamed responses | §3.5.1: comment now reads "across the *entire exchange*: connect + headers + buffered response body **and** the chunk-yield path of a streamed response body (per §3.3.3)" | +| Host normalization wording split | §3.1.3 `from_request` rewritten — `host` is dropped from headers; the **adapter** derives the final value from `req.uri()` at SDK-construction time (§3.1.4 is the single source of truth); `normalize_for_dispatch` re-strips `host` defensively as a safety net | + +## Appendix N — Review round 14 resolutions + +| Review finding | Resolution | +| --- | --- | +| Axum lazy response streaming named an unspecified `Send + 'static` shim | §7 + §4.1: Axum's `response.rs` **buffers** `Body::Stream` to `Bytes` within `max_response_bytes` before constructing the axum response — documented Axum-specific limitation, not a fictional shim. Cloudflare / Fastly / Spin keep true lazy streaming (no `Send` requirement in their WASM guests). New `lazy-streamed-response-passthrough` capability (§3.5.1/2) is `Native` on the three WASM adapters and `BestEffort` on Axum; apps that need lazy Axum streaming declare it required → hard build failure today, with the mpsc-bridge follow-up tracked in §8 risk 6 | +| Fastly streamed-upload overstated what is enforced | §4.3 two-phase decomposition: **source-stream yield** (`stream.next().await`) is `BestEffort` (no preemption); **host write** is `BoundedCooperative` (Fastly applies `between-bytes-timeout` to both read-from-origin and write-to-origin per docs); **between writes** the adapter checks `budget.deadline.is_expired()` after each chunk. The capability label `streamed-upload-deadlines = BestEffort` on Fastly reflects the worst phase; the risk section (§8 risk 7) flags the dependency on Fastly's documented host behaviour | +| Saturating deadline semantics inconsistent | §3.3.1 + §3.3.2: one rule everywhere — clamp `dur` to `DEADLINE_FAR_FUTURE = 365 days` *before* adding to `now` (`saturating(dur)` = `now + min(dur, DEADLINE_FAR_FUTURE)`, with `checked_add` belt-and-suspenders). New `pub const DEADLINE_FAR_FUTURE` exposed in the API. Behaviour table now shows the clamp explicitly and adds the `Some(Duration::MAX)` row | +| `send_all` preflight needed adapter-facing introspection | §3.1.3 adds `OutboundRequest::is_stream_body() -> bool` (cheap non-consuming check used by adapter preflights) and `from_parts(OutboundRequestParts) -> Result` (disciplined round-trip with URI re-validation). Adapter `send_all` bullets call `is_stream_body()` before `send_one` | +| Test plan missed response-converter rewrite | §5.4 adds Tier 3 rows for CF/Fastly/Spin response converters (first bytes flow before upstream stream ends; stream errors after headers surface to client) and an explicit Axum row asserting buffered behaviour with the documented limitation | +| Bounded-memory wording contradicted itself | §3.4.1 reworded: the **persistent collected buffer** is bounded by `max`; worst-case **transient** memory is `max + sizeof(current_chunk)` (the in-flight chunk briefly coexists with the buffer). Not a whole-process ceiling — batch level bound is in §3.4.4 | + +## Appendix O — Review round 15 resolutions + +| Review finding | Resolution | +| --- | --- | +| Fastly `send_all` buffered body drains serialized | §3.3.4 new bullet + §3.2 "Where 'identical' stops being identical" paragraph: explicit, honest documentation that buffered-body drain on Fastly runs in harvest order, so wall-clock = `max(headers) + Σ body_drain_times` vs. `max(headers + body_drain_times)` on Axum/CF/Spin. Small bodies (fan-out batches) are unaffected; large bodies should switch to `Streamed` mode. §8 risk 8 tracks the future interleaved-chunks enhancement | +| Capability metadata inconsistent ("six" / no Fastly tuple) after adding `LazyStreamedResponsePassthrough` | §4.1 / §4.2 / §4.3 / §4.4 `capability()` lines all rewritten to enumerate the **seven** capabilities explicitly. Fastly's tuple is spelled out: `BoundedCooperative` for outbound-deadlines, `BestEffort` for streamed-upload-deadlines, `Native` for the other five | +| Axum buffered fallback had no source for cap | §4.1 + §7 + §3.5.2 footnote 3: introduced `AXUM_RESPONSE_STREAM_BUFFER_BYTES` (defined Axum-adapter constant, default 16 MiB). The per-outbound-request `max_response_bytes` is unavailable by the time the response converter runs; the constant is what the converter uses. Over-cap → 502. Apps that need a different ceiling override the constant at adapter init | +| Streamed error chunks were specified as `EdgeError` but stream is `anyhow::Error` | §7 `src/body.rs` task: **change `Body::Stream`'s error type from `anyhow::Error` to `EdgeError`** so deadline-aware wrappers' `gateway_timeout` chunks survive round-trip without downcasting. In-tree call sites updated mechanically; externally-supplied streams map source errors into `EdgeError::internal(..)` | +| UTF-8 header builder rejected valid non-ASCII | §3.1.4: `OutboundRequest::header(..)` constructs `HeaderValue` via `HeaderValue::from_bytes(value.as_bytes())` (not `from_str`, which is visible-ASCII only), then runs EdgeZero's own `std::str::from_utf8` check. Valid non-ASCII UTF-8 (`café`) round-trips; non-UTF-8 bytes → `bad_request`. Adapter multi-value handling: per-value UTF-8 check, drop only invalid entries, preserve valid siblings (matters for `set-cookie`). §5.4 has the `café` round-trip row | +| Response-converter tests were Tier 3-only | §5.4: response-converter rows for CF/Fastly/Spin (lazy passthrough, stream-error-after-headers) and Axum (buffered cap) are now **Tier 2 as well as Tier 3** — driven by a `MockOutboundClient`-fed stream in-process, so the normal adapter contract suite catches converter regressions without waiting for runtime CI | +| Stale "maximum representable" wording in test row | §5.4: `Duration::MAX` row now asserts the **365-day clamp** to `DEADLINE_FAR_FUTURE`, not an Instant::MAX-style behaviour. Matches §3.3.1/§3.3.2 | + +## Appendix P — Review round 16 resolutions + +| Review finding | Resolution | +| --- | --- | +| Fastly per-slot correctness contradicted the buffered-drain caveat | §3.3.4: per-slot correctness bullet is now explicitly **headers-phase only**; the buffered-body bullet states that a slot can return `gateway_timeout` because earlier slots monopolised harvest, and the `send_all` contract on Fastly **admits harvest-order-induced 504s** in Buffered mode. §5.4 has two rows: headers-phase result correctness, and body-phase harvest-order timeout | +| Streamed-mode "consume chunks concurrently" mitigation had no API | §3.3.4 + §3.2: the Streamed-mode recommendation is **dropped** — Fastly has no concurrent body-drain primitive (no guest reactor), and EdgeZero has no API that recovers parallel large-body fan-out on Fastly. Apps that need that should target a different adapter, restructure their topology, or wait for the interleaved-drain follow-up in §8 risk 8 | +| Header builder signature could not satisfy the UTF-8 rule | §3.1.3: signature changed from `TryInto` to `AsRef<[u8]>`. The implementation reads bytes, runs the EdgeZero UTF-8 check, then calls `HeaderValue::from_bytes` (not `from_str`). Valid non-ASCII UTF-8 (`café`) round-trips; non-UTF-8 bytes → `bad_request`. `&str`, `String`, `&[u8]`, `Vec`, `HeaderName`, `HeaderValue` all `AsRef<[u8]>` | +| Post-header stream errors had no defined wire behaviour | §3.1.1 trait rustdoc + §5.4 row: once response headers are sent, HTTP cannot change status, so adapters **abort the downstream body** (TCP close on HTTP/1.1, RST_STREAM on HTTP/2) and `log::warn!` the originating `EdgeError`. Clients observe an early close; the synthetic 502/504 only applies when the error happens before headers go out | +| Public `Deadline::at_instant` bypassed the far-future clamp | §3.3.2 pseudocode: `from_caller` is re-clamped to `now + DEADLINE_FAR_FUTURE` inside `dispatch_budget`. A caller constructing a 100-year `Deadline` via `at_instant` is honoured up to the clamp and no further | +| Fastly backend hash used 64-bit FNV — collision risk for transport identity | §4.3: hash changed to **SHA-256 truncated to 128 bits** (`format!("ez_{:032x}", sha256_128(identity))`). Belt-and-suspenders: in-memory `HashMap` per `send_all` call, fail closed with `EdgeError::internal("dynamic backend name collision — refusing to reuse")` if a name reappears with a different identity | +| `AXUM_RESPONSE_STREAM_BUFFER_BYTES` configurable in prose only | §4.1 + §3.5.2 footnote 3: this is now a **fixed compile-time constant (16 MiB)**, no runtime override. Adding an `AxumOutboundConfig` plumbing layer is tracked in §8 risk 6 alongside the mpsc-bridge follow-up | + +## Appendix Q — Review round 17 resolutions + +| Review finding | Resolution | +| --- | --- | +| `outbound-deadlines` Fastly claim conflicted with harvest-order false 504s | §3.5.1: new capability `send-all-slot-isolation` separates "each slot's result reflects what it would have produced in isolation" from the single-exchange deadline guarantee. Matrix marks it `Native` on Axum/CF/Spin and `BestEffort` on Fastly. `outbound-deadlines` footnote 1 now explicitly scopes the Fastly `BoundedCooperative` claim to single `send` + headers phase of `send_all`; the cross-slot body caveat is owned by footnote 4 (the new capability). One label, one meaning | +| Risk 8 recommended an impossible Fastly mitigation | §8 risk 8 rewritten: there is **no** EdgeZero mitigation that recovers parallel large-body fan-out on Fastly. Apps target a different adapter, restructure the topology, or wait for the interleaved-drain follow-up. The Streamed-mode-consume-concurrently text is gone. Cross-reference to `send-all-slot-isolation` so the build-time enforcement is discoverable | +| Behaviour table didn't reflect `at_instant` clamp | §3.3.2: table rows for `req.deadline = Some(d)` use `clamped(d) = Deadline::at_instant(d.instant().min(now + DEADLINE_FAR_FUTURE))` instead of raw `d`. New row covers the 100-year `at_instant` case landing on the 365-day clamp | +| Fastly pseudocode comment said "~max(latency), not the sum" | §4.3 pseudocode comment updated: headers phase is `~max(header_arrivals)`; buffered body drain runs serially in harvest order, so total wall-clock is `~max(header_arrivals) + Σ body_drain_times`. Matches §3.3.4 | +| Spin wildcard `*` only rendered HTTPS | §3.5.4: wildcard now renders both schemes — `["https://*:*", "http://*:*"]` — matching the "any host" semantics and the http loopback contract tests. Specific bare hosts still default to https | +| §3.1.4 prose used `.as_bytes()` after signature switched to `AsRef<[u8]>` | §3.1.4: `value.as_bytes()` → `value.as_ref()` so the prose matches the builder's actual `AsRef<[u8]>` bound (which covers `&[u8]`, `Vec`, `HeaderValue`, in addition to `&str` / `String`) | +| Fastly collision detection was per-`send_all` only | §4.3: the collision-detection `HashMap` lives on the `FastlyOutboundClient` itself (one per request context) and applies to single `send`, `send_all`, and across calls. `Backend::builder` returning `NameInUse` is caught and the registered identity is verified — match → reuse, mismatch → fail closed with `EdgeError::internal` | + +## Appendix R — Review round 18 resolutions + +| Review finding | Resolution | +| --- | --- | +| `send_all-slot-isolation` would not deserialize (kebab-case mismatch) | Renamed to `send-all-slot-isolation` everywhere — matrix, footnote, prose, test rows, enum doc. `#[serde(rename_all = "kebab-case")]` now produces the same string the spec uses | +| Fastly dynamic backend identity omitted timeout settings | §4.3: identity tuple is now `scheme + ":" + host + ":" + port + ":" + tls_mode + ":" + budget_ms` — distinct budgets to the same host get distinct dynamic backends, so a 50 ms slot and a 3 s slot don't silently share one timeout config. Homogeneous-budget fan-out batches still share one backend per host. Per Fastly's `BackendBuilder` docs, dynamic backend names cannot duplicate in a session and sameness includes settings — the identity must reflect every setting | +| `capability()` tuples missing `send-all-slot-isolation` on every adapter | §4.1 / §4.2 / §4.3 / §4.4 `capability()` lines updated to enumerate **eight** capabilities. Fastly's tuple is `outbound-deadlines = BoundedCooperative`, `send-all-slot-isolation = BestEffort`, `streamed-upload-deadlines = BestEffort`, the rest `Native`. Axum / CF / Spin are `Native` for `send-all-slot-isolation` | +| Trait `send_all` doc still said "behaves identically across adapters" | §3.1.1 trait rustdoc adds an "Identical scope" paragraph: identical is **input/output contract** (preflight, index alignment, per-slot Ok/Err shape); cross-slot timing is governed by `send-all-slot-isolation`. §3.2 paragraph also rewritten to match | +| `RequestContext::into_request()` silently returned `Body::empty()` for Poisoned/Draining | §3.4.5: `into_request() -> Result` is now **fallible**. `Draining` → `internal`; `Poisoned(err)` → `Err(err.clone_as_edge_error())`; only `Taken` returns `Ok(Body::empty())` (the caller already consumed the body explicitly). A poisoned read can no longer silently become an empty proxy-forward | +| Test plan missed the new capability's critical behaviour | §5.4: added rows for (a) required `send-all-slot-isolation` on Fastly → hard build fail; (b) Fastly same-host mixed-budget `send_all` → distinct backends per `budget_ms` (catches the timeout-identity bug); (c) `into_request()` after poison returns `Err`, not empty | + +## Appendix S — Review round 19 resolutions + +| Review finding | Resolution | +| --- | --- | +| Fastly streamed-upload "remaining-budget host timeout adjustment" overclaimed | §4.3: the post-upload bullet is honest now — Fastly sets host timeouts once at dispatch and the SDK does not expose mutation, so for `send_async_streaming` the response-phase host timeout is locked to `budget.duration`. The adapter checks `budget.deadline.is_expired()` cooperatively before `wait()` (drop + 504 if exhausted), but a non-expired remaining of e.g. 10 ms can still be followed by up to one between-bytes-timeout of host blocking — the same `BoundedCooperative` overshoot bound. Apps that need tight end-to-end wall-clock pass a buffered request body | +| Test plan asserted impossible Fastly "returns before constructing/sending" | §5.4: the upload-budget-exhaustion row is split per-adapter. Axum/Cloudflare buffer the streamed request body before constructing the platform request, so a budget-exhausted drain genuinely returns *before* sending. Fastly's `send_async_streaming` and Spin's WASI outgoing-body both begin sending while chunks flow, so **partial upstream send is expected** on those two — the test asserts that contract honestly rather than the impossible "no partial send anywhere" claim | +| Fastly upload deadline check missed the resumed-after-deadline case | §4.3: the "Around each chunk" bullet now requires **two** `budget.deadline.is_expired()` checks per iteration — once immediately after `stream.next().await` returns and **before** `write_all` (catches a stream that stalled past the deadline and then yielded), and once after the successful `write_all` / `flush()` (catches a write that pushed the budget over). | +| Stale "into_request returns Body::empty()" test row | §5.4: row 1947 rewritten — `into_request()` after poison returns `Err(stored_err)`, matching §3.4.5 and the round-18 fallible-`into_request` change | +| `budget_ms` could collapse sub-millisecond budgets to 0 | §4.3: identity tuple uses `max(1, dispatch_budget(req).duration.as_millis())` — a 100 µs and a 900 µs slot don't share a backend with `0 ms` timeouts. Apps wanting sub-ms wall-clock should not target Fastly (host between-bytes-timeout itself is ms-granular) | +| Appendix Q missing — file jumped P → R with an orphan table | Added the `## Appendix Q — Review round 17 resolutions` heading before the orphan table; round-17 and round-18 appendices are now correctly numbered and ordered | + +## Appendix T — Review round 20 resolutions + +| Review finding | Resolution | +| --- | --- | +| Streamed response decompression was underspecified | §3.4.1: explicit **streaming-decompressor design** — each WASM adapter wraps the platform raw byte stream with an incremental decoder (`flate2::read::GzDecoder` for gzip, `brotli::Decompressor` for brotli) configured chunk-at-a-time, counts decompressed bytes against the cap, and strips `content-encoding` / `content-length` at construction. Lazy passthrough + decompressed-byte caps + correct header stripping all hold simultaneously. Axum buffers anyway, so a non-streaming decoder is fine there | +| `budget_ms` was floored, not ceiled | §4.3: identity tuple uses **true ceil-to-ms** — `((duration.as_nanos() + 999_999) / 1_000_000).max(1)`. A 1.9 ms budget no longer becomes 1 ms. The same ceiled value is what's fed into the host timeouts, so the identity tuple and the actual host configuration always match. The §3.3.4 "host timeouts = `budget.duration`" wording is documented as shorthand for ceil-to-ms; the body-phase `budget.deadline.is_expired()` check still uses the exact original `Deadline` | +| Fastly backend collision map wasn't implementable | §4.3: the field is `Mutex>` — interior mutability with `Send + Sync`. The map stores the registered `Backend` handle so subsequent calls skip a fresh host call. **The lock is not held across host calls**: build the backend first, then insert under the lock; on concurrent duplicate-with-same-identity the extra handle is discarded; on duplicate-with-different-identity the adapter fails closed | +| Stalled streamed-upload test row overclaimed uniform behaviour | §5.4 row split into two: **host-write phase** stops at `budget.deadline` on every adapter (Axum/CF/Spin platform timer; Fastly host between-bytes-timeout); **source-pull phase** preempts on Axum/CF/Spin but **cannot preempt on Fastly** (BestEffort per `streamed-upload-deadlines`). No false uniform claim | +| `BestEffort` definition was timing-specific but covers Axum's deterministic-buffer case | §3.5.1: `CapabilitySupport::BestEffort` doc broadened — "available with a documented limitation; can be timing (unbounded cooperative) **or functional** (deterministic behaviour differs from `Native`, e.g. Axum buffers a body that other adapters stream)." CLI error text in §3.5.3 mirrors the broadened meaning | +| Older appendices contained superseded claims | Added the "Appendix index — historical, not normative" note before Appendix A: the round-by-round appendices are a paper trail; the authoritative content is §1–§8, and active sections win when an older appendix entry disagrees. No per-entry retroactive edits — the index disclaimer covers the whole history | + +## Appendix U — Review round 21 resolutions + +| Review finding | Resolution | +| --- | --- | +| Streamed decompressor had undefined cap ownership | §3.4.1 rewritten: the decoder **only decodes / strips compressed-only headers / surfaces decode errors** — no byte counting in the wrapper. Cap ownership is explicit: Buffered → adapter helper; Streamed + `into_bytes_bounded` → helper's own pre-append check; Streamed + `into_response()` passthrough → **deliberately no EdgeZero cap** (the platform wire is the budget; capping a transparent proxy stream would silently truncate). Removes the `ResponseMode::Streamed has no max_bytes` / "decoder enforces cap" conflict | +| Fastly streamed-upload test rows asserted exact `budget.deadline` for host-write stalls | §5.4: the host-write row now distinguishes Axum/CF/Spin ("at the deadline, real preemption") from Fastly ("within one between-bytes-timeout past `budget.deadline` — bounded overshoot, BoundedCooperative"). The source-pull row keeps its existing per-adapter split | +| Spin's `streamed-upload-deadlines = Native` source-pull guarantee was not specified | §4.4 streamed-request-bodies bullet: **two distinct races** — (1) `futures::select!` around `source_stream.next()` against a wasi monotonic-clock timer (this is what makes the source-pull preemption real on Spin); (2) host-write race around `OutgoingBody::write` against the same timer. The `Native` label now has a spec to point at, not just a claim | +| Fastly ceil-to-ms helper inconsistent across sections | §3.3.4 introduces `fn fastly_timeout_ms(budget) -> u64` (true ceil-to-ms, with `max(1, ..)`) and uses it for `set_connect_timeout_ms` / `first_byte_timeout` / `between_bytes_timeout`. §4.3 dynamic-backend identity uses the same helper, so identity and host configuration always match. The earlier "= `budget.duration`" wording is replaced | +| Streamed decompressor guidance bypassed the repo's existing async helpers | §3.4.1 implementation-hooks paragraph: the migration **evolves** the existing async decoders at `compression.rs:15` / `41` (change their error type from `anyhow::Error` to `EdgeError` per round 15, then lift them into a shared core module reused by CF/Fastly/Spin) rather than writing new `flate2::read::GzDecoder` / `brotli::Decompressor` wrappers from scratch | + +## Appendix V — Review round 22 resolutions + +| Review finding | Resolution | +| --- | --- | +| `send_all` + `Streamed` responses broke isolation/deadline | §3.1.1 + §4.1 / §4.2 / §4.3 / §4.4 preflight: any request with `response_mode = Streamed` yields `out[i] = Err(EdgeError::bad_request(..))` *before* `send_one` is invoked. `send_all` is now buffered-only on **both** sides — request body **and** response. Removes the cross-slot streamed-body deadline-lifetime hazard by construction; `send-all-slot-isolation = Native` on Axum/CF/Spin stays honest. Streamed responses use single `send` and the app orchestrates concurrency itself on reactor-bearing adapters | +| Fastly timeout setters were on the wrong type (not on `Request`) | §3.3.4 pseudocode now configures timeouts on `BackendBuilder` per Fastly 0.12.1 docs: `Backend::builder(&name, &host).connect_timeout(t).first_byte_timeout(t).between_bytes_timeout(t).finish()?`. Same `t = Duration::from_millis(fastly_timeout_ms(&budget))` is also folded into the dynamic-backend identity (§4.3), so the cached `Backend` and a freshly-built one always carry identical timeouts | +| "Homogeneous-budget shares one backend" was not actually guaranteed | §3.3.2: `dispatch_budget(req, now)` now takes `now` as a parameter (not snapshotted internally). `send_all` takes **one** `now` snapshot at the start of the call and passes it to every per-slot `dispatch_budget`, so a shared caller `Deadline` produces the same `duration` and the same ceiled `budget_ms` for every slot — and therefore one backend identity per host. §4.3 spells out the dependency as a normative requirement, not an optimisation | +| Fastly stalled-upload "between yielded chunks" row claimed exact `budget.deadline` | §5.4: row now says "504 **within one between-bytes-timeout past `budget.deadline`** — bounded overshoot, BoundedCooperative — not exact deadline." Matches §3.3.4 and the §4.3 between-write check semantics | +| Streamed decompressor implementation hook pointed at the wrong file | §3.4.1: implementation-hooks paragraph no longer pins a Spin path; it says the async decoders are at `compression.rs:15` / `41` inside one of the adapters (Spin's `decompress.rs` is a separate buffered slice decoder, not the async helper). §7 migration sweep includes a one-line audit step to confirm the actual source file before the refactor | + +## Appendix W — Review round 23 resolutions + +| Review finding | Resolution | +| --- | --- | +| Stale `dispatch_budget(req)` call signature in adapter notes | §4.1 / §4.2 / §4.3 pseudocode now use `dispatch_budget(req, batch_now)` / `dispatch_budget(req, now)`. Each `send_all` flow snapshots `let batch_now = web_time::Instant::now()` once before fanning out; per-slot `send_one` calls accept and use that `now`. `send` (single request) snapshots inline. The Fastly backend identity guarantee depends on this — explicit in §4.3 | +| "One concurrency primitive" vs `send_all` rejecting Streamed wasn't reconciled | §3.4.4 batch memory model: dropped the Streamed-mode row entirely — `send_all` is buffered-only on both sides, so there is no `send_all`-with-`Streamed` memory model. The single-`send` Streamed path is the explicit non-portable lane for lazy bodies. Older "switch to Streamed mode" guidance is now confined to historical appendices | +| `send_all` preflight needed `is_stream_response()` accessor | §3.1.3 adds `OutboundRequest::is_stream_response() -> bool` alongside `is_stream_body()`. Adapter preflights call both, reject either to `bad_request`, never consume the request | +| Fastly `send_all` pseudocode still carried `ResponseMode::Streamed` through harvest | §4.3 pseudocode rewritten: `PendingSlot` carries `max_bytes: usize` (not `ResponseMode`), because preflight rejects Streamed before dispatch. The dispatch helper returns `(PendingRequest, DispatchBudget, usize)` and harvest comments confirm only Buffered survives. `batch_now` is explicit at the top of the function | +| Manifest `[capabilities.outbound].hosts` validation was promised but not modelled | §3.5.1: `ManifestOutboundCapability::hosts` gains `#[validate(custom(function = "validate_outbound_hosts"))]`, a custom validator that walks each entry through the §3.5.4 accepted-form table — wildcard, scheme-prefixed (`http`/`https` only), `host:port`, bare host (DNS label or `*.subdomain`). Empty strings / bad schemes / missing authorities all reject at manifest-load time. §5.4 covers the cases | +| Test matrix missed `stream_response()` + `send_all` rejection | §5.4 adds rows for `is_stream_response()` accessor truthiness and for `send_all` rejecting `stream_response()` requests with per-slot `bad_request`. Tier 1 + Tier 2. Also adds the shared-`now` test that catches the backend-identity drift bug | +| Streamed response cap-ownership prose was inconsistent | §3.1.1 trait rustdoc rewritten: over-cap on streamed bodies comes from bounded helpers (`into_bytes_bounded[_until]`, `json_bounded[_until]`) or Axum's response converter — NOT from raw `into_response()` passthrough, and NOT from the streaming decoder (which deliberately does no byte counting per §3.4.1). The trait, §3.4.1, and the streamed-body wrapper now agree | +| Decompressor hook pointed at an adapter when the helpers live in core | §3.4.1: implementation-hooks paragraph now says the decoders **live in `edgezero-core` at `compression.rs:15` / `41`** and the migration **evolves them in place** (no lift, no relocation). CF/Fastly/Spin converters call into the existing core helpers | + +## Appendix X — Review round 24 resolutions + +| Review finding | Resolution | +| --- | --- | +| Fastly HTTPS dynamic backends weren't actually configured for HTTPS | §3.3.4 builder example now configures SSL per `tls_mode`: `Tls` → `.enable_ssl().sni_hostname(host).check_certificate(host)`; `Plain` → `.disable_ssl()`; `override_host(host)` in both. Generalises the existing pattern at `crates/edgezero-adapter-fastly/src/proxy.rs:120`. Identity tuple already includes `tls_mode` (§4.3) so cached and fresh backends match SSL config | +| `DEADLINE_FAR_FUTURE = 365 days` exceeded Fastly's `u32` ms ceiling | §3.3.1: clamp reduced to **7 days**, well under Fastly's ~49.7-day limit (`u32::MAX` ms). `fastly_timeout_ms` adds a `debug_assert!` + `min(u32::MAX - 1)` belt-and-suspenders saturation in case the clamp is bypassed elsewhere. Behaviour table and test rows updated; no legitimate caller is affected | +| Spin and §3.3.4 still used stale `dispatch_budget(req)` signature | §4.4 mirrors Axum/CF: `send_all` snapshots `let batch_now = web_time::Instant::now()` once; private `send_one(req, now)`; single `send` snapshots inline. §3.3.4 Fastly precision sample code now uses `dispatch_budget(req, now)` | +| SHA-256 backend-name hash needed an explicit dependency | §7 Fastly file-summary entry now adds **`sha2` workspace dependency** to `edgezero-adapter-fastly/Cargo.toml`, with the audit step "if `edgezero-core` already exposes a SHA-256 helper, use that instead." Either way the dep is declared in this migration, not assumed transitive | +| "One concurrency primitive" overclaim after Streamed got rejected | §1.4 locked-decision reworded to **"one portable buffered fan-out primitive"** — streamed-response fan-out is explicitly non-portable; single `send` is the path for streamed responses on reactor-bearing adapters (Axum/CF/Spin). §8 risk 8 no longer suggests Streamed mode as a `send_all` workaround | +| `BestEffort` CLI text said "no documented bound" but the broadened def covers functional deviations | §3.5.3 bullet rewritten: required + BestEffort fails because BestEffort means a **documented deviation from Native** (timing OR functional). The matrix footnotes describe the specific deviation per capability | +| Host/authority handling didn't specify non-default ports | §3.1.3 `from_request` doc + §5.4 row: `Host` includes the explicit port when the URI carries one (`http://localhost:3000` → `Host: localhost:3000`; `https://example.com` → `Host: example.com`). Adapters derive from `req.uri()` and never re-read `req.headers()` | + +## Appendix Y — Review round 25 resolutions + +| Review finding | Resolution | +| --- | --- | +| Fastly dynamic backend construction dropped explicit ports | §3.3.4 builder example splits the URI into **three distinct values** — `backend_target = "host:port"` (passed to `Backend::builder` as the connection target, generalising the existing `host_with_port` precedent at `crates/edgezero-adapter-fastly/src/proxy.rs:108`), `host_authority = req.uri().authority()` (passed to `.override_host()` so the outgoing Host header keeps explicit ports per §3.1.3), and `sni_hostname = req.uri().host()` (passed to `.sni_hostname()` / `.check_certificate()` — SNI and certificate verification are not port-qualified). §5.4 Fastly SSL/override row updated to assert all three values on `https://example.com:8443` and `http://example.com:8443` | +| §3.3.4 stale `dispatch_budget(req)?` sample | The Fastly precision sample now explicitly snapshots `let now = web_time::Instant::now();` and calls `dispatch_budget(req, now)?`, with a comment clarifying single `send` snapshots inline while `send_all` passes `batch_now` (round 23) | +| `DEADLINE_FAR_FUTURE = 365 days` references in prose | Active prose updated to 7 days — `Deadline::after` doc comment, `dispatch_budget` saturating-helper comment, "100-year via at_instant" sentence in §3.3.2. Historical appendix entries retain the original 365-day language per the appendix-index note (round 20) | +| `send_all` rustdoc "per `ResponseMode`" was stale | §3.1.1: per-slot `Ok`/`Err` paragraph rewritten to say surviving slots match `send`'s **Buffered-mode** semantics — streamed-mode `Ok`-means-headers-only doesn't apply because preflight rejects streamed responses | + +## Appendix Z — Review round 26 resolutions + +| Review finding | Resolution | +| --- | --- | +| Fastly backend identity didn't actually pin Host override | §3.1.3 constructors now **canonicalize** the URI: userinfo is **rejected** (`bad_request`) so credentials never end up in `override_host`; default ports (`:443` for https, `:80` for http) are normalised away so `https://example.com` and `https://example.com:443` produce identical `OutboundRequest`s. With canonicalization in place the §4.3 identity tuple `(scheme, host, resolved_port, tls_mode, budget_ms)` is sufficient — the Host override is a deterministic function of those fields, not a separate input. §5.4 adds the two parity tests | +| §3.3.4 stale `dispatch_budget(req)?` normative prose | The "Fastly precision" paragraph now says `dispatch_budget(req, now)?` with the explicit note: single `send` snapshots `now` inline, `send_all` passes `batch_now`. Matches the code block immediately below | +| §7 Fastly file summary missing round-25 three-value split | §7 Fastly entry rewritten to spell out the three-value split — `Backend::builder(name, "host:port")` connection target, `.override_host(host_authority)` for the Host header (canonicalized authority, ports preserved when non-default), `.sni_hostname(sni_host).check_certificate(sni_host)` for SNI/cert (host-only). Matches the §3.3.4 sample and §5.4 test row | +| `send-all-slot-isolation` footnote 4 gave the wrong "consumer unaffected" reason | The shared-deadline reason was a non-sequitur — §3.3.4's harvest-order false 504s can happen even with one deadline. The footnote now says **typical small-body fan-outs are unaffected because fan-out response bodies are expected to be small** (the external batch protocol JSON, sub-millisecond drain hostcalls), making the serial-drain wall-clock negligibly different from concurrent | +| `DEFAULT_*` constants used but not declared in active API snippets | §7: `src/time.rs` summary now lists `pub const DEFAULT_NO_DEADLINE_BUDGET = Duration::from_secs(30)` and `pub const DEADLINE_FAR_FUTURE = Duration::from_secs(7 * 24 * 60 * 60)`. `src/outbound.rs` summary now lists `pub const DEFAULT_MAX_RESPONSE_BYTES: usize = 1 MiB` and `pub const DEFAULT_OUTBOUND_REQUEST_BODY_BYTES: usize = 8 MiB`. Implementers have a single place to copy from | + +## Appendix AA — Review round 27 resolutions + +| Review finding | Resolution | +| --- | --- | +| `[capabilities.outbound].hosts` validator was too permissive | §3.5.1 `validate_outbound_hosts` doc rewritten as **host-authority-only plumbing**: rejects userinfo (`https://u:p@x`), path (`/p`), query (`?q`), fragment (`#f`), out-of-range / non-numeric ports, and any scheme other than `http`/`https`. Accepts wildcards, IPv6 (`https://[::1]`), `host:port`, scheme-prefixed forms. §5.4 row enumerates every reject and accept case | +| Cloudflare streamed-request upload path was ambiguous | §4.2 capability bullet clarified: `worker::Body::from_stream` is for the **response-out direction** (`lazy-streamed-response-passthrough`). The **outbound-request upload** still drains `Body::Stream` to `Bytes` first per `send_one`'s flow — `send_async`-style streamed uploads aren't part of this migration, and the worker SDK's request-body shape differs from `Body::from_stream`. The bullet now explicitly says "don't conflate the two" | +| URI canonicalization didn't include scheme/host case | §3.1.3 adds **lowercase scheme + host** to the canonicalization steps (per RFC 3986 §3.1 / §3.2.2 — both are case-insensitive). `https://EXAMPLE.com`, `HTTPS://example.com`, `https://example.com` produce identical requests; path / query / fragment remain case-preserving (they're case-sensitive per spec). §5.4 adds the parity test | +| §1.4 locked decision still said `send_all` "behaves identically" | Reworded: input/output contract is identical (preflight, index alignment, Ok/Err shape); **cross-slot timing is not uniform** — Fastly's body drain runs serially in harvest order. `send-all-slot-isolation` is the capability that lets apps require the stricter guarantee. Matches §3.1.1 / §3.2 / §3.3.4 | +| Compression hook said decoders return `anyhow::Error`; they actually return `io::Error` | §3.4.1: implementation-hooks paragraph corrected. The migration wraps each `io::Error` chunk with `EdgeError::bad_gateway(..)` (decode-side IO failure → 502), distinct from the `gateway_timeout` chunks the deadline wrapper injects | + +## Appendix AB — Review round 28 resolutions + +| Review finding | Resolution | +| --- | --- | +| `batch_now` froze `budget.duration` before preflight / dispatch work | §4.3 adds an explicit **"Dispatch-overhead slack, documented"** paragraph: backend identity uses the bucketed `budget_ms` (host enforces it from SDK arming time, so dispatch-overhead lets a request live up to `now_at_send_async − batch_now` ms past the absolute deadline on the dispatch+headers phase). Body drain still does cooperative `is_expired()` checks (§3.3.4). §4.4 Spin updated to use **`budget.deadline.remaining()`** at the moment the SDK timer is armed, matching Axum/CF's step 3 (round 23). Apps needing exact dispatch+headers absolute-deadline enforcement target a non-Fastly adapter | +| Capability enforcement omitted `edgezero dev` | §3.5.3 + §7: `ensure_capabilities` now runs in `handle_build`, `handle_serve`, `handle_deploy`, **and `handle_dev`** (the dev command implicitly selects Axum via `dev_server::run_dev` / `try_run_manifest_axum`; manifests requiring `lazy-streamed-response-passthrough` must fail there too) | +| URI canonicalization and Spin host plumbing didn't share canonical spelling | §3.5.4: Spin host rendering **first canonicalizes** each entry by the same rules `OutboundRequest` applies to its URI (§3.1.3) — lowercase scheme/host, strip default ports, userinfo/path/query/fragment already rejected by the §3.5.1 validator. The "fallback `scheme://authority` Spin accepts" prose is removed: the validator is authoritative. Rendered `spin.toml` matches what `OutboundRequest::uri()` reports | +| Case-normalization claimed fragments are passed through; `http::Uri` truncates | §3.1.3: **fragments are rejected** at construction with `bad_request("outbound URI must not contain a fragment")`. Silent truncation surprise is gone. Case-preserving claim now applies only to path and query (which `http::Uri` does preserve, and which RFC 3986 leaves case-sensitive) | +| `get`/`post` `TryInto` excluded already-built `Uri` | §3.1.3: signature loosened to `T: TryInto, T::Error: core::fmt::Display`. Now accepts `&str`, `String`, **`Uri`** (whose `try_into::` has `Error = Infallible`, which does implement `Display`), and any other sensible TryInto. Error message goes into `EdgeError::bad_request` via the `Display` bound. (Round 29 then changed this further to `impl AsRef` for fragment detection — see Appendix AC) | + +## Appendix AC — Review round 29 resolutions + +| Review finding | Resolution | +| --- | --- | +| Fragment rejection wasn't enforceable through generic `TryInto` | §3.1.3: `get`/`post` signature changed to `impl AsRef` — the raw input string is available for `#` detection *before* `http::Uri` truncates. Fragment rejection is now real for string inputs. `new(Method, Uri)` accepts a `Uri` that has already lost the fragment; the asymmetry is documented loudly: use `get`/`post` when constructing from a raw string and you get fragment rejection for free | +| Fastly dispatch-overhead slack weakened `BoundedCooperative` | §4.3 + §7: introduced `pub const BATCH_DISPATCH_SLACK_MAX = Duration::from_millis(25)`. Before each slot's `send_async`, the adapter asserts `Instant::now() - batch_now <= BATCH_DISPATCH_SLACK_MAX`; over-budget slots fail closed with `EdgeError::internal(..)`. Slack is a **hard-bounded constant**, not "scales with preflight." Net guarantee: dispatch+headers overshoot ≤ 25 ms + `budget_ms`; body-phase overshoot ≤ one between-bytes-timeout. Both terms deterministic and testable, so `outbound-deadlines = BoundedCooperative` on Fastly is honest | +| Test matrix stale relative to recent rounds | §5.4 rows updated: case-preserving claim drops "fragment" (now rejected); fragment-rejection row added; `edgezero dev` capability-enforcement row added; Spin canonical-rendered-output row added; Fastly dispatch-overhead-slack row added | +| Manifest accepting uppercase schemes was ambiguous | §3.5.4 makes the canonicalization order explicit: the §3.5.1 validator accepts uppercase schemes/hosts (RFC 3986 says they're case-insensitive), and the §3.5.4 Spin renderer canonicalizes to lowercase before emitting `spin.toml`. `HTTPS://EXAMPLE.com:443` → accepted → rendered as `https://example.com` | +| Appendix index stale (said A–S, file extends through AB+) | Index note updated to "A–AC (and counting)" with an explicit pointer to the last `## Appendix` heading — keeps the historical-vs-normative boundary trustworthy without requiring per-round edits to the index | + +## Appendix AD — Review round 30 resolutions + +| Review finding | Resolution | +| --- | --- | +| Validator said "scheme must be lowercase" while the Spin render accepts uppercase | §3.5.1 validator doc rewritten: scheme matching is **case-insensitive** at the validator (RFC 3986 §3.1) — `HTTPS`, `https`, `Https` all accepted. The §3.5.4 Spin renderer then canonicalizes to lowercase before emitting `spin.toml`. One canonical spelling in the rendered manifest | +| Fastly capability footnote understated the new dispatch slack | §3.5.2 footnote 1 rewritten: `BoundedCooperative` on Fastly has **two documented bounds** — single `send` (zero dispatch drift, body ≤ one between-bytes-timeout) and `send_all` (dispatch+headers ≤ `BATCH_DISPATCH_SLACK_MAX + ms_rounding ≈ 26 ms`, body ≤ one between-bytes-timeout). §4.3 corrects the bound to dispatch delay + ms rounding | +| §6 migration checklist omitted `handle_dev` | §6 CLI bullet lists **`handle_build`, `handle_serve`, `handle_deploy`, and `handle_dev`**. Matches §3.5.3 + §7 | +| Header-value wording overclaimed "exactly valid UTF-8" | §3.1.4: spelled out as **valid UTF-8 *and* valid HTTP header-value bytes** — `HeaderValue::from_bytes` rejects control bytes (`\n`, `\0`, etc.) for header-injection prevention. Two distinct error messages: forbidden-bytes vs invalid-UTF-8 | +| `time.rs` doc said "Deadline is the only thing" | §3.3.1 Deadline doc updated to list the full module contents: `Deadline`, `DispatchBudget`, `dispatch_budget`, public timing constants. The §3.3.5 constraint is "no runtime/timer/platform dep in core," not "value type only" | + +## Appendix AE — Review round 31 resolutions + +| Review finding | Resolution | +| --- | --- | +| Fastly dispatch+headers worst case was ~2× the claimed bound | §3.3.4 / §4.3: the budget is now **phase-split** — `connect_timeout = budget * 1/4`, `first_byte_timeout = budget * 3/4`, `between_bytes_timeout = budget`. Their sum equals `budget.duration`, so the dispatch+headers host enforcement is bounded by `budget.duration` plus `BATCH_DISPATCH_SLACK_MAX + ms_rounding`. The earlier "both set to `t`" wording would have been ~2×; spelled out in the §3.3.4 paragraph and the code block. §5.4 row asserts a single `send` to a connect-hang target fires within `budget.duration + ms_rounding`, not twice | +| Dispatch-slack test couldn't exercise the guard from handler code | §4.3 + §5.4: the test uses an **adapter-internal `#[cfg(test)]` injection hook** (a `Fn`-slot on `FastlyOutboundClient`) invoked between `batch_now` capture and per-slot `dispatch()`. A handler-side `thread::sleep` before `send_all` is explicitly insufficient because it runs before `batch_now` is captured; the test row spells this out | +| Header-value builder doc contradicted §3.1.4 | §3.1.3 builder step 3 rewritten: "values that survive are exactly the ones that are **both** valid UTF-8 **and** valid HTTP header bytes" — a valid-UTF-8 string with a forbidden control byte (`\n`, `\0`) still rejects. Two distinct error messages. §5.4 adds the `\n`/`\0` row (header-injection vectors) | +| Axum response converter stream-error behavior was underspecified | §4.1 response.rs paragraph: full mapping table — `GatewayTimeout` chunk → 504, `BadGateway` chunk → 502, over-cap → 502, other `EdgeError` → its own `status()`. The buffering boundary (no headers yet written) is what enables the clean status mapping, unlike the streaming-passthrough adapters which can only abort the wire after headers. §5.4 row covers each branch | +| Generic BestEffort enforcement test row mentioned only build/deploy | §5.4: row extended to "every adapter-selecting CLI command — `build`, `serve`, `deploy`, `dev` — exits non-zero." Matches §3.5.3 | + +## Appendix AF — Review round 32 resolutions + +| Review finding | Resolution | +| --- | --- | +| Fastly `send_all` opportunistic poll lost `Slot::Done(Err(..))` slots | §4.3 pseudocode: the inner `for j in (i+1)..n` loop now matches **all three** variants — `Slot::Done(r)` preserves preflight/dispatch errors into `out[j]`, `Slot::Taken` is a no-op, only `Slot::Pending(s2)` runs the `poll()` path. Index-aligned per-slot errors survive intact; the generic "slot unresolved" internal error is reserved for true contract bugs | +| 1/4 connect + 3/4 first-byte split causes premature connect failures inside the caller's total budget | §3.3.4 / §4.3: documented explicitly — the split preserves the absolute-deadline upper bound at the cost of the "slow-connect-but-fast-everything-else fits in budget" property. A 1 s `send` with a 300 ms connect fails at the `250 ms` connect slice. §5.4 adds the row that asserts this exact deviation (not just "not 2×"). A configurable phase split is a future change; for now apps that hit it target a different adapter | +| Fastly timeout prose inconsistent + edge case at sub-4 ms budgets | §3.3.4 row + §4.3 code: prose now says "phase timers split per §4.3," not "= `budget.duration`." Code handles `total_ms < 4` by setting `connect = first_byte = total_ms` (the absolute bound degenerates to 2× at sub-4 ms scale where ms rounding dominates anyway). `connect_ms + first_byte_ms == total_ms` for `total_ms ≥ 4` | +| IPv6/IP-literal HTTPS behaviour on Fastly was unspecified | §4.3 code: for IP-literal hosts (`https://[::1]`, `https://127.0.0.1`) the adapter **skips** `.sni_hostname()` (SNI is DNS-only per RFC 6066) and passes the bracket-stripped form to `.check_certificate()` (IP-literal cert verification mode). DNS-name hosts call both setters as before. §5.4 adds the dedicated test row | +| §7 omitted core extractor + compression files | §7 `crates/edgezero-core` block now lists `src/extractor.rs` (extractor migration, `DEFAULT_INBOUND_JSON_BYTES = 8 MiB`, `DEFAULT_INBOUND_FORM_BYTES = 1 MiB`, `ValidatedJsonWithin` / `ValidatedFormWithin`) and `src/compression.rs` (evolve in place — error type `io::Error` → `EdgeError::bad_gateway`, shared by CF/Fastly/Spin response converters) | +| Dispatch-slack diagnostic blamed handler CPU | §4.3 paragraph rewritten: diagnostic explicitly names **adapter-side** work (preflight + dynamic-backend lookup/creation + SDK setup), not handler code. Handler code runs before `batch_now` is captured and cannot trip the guard — the wording prevents operator confusion | + +## Appendix AG — Review round 33 resolutions + +| Review finding | Resolution | +| --- | --- | +| `outbound-deadlines = BoundedCooperative` on Fastly was still too strong given the phase-split deviation | §3.5.1 + §3.5.2: new capability `outbound-flexible-phase-budget` — Native on Axum/CF/Spin (single total timeout), **BestEffort on Fastly** (rigid 1/4:3/4 split per §4.3). Apps that need elastic phase budget declare it required and get a hard build failure on Fastly. `outbound-deadlines` keeps its BoundedCooperative meaning (absolute upper bound); the new capability isolates the "no premature phase failure" property | +| Fastly `NameInUse` recovery overclaimed identity verification | §4.3: the adapter cannot fully verify identity for an externally-registered backend (Fastly's `Backend::from_name` getters don't round-trip every builder field — notably SNI / cert hostname). The adapter now **fails closed** with `EdgeError::internal(..)` on `NameInUse` for names not already in its own collision map. Names in the map are reused from the cached `Backend` handle without a fresh `Backend::builder` call, so the path doesn't fire for normal dedupe | +| Fastly code block used non-existent `fastly_req.with_backend(&backend)` | §4.3 code corrected: `let pending = fastly_req.send_async(&backend)?;`. Fastly's `Request` API attaches the backend at send time via `impl ToBackend` — there is no `with_backend` setter. §7 file summary echoes the correction | +| Sub-4 ms timeout degeneracy contradicted "sum = budget" claim | §3.3.4: prose explicitly notes the sub-4 ms branch sets `connect = first_byte = total_ms`, so the absolute-deadline bound becomes 2 × `total_ms` at that scale. Ms rounding already dominates sub-4 ms scenarios, so the test row asserts ≤ 2× rather than = | +| §7 Fastly file summary was stale for IP literals | §7: TLS rule now says `.sni_hostname(sni_host)` is called **only for DNS-name hosts**; IP-literal hosts skip SNI per RFC 6066 §3. Cert verification still runs with the bracket-stripped form. Matches the §4.3 normative code (round 32) | +| Batch memory model used `N × max_response_bytes` ignoring heterogeneity | §3.4.4: bound rewritten as `Σᵢ request_bodyᵢ.len() + Σᵢ max_response_bytesᵢ`. The homogeneous case `N × max_response_bytes` is shown as the simplification; the precise sum is over per-slot caps | +| "Future change (§8 risk slot)" had no corresponding §8 entry | §8: new **risk 9** for configurable Fastly phase split — describes the trade-off, the options (per-request setter / per-`OutboundRequest` field / per-adapter knob), and that it's deferred pending a real use case. Test row in §5.4 now cross-references §8 risk 9 | +| Pre-append rule could overflow `usize` | §3.4.1: rule restated as `collected.len().checked_add(chunk.len()).map_or(true, |n| n > max)` (equivalently `chunk.len() > max.saturating_sub(collected.len())`). Either form is checked; no `+` that could panic on absurd inputs | + +## Appendix AH — Review round 34 resolutions + +| Review finding | Resolution | +| --- | --- | +| Adapter "eight capabilities" tuples stale after adding `outbound-flexible-phase-budget` | §4.1 / §4.2 / §4.3 / §4.4 `capability()` lines all updated to **nine** capabilities. Axum: `Native` for the new one (single reqwest timeout). Cloudflare: `Native` (single `worker::Delay` race). Spin: `Native` (single wasi-timer race). Fastly: **`BestEffort`** (rigid 1/4:3/4 split per §4.3, footnote 5). Implementers following the per-adapter notes can't miss the hard-fail path on Fastly | +| Sub-4 ms prose contradictory | §3.3.4: prose now matches the §4.3 code — `total_ms < 4` sets both = `total_ms`, so sum = `2*total_ms` (e.g. `total_ms=3` → 6 ms phase total, post-deadline slack up to ~3 ms). At sub-4 ms scale ms-rounding already dominates; the test row asserts ≤ 2× rather than = | +| Phase-split comment claimed `ceil-to-ms(budget * 1/4)` but code does `total_ms / 4` (floor) | §4.3 comment rewritten to match the code exactly: `connect_ms = total_ms / 4` (floor), `first_byte_ms = total_ms - connect_ms` (remainder), so sum = `total_ms` exactly. The earlier "ceil-to-ms of budget * 1/4" framing was a misnomer that would have made the sum exceed `total_ms` for some inputs | +| `req.tls_mode()` / `TlsMode` didn't exist on `OutboundRequest` | §4.3 code: TLS branch now derives from the URI scheme directly — `let tls = req.uri().scheme_str() == Some("https");`. No phantom `tls_mode()` method; the canonicalized scheme in `req.uri()` is the single source of truth (§3.1.3) | +| `parts()` / `parts_mut()` missing from the §3.4.5 behavior table | §3.4.5: behavior table now has the explicit row for `parts() -> &http::request::Parts` and `parts_mut() -> &mut http::request::Parts`. Matches the §6 migration sweep which directs `ctx.request()` / `request_mut()` callers to these | +| Specific `send-all-slot-isolation` test row omitted `edgezero dev` | §5.4 row updated to "**every adapter-selecting CLI command** (`build` / `serve` / `deploy` / `dev`) exits non-zero." Matches the generic BestEffort row and §3.5.3 | +| Appendix index said A–AC, doc extends through AG | Index updated to "A–AG (and counting)". Same self-pointer to the last `## Appendix` heading so the next round-up is automatic | + +## Appendix AI — Review round 35 resolutions + +| Review finding | Resolution | +| --- | --- | +| Fastly backend caching had a same-identity race (loser sees `NameInUse`, looks in map, doesn't find name yet, false external) | §4.3: lookup/build protocol redesigned around a `BackendSlot { Building \| Ready(Backend) }`. The outer lock is held **through** `Backend::builder.finish()` (the lock-across-host-call note from round 20 is reversed — Fastly's host call is short and never blocks on guest I/O, so holding the lock is safe). Concurrent same-identity callers serialize on the slot; `NameInUse` under that protocol is unambiguously external | +| Sub-4 ms exception not carried through normative guarantees | §4.3 "Net guarantee" rewritten with **two explicit branches**: `total_ms ≥ 4` keeps `BATCH_DISPATCH_SLACK_MAX + ms_rounding` (the common case); `total_ms < 4` is `BATCH_DISPATCH_SLACK_MAX + total_ms + ms_rounding` (≤ ~28 ms — sub-4 ms is a degenerate input where ms-rounding already dominates). Test row already asserts the 2× sub-4 ms bound | +| Stale "same `t` value and `tls_mode` are folded into identity" sentence | §3.3.4 prose updated: the identity tuple is `scheme + host + resolved_port + tls_mode + budget_ms`, where `tls_mode` is derived from `req.uri().scheme_str()` and `budget_ms` drives the deterministic phase split. Cached and freshly-built backends match because both are deterministic functions of the same tuple | +| Appendix bookkeeping: index said A–AG but file had AH, and AD/round-30 was skipped | New **Appendix AD — Review round 30 resolutions** inserted between AC and AE (reconstructed from the round-30 review). Index note updated to "A–AH (and counting)" with the same self-pointer to the last `## Appendix` heading | + +## Appendix AJ — Review round 36 resolutions + +| Review finding | Resolution | +| --- | --- | +| Sub-4 ms exception stale in §3.3.4 prose, capability footnote 1, and the test row | §3.3.4: "shifts to ≤ 2 ms past deadline" replaced with the precise sub-4 ms bound (`total_ms + BATCH_DISPATCH_SLACK_MAX + ms_rounding`, ≤ ~28 ms). §3.5.2 footnote 1 now explicitly scopes its numbers to "common-case `total_ms ≥ 4`" and points at §4.3's two branches. §5.4 phase-split test row also annotated "common case, `total_ms ≥ 4`" with a cross-reference to the existing sub-4 ms row | +| Backend cache protocol had undefined `Building` / `Failed` / condvar state | §4.3 rewritten — the protocol is just `Mutex>` plus "hold the outer lock through `Backend::builder().finish()`." Removed the `BackendSlot::Building` enum, the unwritten condvar storage, and the unwritten `Failed` notification. Holding the lock through the host call makes the race the round-34 review found structurally impossible without any additional state machine | +| Appendix bookkeeping: index said A–AH but file had AI | Index updated to "A–AI (and counting)". Self-pointer to the last `## Appendix` heading remains the canonical answer | + +## Appendix AK — Review round 37 resolutions + +| Review finding | Resolution | +| --- | --- | +| Cached Fastly backend reuse skipped identity comparison | §4.3 step 2 now branches on `stored_identity == identity` — match → reuse; mismatch → fail closed with the in-adapter SHA-256-128 collision error. §5.4 row exercises this via an injectable hash collision under `#[cfg(test)]`. The "reuse by name alone" wording is removed | +| `NameInUse` wording was narrower than Fastly's actual same-name rule | §4.3 step 5 rewritten with the precise Fastly contract (per `BackendBuilder` docs): identical name + identical properties returns `Ok` (re-registration); `NameInUse` only fires for identical name + **conflicting** properties. So a `NameInUse` in step 5 means an external party registered with conflicting properties we can't safely match. Error message updated accordingly. **Superseded by Appendix AY** (round 50): Fastly's actual contract is unconditional session-uniqueness — `NameInUse` carries no property-comparison semantics, and the "identical re-registration returns Ok" carve-out was a false premise. See Appendix AY for the corrected fail-closed protocol | +| Sub-4 ms bound "≤ ~28 ms" was loose | §3.3.4 + §4.3 + Appendix AI: replaced "≤ ~28 ms" with the strict upper bound `25 + (≤ 3) + (≤ 1) < 29 ms` (the explicit `BATCH_DISPATCH_SLACK_MAX + total_ms + ms_rounding` arithmetic) so the formula and the number agree | +| Appendix bookkeeping: index said A–AI, file had AJ, and an orphan unheaded round-30 review-table sat after AJ | Removed the orphan round-30 table (the round-30 content is already correctly placed in Appendix AD between AC and AE). Index updated to "A–AJ (and counting)" with the standard self-pointer to the last heading | + +## Appendix AL — Review round 38 resolutions + +| Review finding | Resolution | +| --- | --- | +| Fastly single-`send` dispatch slack claimed "structurally 0" but time still passes between `dispatch_budget` and `send_async` | §4.3: the single-`send` paragraph is rewritten to apply the **same `BATCH_DISPATCH_SLACK_MAX` guard** as `send_all` — re-check `Instant::now() - now <= BATCH_DISPATCH_SLACK_MAX` immediately before `send_async`, fail closed on exceedance with the same diagnostic. §3.5.2 footnote 1 single-`send` bullet now says dispatch+headers overshoot ≤ `BATCH_DISPATCH_SLACK_MAX + ms_rounding` instead of zero. §5.4 adds a row that exercises the single-send hook (matching the existing `send_all` injection-hook test) | +| Axum / Cloudflare arming the timer with a value snapshotted before SDK construction left a construction-time gap | §4.1 step 3/4 split into "construct without arming" and "re-read `budget.deadline.remaining()` immediately before arming reqwest's `.timeout(..)` / `worker::Delay(..)`." Matches Spin's "at the moment the race starts" wording (round 21). The cached after-drain value is no longer reused at arming time; on a 100 ms construction phase the SDK timer now reflects 100 ms less wall-clock, not 100 ms of silent overrun. §4.2 Cloudflare step 3/4 mirrors | +| Early dynamic-backend prose said "name cannot duplicate another in same session," contradicting the precise later `NameInUse` rule | §4.3 Dynamic-backends paragraph rewritten to match the later collision-protocol contract: identical name + identical properties re-registers (`Ok`); identical name + conflicting properties fails (`NameInUse`). Implementers reading top-to-bottom see one consistent rule, and a forward-pointer to the precise reuse-vs-conflict protocol later in the same section. **Superseded by Appendix AY** (round 50): the "name cannot duplicate" wording was the *correct* one all along; the "identical re-registration returns Ok" rewrite was a false premise. The §4.3 paragraph now says session-uniqueness is unconditional and EdgeZero owns the entire uniqueness story at the guest layer via an adapter-local cache. See Appendix AY | +| Appendix index said A–AJ, file had AK | Index updated to "A–AK (and counting)". Same self-pointer to the last `## Appendix` heading | + +## Appendix AM — Review round 39 resolutions + +| Review finding | Resolution | +| --- | --- | +| Fastly body deadline underspecified at EOF / final read | §3.3.4 + matrix row + §4.3 "Streamed-response wrapping" all rewritten to require the `budget.deadline.is_expired()` check **after every blocking body read returns, including the EOF read** — not just "between chunk reads." Streamed wrapping checks both before issuing the underlying read and after it returns. A last-chunk-or-EOF-arrives-after-deadline test row is added in §5.4 | +| Fastly `send_all` slack diagnostic was inconsistent between the normative message and the test row | §4.3 narrative now quotes the full normative `internal(..)` message verbatim. §5.4 row asserts against the **stable substring `"BATCH_DISPATCH_SLACK_MAX"`** with the full normative string included for reference — future wording polish doesn't break the tests | +| Appendix index said A–AK, file had AL | Index updated to "A–AL (and counting)". Standard self-pointer to the last `## Appendix` heading | + +## Appendix AN — Review round 40 resolutions + +| Review finding | Resolution | +| --- | --- | +| `into_bytes_bounded_until` overclaimed tighter-deadline enforcement | §3.1.3 helper doc rewritten: the drain checks **`min(effective_deadline, until_deadline).is_expired()` both before issuing each blocking body read and again after it returns** — including EOF. The `min(..)` is what catches the *tighter* `until` case; without it a final EOF read could complete after `until_deadline` but before the looser effective deadline. The "Enforcement is layered" paragraph clarifies that the adapter wrapper handles the effective budget and the helper's `min(..)` handles tighter `until`. §5.4 adds an "until shorter than budget; EOF arrives after until" test row | +| §4.3 Fastly precision still said "between chunks" before the corrected EOF rule | Wording aligned with §3.3.4: body drain checks `is_expired()` **after every blocking read return, including EOF** — not "between chunks." The earlier paragraph no longer contradicts the later correction | +| Appendix index said A–AL, file had AM | Index updated to "A–AM (and counting)". Standard self-pointer to the last `## Appendix` heading | + +## Appendix AO — Review round 41 resolutions + +| Review finding | Resolution | +| --- | --- | +| `into_bytes_bounded_until` required `min(effective, until)` state `OutboundResponse` doesn't carry | §3.1.3 helper doc rewritten to drop the `min(..)` framing: the adapter wrapper enforces the **request budget** by yielding error chunks; the helper enforces **`until_deadline`** cooperatively before and after each read (including EOF). The two layers compose because whichever fires first wins — no shared "effective deadline" stored on `OutboundResponse` (which carries only status / headers / body), no `min(..)` computation. Test row reworded to match | +| `send_all` rustdoc overpromised isolation | §3.1.1 + §3.2: "without affecting other slots" scoped to **input handling and per-slot Ok/Err type**. Cross-slot timing is explicitly governed by `send-all-slot-isolation` (BestEffort on Fastly because of harvest-order false 504s, §3.3.4). The trait rustdoc now points at the capability for the stricter guarantee | +| Streamed-upload host-write test row didn't match Axum/CF mechanics | §5.4 row rewritten by adapter: Axum/CF drain `Body::Stream` to `Bytes` *before* constructing the platform request (the relevant stall is source-pull during the drain); Spin has explicit source-pull + host-write races on WASI outgoing-body; Fastly has source-pull (unpreemptable, BestEffort) + bounded-cooperative host-write via between-bytes-timeout. The previous unified "host-write" framing is gone | +| Stale "before yielding each chunk" / "between chunks" wording for Fastly streamed body | §3.1.3 Fastly bullet updated to the EOF-safe rule — "both before issuing the underlying body read and again after it returns (including the EOF read)." No active normative text still says the older form | +| Batch memory warning claimed to be in send_all rustdoc but wasn't | §3.1.1 send_all rustdoc gains a **"Memory model"** paragraph: worst-case `Σᵢ request_bodyᵢ.len() + Σᵢ max_response_bytesᵢ`, no global cap on N, app bounds N (especially fan-out batches). Implementers copying the rustdoc into their docs site now see the bound at the API level, not only in §3.4.4 | +| Appendix index said A–AM, file had AN | Index updated to "A–AN (and counting)". Standard self-pointer | + +## Appendix AP — Review round 42 resolutions + +| Review finding | Resolution | +| --- | --- | +| Stale "between chunk reads" still in active §4.3 Fastly note | §4.3 Deadline bullet rewritten: body phase checks `budget.deadline` **after every blocking body read returns, including the EOF read**; streamed bodies are wrapped to check before and after each underlying read. Aligns with §3.3.4 and the round-39/40 EOF-safe rule | +| Appendix index named an exact upper bound and kept drifting | Index reworded to say "A through the last `## Appendix` heading in the document" with an explicit note that the index deliberately doesn't pin an exact letter — every round adds another and the index would otherwise drift. Round-by-round bookkeeping rows can stop chasing the upper bound after each one | + +## Appendix AQ — Review round 43 resolutions + +| Review finding | Resolution | +| --- | --- | +| Batch memory model under-counted resident memory | §3.1.1 rustdoc + §3.4.4 split the bound into **persistent collected buffer** (`Σᵢ request_bodyᵢ.len() + Σᵢ max_response_bytesᵢ`) and **transient in-flight chunks** (`Σⱼ sizeof(current_chunkⱼ)` for actively-draining slots, typically 8-64 KiB each). The §3.4.1 pre-append rule is the source. §5.4 row reworded from "without allocating past max" to "**without extending the collected buffer past max**" with the in-flight-chunk note | +| Fastly dynamic-backend error mapping was incomplete | §4.3 step 6 spells out: any other `Backend::builder()` error (dynamic backends disabled, DNS, TLS misconfig, Fastly-side rejection) maps to `EdgeError::bad_gateway(format!("Fastly dynamic backend setup failed: {e}"))`. `EdgeError::internal` is reserved for **adapter contract bugs** — `BATCH_DISPATCH_SLACK_MAX` overshoot, `NameInUse` external collision, unfilled-slot harvest invariant. §5.4 adds two rows: (a) each builder-error branch → 502 via a host fake / Viceroy harness, (b) error-chain inspection asserting `internal` only fires on the three contract-bug cases | +| `into_bytes_bounded_until` didn't define `Body::Once` behaviour | §3.1.3 helper doc adds an explicit branch: `Body::Once` checks `until_deadline.is_expired()` **at entry** before anything else; expired → `gateway_timeout` (precedence over over-cap → `bad_gateway`). `Body::Stream` keeps the existing before/after each read rule. Callers see consistent `gateway_timeout` semantics across body shapes | +| Tier 1 over-claimed for adapter-specific mechanics | §5.4: the stalled-streamed-upload row is **split** into a Tier 2/3 row (adapter mechanics — Axum tokio / CF `worker::Delay` / Spin wasi / Fastly host-timer behaviour, requires runtime CI) and a Tier 1 row (cross-adapter *contract* — 504, index alignment, partial-failure isolation — via `MockOutboundClient` with scripted stalls). Tier 1 no longer claims to prove adapter-specific wall-clock semantics | + +## Appendix AR — PR #269 rebase + +Rebases the spec onto [`stackpop/edgezero` PR #269](https://github.com/stackpop/edgezero/pull/269) (`feature/extensible-cli`, rev `b4c80e9`). PR #269 reshapes the CLI dispatch, the manifest store sections, the Spin adapter target, and adds an integration-test crate under `examples/app-demo/`. None of the outbound-HTTP design decisions change — this appendix records the wording and reference updates so future readers don't trip on the older symbol names that live on in earlier appendices. + +| Area | PR-#269 reality | Spec change | +| --- | --- | --- | +| CLI dispatch | `edgezero-cli` exposes nine commands (`auth login/logout/status`, `build`, `config push/validate`, `deploy`, `demo` [feature-gated, contributor-only], `new`, `provision`, `serve`); every adapter-selecting one routes through a single `edgezero_cli::adapter::execute(adapter_name, action, manifest_loader, args)` helper in `crates/edgezero-cli/src/adapter.rs`. The legacy `handle_build` / `handle_serve` / `handle_deploy` / `handle_dev` free functions are gone. | §3.5.3 paragraph rewritten to use `Adapter::execute` framing; §7 `edgezero-cli` bullet rewritten to point at `src/adapter.rs` and the `run_*` entry points; §5.4 capability rows updated to enumerate the PR-#269 command list. Older appendices (e.g. Appendix M, Appendix AC) still quote `handle_*` — those are historical resolution log, not normative | +| `dev` → `demo` | The `dev` command is removed. `demo` is the feature-gated, contributor-only replacement that runs the bundled demo app under Axum; production users get `--adapter axum serve` instead. | §3.5.3 paragraph + §5.4 `BestEffort` row note that `demo` (not `dev`) is the contributor-only Axum runner that must also fail capability checks. Earlier appendices quoting `edgezero dev` are historical | +| Spin SDK + target | Spin adapter pins `spin-sdk = "6"` and builds for `wasm32-wasip2` (CI gate quoted in CLAUDE.md still reads `wasm32-wasip1`; that's a CLAUDE.md/CI follow-up tracked at the bottom of §8, not a spec change since the spec doesn't pin a target). | No spec change — §3.1.4 / §4.4 / §5.4 reference `spin_sdk::http::send` symbolically and are SDK-6-compatible. §8 risk list updated to note the CLAUDE.md / CI command-quote refresh as a follow-up | +| Spin proxy + store APIs | `SpinRequest` exposes `into_parts`; `IncomingBodyExt::bytes()` replaces the older manual incoming-body drain; `FullBody::new(Bytes)` is the outgoing-body constructor; KV / config / secret stores use async `open` / `get` / `set` / `delete` / `exists` / `get_keys`. | No spec change — the outbound design does not pin Spin's body or store call shapes. §4.4 keeps its `spin_sdk::http::send` shape, which is unchanged | +| Multi-store manifest | The manifest now carries `ManifestStores { config: Option, kv: Option, secrets: Option }` instead of a single store block. | §7 `examples/app-demo` bullet calls out that the demo manifest's `[stores.*]` blocks are unchanged from PR #269 and that `[capabilities.outbound]` composes additively with them. §3.5.1 outbound capability shape is untouched | +| Adapter registry hook | The adapter trait grows `execute(action, args)`, `provision(..)`, `push_config_entries(..)`, plus validation hooks. `ensure_capabilities` plugs into `execute` so every adapter-selecting command runs the check exactly once. | §7 `edgezero-cli` bullet rewritten to put `ensure_capabilities` in `src/adapter.rs::execute` rather than four per-command handlers; the wording explicitly names the new `run_*` entry points the dispatch fans out to | +| `examples/app-demo` integration | PR #269 adds `examples/app-demo/crates/app-demo-cli/` — a typed-CLI integration crate that exercises `auth` / `provision` / `config push|validate` / `demo` against the demo manifest. | §7 `examples/app-demo` bullet now mentions the new integration crate explicitly so the outbound-HTTP migration updates both the per-adapter binaries and the CLI integration crate together | +| Status header | Snapshot through review round 43 (date 2026-06-04). | Bumped to `revised through review rounds 1–43 + PR-#269 rebase · Date: 2026-06-05`, with a one-line "Codebase baseline" pointer to the PR plus an explicit note that earlier appendices retain the legacy `handle_*` / `edgezero dev` wording for historical fidelity | +| Older appendices | Appendices D, M, AA, AB, AC, AD, AH, etc. quote `handle_build` / `handle_dev` / `edgezero dev` verbatim as part of the round-by-round resolution log. | **Left as-is by design.** Rewriting the historical journal would erase the audit trail of which round added which guarantee; the §3.5.3 + §7 + Appendix AR text is authoritative going forward. The status header points readers at this appendix for the resolution | + +## Appendix AS — Review round 44 resolutions (PR-#269 reality check + carry-overs) + +| Review finding | Resolution | +| --- | --- | +| PR-#269 rebase claims didn't match the local checkout (`Command` has `Build/Deploy/Dev/New/Serve`, `AdapterAction` has only `Build/Deploy/Serve`, `main` still handles `Command::Dev`) | Status header (line 3 onward) reframed: "Target codebase baseline" makes PR #269 the explicit forward target and calls out that it is **not yet merged**; "Current checkout (pre-#269)" enumerates the concrete differences (`args.rs::Command`, `registry.rs::AdapterAction`, `main.rs::Command::Dev`) and says the §3.5.3 / §5.4 / §7 / Appendix AR rows are **contingent** on the PR landing in the documented shape. Outbound HTTP design (§1 / §3.1 / §3.2 / §3.3 / §3.4 / §4) is independent of PR #269 and lands either way | +| Capability enforcement underspecified for non-`execute` paths and manifest shell commands. §3.5.3 said one `execute` hook covers everything, but PR #269 routes `provision` to `Adapter::provision` and `config` to validation hooks, and the dispatcher runs manifest shell commands before the registry lookup. The earlier pseudocode required `registry::get_adapter` for capability metadata, which shell-overridden adapters bypass entirely | §3.5.3 rewritten as **four pre-dispatch gates**: one at the top of `edgezero_cli::adapter::execute(..)` (before `manifest_command` is checked, before the registry lookup), plus three sibling gates at the top of `run_provision`, `run_config_push`, and `run_config_validate`. Each gate consults the **registry** for capability metadata regardless of whether the action ultimately dispatches to a shell command, so shell-overridden adapters still get checked; if the adapter is not in the registry, the gate degrades to a warning so a brand-new shell-only adapter without a registered stub still works. Covered / not-covered table enumerates every PR-#269 command. Pre-#269 fallback wording (gate at each of `Build`/`Serve`/`Deploy`/`Dev` handler tops) is preserved for readers on today's checkout | +| `into_bytes_bounded_until` overpromised tighter deadline enforcement: doc said "if the caller's `until_deadline` is tighter, the helper fires first," then admitted the helper is cooperative and cannot preempt a read in progress | §3.1.4 rustdoc rewritten: helper is explicitly a **cooperative post-read / EOF validator, not a timer-backed race**. New paragraph spells out the concrete failure mode — a read blocked for 500 ms with `until = 100 ms` does **not** return at 100 ms; it returns at 500 ms with `gateway_timeout` (post-read check observed expiry). "Whichever fires first" reworded to "at yield boundaries only." Real-time preemption explicitly delegated to the request builder's `.deadline(min(req_deadline, app_inner_deadline))` (pushed into the wrapper, which is the only layer with timer-backed enforcement on Axum / CF / Spin). §3.1.4 single-quote about the tighter-`until` case (line ~589) likewise updated | +| Tier 1 streamed-upload contract contradicted Fastly's declared `streamed-upload-deadlines = BestEffort` (footnote + §4.3 both say a Fastly source-pull stall is unbounded) | §5.4 Tier 1 streamed-upload-contract row reworded: the "within the configured deadline" half holds **only on the preemptible-source adapters (Axum / Cloudflare / Spin)**; Fastly is explicitly excluded from the wall-clock half and observes only the index-alignment + partial-failure-isolation half. `MockOutboundClient` is parameterised by the adapter under test so the Fastly invocation runs only the structural assertions. Wall-clock mechanics across all four adapters (including Fastly's `BoundedCooperative` between-chunk bound) live in the Tier 2/3 row above | +| Tier 1 still claimed coverage for adapter-only mechanics (Fastly host timers, harvest behaviour, dynamic backend identity, `BATCH_DISPATCH_SLACK_MAX` injection hook) — but Tier 1 is defined as `edgezero-core` + `MockOutboundClient`, which has no analogue for any of those | §5.4 rows demoted from Tier 1 (yes) → Tier 1 (—) with an explicit per-row note pointing at the Tier 2 / Tier 3 home: (a) Fastly `send` `Body::Stream` mechanics (Fastly host between-bytes-timeout, source-pull non-preemption) → Tier 2 (Fastly contract crate) + Tier 3 (Viceroy); (b) Fastly `send_all` mixed-budget headers-phase harvest-order delivery delay → Tier 2 / Tier 3; (c) Fastly `send_all` Buffered body-phase harvest head-of-line block → Tier 2 (deterministic harvest ordering against a host-side fake) + Tier 3 (Viceroy wall-clock); (d) Fastly mixed-budget same-host distinct-backends-by-`budget_ms` identity assertion → Tier 2 (inspect registered-backend map) + Tier 3 (Viceroy); (e) Fastly `send_all` `BATCH_DISPATCH_SLACK_MAX` substring + hook → Tier 2 (`crates/edgezero-adapter-fastly/tests/contract.rs`) + Tier 3 (Viceroy with hook); (f) Fastly upload-consumes-budget `send_async_streaming` + `wait()`-drop sequence → Tier 2 / Tier 3 | +| §3.4.1 memory model still treated `current_chunk` as effectively bounded ("8-64 KiB for typical sources … not unbounded") while only the persistent collected buffer is actually guaranteed under `max` | §3.4.1 rewritten: the `8-64 KiB` figure is now explicitly **descriptive of the adapters' incoming stream chunking, not a contract**. Three concrete consequences spelt out — (a) an upstream yielding one large `Bytes` exceeds the typical figure (4 MiB single-chunk example); (b) EdgeZero does not rechunk, so there is no core-side cap on incoming chunk size; (c) the §3.4.4 batch model inherits the same source-controlled property. New **§8 risk 11** tracks the deferred follow-up: opt-in `max_chunk_bytes` builder field vs. fixed `MAX_TRANSIENT_CHUNK_BYTES` constant vs. leave-and-document, each with its perf / lazy-streaming trade-off | +| §3.4 numbering was out of source order (3.4.5 appeared before 3.4.3 / 3.4.4) | §3.4.5 ("Inbound body migration") **physically moved** to after §3.4.4 ("Batch memory model") — section numbers preserved (so cross-refs in §1, §3.1, §5.4, §6, §7, and 25+ appendix entries still resolve), but physical source order now matches numeric order (3.4.1 → 3.4.2 → 3.4.3 → 3.4.4 → 3.4.5). Verified via `grep -n '^#### 3\.4'`. No content edits inside §3.4.5; pure reorder | + +## Appendix AT — Review round 45 resolutions + +| Review finding | Resolution | +| --- | --- | +| Capability enforcement had a hard contradiction around unregistered shell adapters: prose said "missing registry metadata degrades to a warning," pseudocode hard-failed on `registry::get_adapter(adapter_name).ok_or_else(..)?` | §3.5.3 now has an explicit **missing-from-registry policy** table: when the manifest declares **no** capabilities (`required = []` AND `optional = []`), missing-from-registry logs a `warn!` and proceeds — the brand-new-shell-only-adapter case still works. When the manifest declares **any** required or optional capability, missing-from-registry is a **hard failure** with a clear "register an adapter stub that returns capability metadata, or remove the `[capabilities]` section" message — the "required capabilities fail early" contract is preserved. Pseudocode rewritten to match (`let Some(adapter) = ..` with the two-branch policy in the `else` arm) | +| Multiple later sections still described capability checks as flowing through "the single `Adapter::execute` dispatch point" / "the shared `Adapter::execute` dispatch" — but §3.5.3 now defines four pre-dispatch gates (one in `execute`, three siblings on `run_provision` / `run_config_*` / `run_demo`) | Four §5.4 test rows reworded to reference the **§3.5.3 pre-dispatch gates** explicitly (one in `execute(..)`, siblings on `run_provision` / `run_config_*` / `run_demo`): (a) generic Required-BestEffort enforcement row, (b) `send-all-slot-isolation` Fastly hard-fail row, (c) `lazy-streamed-response-passthrough` `demo`-runner row (now correctly says `demo` goes through `run_demo`'s sibling gate, *not* through `execute(..)`), (d) `outbound-flexible-phase-budget` Fastly row. §6 migration "CLI dispatch in the PR-#269 world" bullet rewritten to describe the **four-gate** wiring (one inside `execute(..)` before `manifest_command` + registry lookup; siblings on the three commands that don't flow through `execute`). §7 `crates/edgezero-cli` `src/adapter.rs` task rewritten to specify "first statement of `execute(..)`" plus the three sibling-gate placements. Status-header forward pointer (line 6) is left untouched because it lists the surfaces PR #269 *introduces*, not where the gate sits | +| Memory contract overclaimed hard bounds: §3.4.1 / §3.4.4 correctly say resident memory is `max + sizeof(current_chunk)` with the chunk source-controlled, but the §3.4.4 contract bullets just said per-response and per-inbound-body memory are bounded by `max` | §3.4.4 contract bullets rewritten to split **persistent** (post-append, retained, bounded by `max`) vs **transient** (in-flight during the drain, `max + sizeof(current_chunk)` worst case, chunk source-controlled). Per-response, per-inbound-body, and batch entries all carry both terms now. Batch transient `Σⱼ sizeof(current_chunkⱼ)` over actively-draining slots is explicit; the bullet ends with a forward pointer to §8 risk 11 (deferred per-batch transient-chunk cap) | +| `json_bounded_until` rustdoc still implied caller-supplied helper deadlines get real timer enforcement on Axum / CF / Spin via wrapped bodies. The `into_bytes_bounded_until` doc was already fixed in round 44; this one was missed | §3.1.4 `json_bounded_until` rustdoc rewritten to match `into_bytes_bounded_until`: caller-supplied `deadline` is enforced **cooperatively** by the underlying `into_bytes_bounded_until` (at yield boundaries enumerated there); a read already blocked when `deadline` passes is **not** preempted. Real-time enforcement is the **wrapper's** job and applies to the **request budget** only — adapters with platform timers (Axum / CF / Spin) install the deadline-aware stream bounded by `dispatch_budget(req).deadline`; Fastly is `BoundedCooperative` on that bound. To get timer-backed preemption of a tighter deadline, set `.deadline(min(req_deadline, app_inner_deadline))` on the builder so it lands in the wrapper. Malformed-JSON → `bad_gateway` (502) is preserved | +| Fastly dynamic-backend "three distinct values" row was still marked Tier 1, but it asserts Fastly `Backend::builder` / `.override_host` / `.sni_hostname` / `.check_certificate` / `.disable_ssl` mechanics — same shape as the other Fastly-mechanic rows that were demoted in round 44 | §5.4 row split into two: (a) **Tier 1 half** — `OutboundRequest::get(..)` exposes `backend_target()`, `host_authority()`, `sni_hostname()` accessors, tested in `crates/edgezero-core/src/outbound.rs` `#[cfg(test)]` without any adapter dependency; (b) **Tier 2 / Tier 3 half** — Fastly adapter consumes the three values via `Backend::builder(name, backend_target).override_host(..).sni_hostname(..).check_certificate(..)` / `.disable_ssl()`, tested by inspecting the registered-backend map (Tier 2) and a Viceroy round-trip (Tier 3). Each row clearly states what it does and does not assert. Matches the round-44 demotion pattern for the other Fastly-mechanic rows | + +## Appendix AU — Review round 46 resolutions + +| Review finding | Resolution | +| --- | --- | +| §3.5.2 `Adapter` trait snippet was pre-PR-#269 shaped (only `execute` / `name` / `capability`), but the status header said the target baseline adds `Adapter::provision(..)` and config hooks, and §3.5.3 relies on those paths | §3.5.2 now shows **two trait blocks** — the pre-#269 shape (today's checkout: `execute` + `name` + `capability`) and the PR-#269 target shape (adds `provision`, `push_config_entries`, `validate_config` plus "…other PR-#269 validation hooks elided…"). Explanatory paragraph below the blocks states (a) this spec adds only `capability(..)`; (b) the other PR-#269 methods are owned by PR #269 and shown here only so readers don't misread the trait as exhaustive; (c) the `provision` / config hooks are called from §3.5.3's **sibling** pre-dispatch gates, not from `Adapter::execute`; (d) on today's checkout there is no `provision` / `config` surface, so the sibling-gate wording applies once PR #269 lands | +| Capability-gate counting was inconsistent: §3.5.3 said "single pre-dispatch gate," then "two sibling gates," then "four gates," while the table + later sections include `execute`, `run_provision`, `run_config_push`, `run_config_validate`, and `run_demo` (five) | §3.5.3 normalized to **"pre-dispatch gate at each adapter-selecting entry point"** with **five concrete gate sites** enumerated: (1) inside `execute(..)` first statement, (2) `run_provision`, (3) `run_config_push`, (4) `run_config_validate`, (5) feature-gated `run_demo` hardcoding `"axum"`. Code blocks updated to number all five. Table caption changed from "four gates above (one in execute, three siblings)" to "five gate sites above (one inside execute(..), four siblings)". §6 migration "CLI dispatch" bullet updated to "five pre-dispatch gate sites." §5.4 capability test rows that already listed all four siblings + execute are now consistent with the count. Appendix entries from rounds 44–45 left as historical (they record the count at the time they were written) | +| §5.4 referenced core `OutboundRequest` accessors `backend_target()` / `host_authority()` / `sni_hostname()` that the API surface never defined | §3.1.4 `OutboundRequest` surface now defines all three as **adapter-facing, non-consuming** methods with their precise semantics: `backend_target() -> String` (always `"host:port"`, default ports filled, IPv6 bracketed); `host_authority() -> String` (port only when non-default for scheme, IPv6 bracketed); `sni_hostname() -> Option<&str>` (port-stripped, bracket-stripped, **`None` for IP literals** per RFC 6066 §3 — so IP-literal HTTPS adapters fall back to `uri().host()` for `.check_certificate(..)` and skip `.sni_hostname(..)` entirely). Block intro paragraph names them the "single canonical source" the Fastly identity hash (§4.3) depends on, and pins them as what the §5.4 Tier-1-half three-value row tests | +| Multiple §5.4 rows still claimed Tier 1 coverage for adapter wrappers / platform timers / no-partial-send mechanics — specifically `into_bytes_bounded_until` end-to-end, streamed-body-stalls wrapped-stream, Axum no-deadline 30 s end-to-end, `json_bounded_until` end-to-end, and "Adapter `dispatch_budget` everywhere" | Five §5.4 rows split following the round-44 pattern (Tier-1 contract shape, Tier 2 / 3 wall-clock / wrapper insertion): (a) `into_bytes_bounded_until` row → helper-cooperative half (Tier 1) + adapter-wrapper half (Tier 2/3); (b) "streamed body stalls after one chunk" demoted Tier 1 (yes) → (—) — wrapper insertion / platform timer is adapter-specific; (c) Axum no-deadline 30 s split into `DEFAULT_NO_DEADLINE_BUDGET` core constant (Tier 1) + Axum end-to-end wall-clock (Tier 2/3); (d) `json_bounded_until` row split same way (helper-cooperative Tier 1 + adapter wrapper Tier 2/3); (e) "Streamed body honours `dispatch_budget(req).deadline` end-to-end" demoted Tier 1 (yes) → (—) — wrapper-specific; (f) "Adapter `dispatch_budget` everywhere" demoted to Tier 2/3 with note pointing at the core-helper Tier-1 row; (g) `.timeout(short).deadline(long)` split into dispatch_budget classification (Tier 1) + wrapper-fires-at-`now + short` (Tier 2/3) | +| Fastly three-value Tier 2 row overgeneralised HTTPS: it said HTTPS always calls `.sni_hostname(sni_hostname).check_certificate(sni_hostname)`, but Fastly normative code skips `.sni_hostname(..)` and bracket-strips the cert host for IP literals (per RFC 6066 §3) | §5.4 row scoped to **"DNS-name HTTPS path"**: explicit "where `sni_hostname()` returns `Some(host)`" guard, plus a pointer that "IP-literal HTTPS (where `sni_hostname()` is `None`) is the dedicated 'Fastly HTTPS to IP literals' row below, which asserts the **distinct** behaviour of skipping `.sni_hostname(..)` and passing the bracket-stripped host to `.check_certificate(..)`." DNS-only test assertions preserved; the IP-literal row at row 3067 (later in §5.4) is the canonical IP test | + +## Appendix AV — Review round 47 resolutions + +| Review finding | Resolution | +| --- | --- | +| IP-literal TLS host handling broke the new accessor contract: §3.1.4 said the three accessors are the "single canonical source" and adapters must not re-derive from `uri()`, but `sni_hostname()` returned `None` for IP literals and told adapters to fall back to `uri().host()` for the cert host. Fastly pseudocode at §4.3 still parsed and trimmed the host locally | §3.1.4 adds a new **fourth accessor `cert_host() -> Option<&str>`**: `Some(host)` for *any* HTTPS scheme (DNS name OR IP literal — port-stripped, bracket-stripped), `None` for HTTP. The full canonical source is now `backend_target()` / `host_authority()` / `sni_hostname()` / `cert_host()`. `sni_hostname()` rustdoc rewritten to be explicit: `None` means "send no SNI" — adapters MUST NOT fall back to `uri().host()` and MUST consult `cert_host()` for certificate verification. Fastly §4.3 pseudocode rewritten: the four-value comment block names each accessor and its semantics; the TLS-setup branch is now `match req.cert_host() { Some(cert) => builder.enable_ssl().check_certificate(cert).maybe_sni(req.sni_hostname()), None => builder.disable_ssl() }`. The previous local `is_ip_literal` parse + `trim_start_matches('[')` is gone — bracket-stripping and IP-literal detection now live in the core accessors | +| §5.4 still marked adapter mechanics as Tier 1: upload-budget rows claimed Tier 1 could prove Axum / Cloudflare "before constructing/sending, no partial upstream send" and Spin WASI outgoing-body behaviour; URI canonicalization rows claimed Tier 1 could prove "one dynamic backend" / "same Fastly backend identity" | Four §5.4 rows split per the round-44 pattern. (a) Upload-budget *contract shape* — `MockOutboundClient` exposes a `did_dispatch()` flag; Tier 1 asserts "deadline expired during drain → 504 AND `did_dispatch() == false`" without any adapter. (b) Upload-budget on Axum / Cloudflare — Tier 2 (platform-SDK send-call counter on a fake harness) + Tier 3 (mock origin observes zero connections). (c) Upload-budget on Spin — Tier 2 (WASI outgoing-body chunk-count observation) + Tier 3 (Spin runtime, mock origin observes the partial upload). (d) URI canonicalization split into a core accessor row (Tier 1) and a Fastly identity row (Tier 2 / Tier 3); URI scheme + host case normalisation split the same way | +| §7 reintroduced gate-count ambiguity: active migration text said "five pre-dispatch gate sites," but the file summary said "All four call sites" after listing `execute` + three siblings + `run_demo` | §7 `crates/edgezero-cli` `src/adapter.rs` bullet updated: "All five gate sites (one inside `execute(..)`, the four siblings on `run_provision` / `run_config_push` / `run_config_validate` / `run_demo`)." Matches the §3.5.3 + §6 wording | +| Appendix AR was stale but still advertised as a rebase-claims surface: the header pointed readers at AR, while AR still said "every adapter-selecting command routes through a single `Adapter::execute` helper" — wording corrected to "four gates" in AS and "five gates" in AU | Status header (line 8) reworded: AR is now explicitly tagged as "round-44 history" and "superseded by Appendices AS / AT / AU / AV." The authoritative surfaces enumerated in the same bullet are §3.5.3 + §3.5.2 + §5.4 + §7. Readers see the current count + shape without having to reconcile AR's older language | +| Minor copy/paste issues: `sni_hostname() == "example.com"` should have been `Some("example.com")`, and the batch-memory formula carried `request_body_iᵢ.len()` (double subscript) | Three-value test row updated to **four-value** and uses `Some("example.com")` for both `sni_hostname()` and `cert_host()`. Batch-memory formula normalised to `Σᵢ request_bodyᵢ.len() + Σᵢ max_response_bytesᵢ` in every active surface (§3.1.1 rustdoc, §3.4.4 contract bullets, §3.4.4 visualisation block, §3.4.4 simplification). Historical appendices left unchanged (they record the round-N wording verbatim) | + +## Appendix AW — Review round 48 resolutions + +| Review finding | Resolution | +| --- | --- | +| Host/authority wording still bypassed the new canonical-accessor contract: §3.1.4 said adapters MUST consume the four accessors and `host_authority()` owns the outgoing Host, but `from_request` (§3.1.3) and `normalize_for_dispatch` (§3.1.5) still said adapters derive Host directly from `req.uri()` at SDK-construction time | Both proxy-forward sites rewritten to thread `req.host_authority()` end-to-end. `from_request` rustdoc now reads "the adapter sets the final `Host` value from `req.host_authority()` at SDK-construction time — the same canonical accessor every adapter uses (§3.1.4) — and MUST NOT read `req.uri()` for the Host value." Concrete examples (port preservation, IPv6 bracketing, default-port stripping) moved into the accessor doc. `normalize_for_dispatch` step 3 rewritten the same way: "the adapter then sets the final `Host` header from `req.host_authority()` … does NOT re-read `req.headers()` nor reconstruct from `req.uri()` directly." One accessor, one canonical string, every adapter observes the same value. The §7 Fastly file summary already names `req.host_authority()` and was updated in the same edit to remove the leftover "three-value URI split" phrasing | +| Fastly `send_all` body-phase deadline bound overclaimed observed wall-clock behaviour: §3.3.4 admits harvest-order body drain causes false 504s, then said per-slot post-deadline overshoot is one between-bytes-timeout, and §3.5.2 footnote 1 repeated that bound in the capability text without scoping | §3.3.4 "worst-case overshoot" paragraph rewritten: the one-between-bytes-timeout bound now applies **"once that slot is actively draining"**, not to total observed wall-clock. New paragraph spells out that observed completion for slot `k` can be as late as `Σᵢ<ₖ drain_timeᵢ + (effective_at_dispatch for slot k)` — the harvest delay is explicit. The cross-slot weakening is owned by the separate `send-all-slot-isolation` capability (footnote 4), so apps that need cross-slot isolation declare it required and get the Fastly hard build failure. §3.5.2 footnote 1 (`outbound-deadlines` rubric) updated to say "body phase **once a slot is actively draining** is still ≤ one between-bytes-timeout — but the slot's observed completion can additionally be delayed by harvest-order serialization … the bound here is on the active-drain phase only, not on total observed wall-clock across the batch." `outbound-deadlines` and `send-all-slot-isolation` now own non-overlapping slices of the story | +| Tier 1 upload-budget "no platform dispatch" contract contradicted Spin/Fastly's explicitly-documented partial upstream sends. The Tier 1 row required `did_dispatch() == false`, while the Spin and Fastly per-adapter rows said partial upstream send is possible/expected | §5.4 Tier 1 row scoped to **"Axum / Cloudflare semantics only"**: the `did_dispatch() == false` assertion is now the Axum / Cloudflare contract (drain-then-dispatch). The mock's `drain-first` mode is called out as a property of the test harness, not a cross-adapter contract. Row text explicitly excludes Spin and Fastly and points at the per-adapter Tier 2 / Tier 3 rows for those adapters' distinct partial-send semantics | +| Four-value URI row contradicted `cert_host()` for HTTP: `cert_host()` is `None` for non-HTTPS, but the row asserted `http://example.com:8443` produces `cert_host() == Some("example.com")` | §5.4 row split by scheme. **HTTPS DNS-host inputs** (three URL variants): `cert_host() == Some("example.com")` on all; `sni_hostname() == Some("example.com")` on all. **HTTPS IP-literal inputs**: `sni_hostname() == None` (RFC 6066 §3); `cert_host() == Some("127.0.0.1")` / `Some("::1")`. **HTTP DNS-host inputs** (three URL variants): `sni_hostname() == None`; `cert_host() == None`. The HTTPS-only `cert_host() == Some` is now the canonical reason an adapter calls `.disable_ssl()` vs `.enable_ssl()` / `.check_certificate(..)` — a single accessor disambiguates TLS-on-vs-off | +| Stale "three-value" language remained after `cert_host()` was added in round 47 (round 47 added the fourth accessor but didn't sweep). The §3.1.4 accessor-block comment said "tested by the Tier 1 half of the §5.4 three-value row"; the Fastly Tier 2 row title still said "three values"; the §7 Fastly file summary said "three-value URI split" | All three sites updated to "four-value": (a) §3.1.4 accessor-block comment now reads "the §5.4 four-value row"; (b) §5.4 Fastly Tier 2 row title rewritten to "Fastly adapter consumes the four canonical accessors, DNS-name HTTPS path" with the `check_certificate(cert)` argument coming from `req.cert_host()` (not the previously-conflated `sni_host`); (c) §7 Fastly migration entry rewritten to reference "the four canonical URI accessors" and spell out the per-accessor wiring (`backend_target`, `host_authority`, `cert_host`, `sni_hostname`). The earlier "three URI values must be derived from canonicalized `req.uri()`" warning is removed; the new wording says adapters MUST NOT re-derive from `req.uri()` directly and must consume the accessors | +| §5.5 CI gate wording conflicted with the PR-#269 Spin target baseline: status header said PR #269 moves Spin to SDK 6 / wasm32-wasip2, but §5.5 said "the five existing CLAUDE.md gates still apply" — implementers landing the spec post-#269 would have preserved the stale `wasm32-wasip1` quote | §5.5 reworked. **First paragraph** preserved (count + shape of the five gates unchanged). **New "Spin gate triple — pre-#269 vs PR-#269" subsection** explicitly enumerates the two literal command strings: pre-#269 = `cargo check -p edgezero-adapter-spin --target wasm32-wasip1 --features spin`; PR-#269 = `cargo check -p edgezero-adapter-spin --target wasm32-wasip2 --features spin`. "Implementers landing this spec after PR #269 must update the gate quote … preserving the stale `wasm32-wasip1` quote would silently break the Spin build." §8 risk 10 cross-referenced for the CLAUDE.md / CI command-quote follow-up. The other four gates are stated as unaffected by PR #269 | + +## Appendix AX — Review round 49 resolutions + +| Review finding | Resolution | +| --- | --- | +| URI canonicalization text contradicted itself across active surfaces: `OutboundRequest` explicitly *preserves* path / query (§3.1.3), but the canonical accessor block (§3.1.4) said the §3.1.3 rules had "rejected path / query," and §3.5.4 said manifest host entries use "the same rules" then rejected path / query. Request-URI rules and manifest-host-entry rules were conflated | §3.1.4 accessor-block comment rewritten: rejects **userinfo and fragments** only; path and query are explicitly preserved per RFC 3986 §3.3 / §3.4 (still accessible via `self.uri()` for the wire-level request line). New paragraph at the end of the block calls out that manifest `[capabilities.outbound].hosts` entries (§3.5.4) are a **separate grammar** — host-authority-only declarations, so the manifest-host validator rejects path / query / fragment / userinfo on the manifest side. §3.5.4 prose updated likewise: "diverge on path/query — request URIs pass them through; manifest host entries reject them. Sharing the lowercase-scheme / lowercase-host / strip-default-port / reject-userinfo / reject-fragment rules with §3.1.3 keeps the canonical spelling identical; the path/query divergence is the only difference and is enforced by the validator, not by quietly dropping at render time." Reader sees one shared subset + one explicit divergence, not two contradictory "same rules" claims | +| `OutboundDeadlines` enum doc-comment and Fastly capability summary both said the `send_all` coverage is "headers phase only," contradicting the round-48 active-body-drain scoping in footnote 1 | `Capability::OutboundDeadlines` doc-comment rewritten to say `send_all` coverage is "both the headers phase and the **active body-drain phase** of each slot — a slot's active drain still honours the single-slot bound (≤ one between-bytes-timeout overshoot per gap on Fastly per §3.3.4). The **cross-slot harvest delay** … is *not* covered here — that is the separate `SendAllSlotIsolation` capability below." Fastly capability summary (`§4.3` end) updated: `outbound-deadlines = BoundedCooperative (footnote 1 — covers single send, plus send_all headers phase AND active body-drain phase per slot; cross-slot harvest-order delay is the separate send-all-slot-isolation story)`. Three surfaces now say the same thing | +| Fastly streamed-upload "response phase" prose used `between_bytes_timeout` as the bound on the post-upload headers wait, but §3.3.4 defines `first_byte_timeout` as the headers wait and `between_bytes_timeout` as the inter-chunk gap (active drain only). Apps reading the streamed-upload prose would have assigned the wrong phase | §4.3 streamed-upload response-phase paragraph rewritten: "the response-phase host timeouts are locked to the phase-split values computed at dispatch (`first_byte_ms` for the headers wait, `between_ms` for inter-chunk gaps once the response body flows)." Concrete worked example switched from "host's between-bytes-timeout was set to 200 ms" to "host's `first_byte_timeout` was set to 150 ms at dispatch (3/4 of a 200 ms budget)." Net-wall-clock claim updated: "exceed `budget.duration` by up to one first-byte-timeout (for the headers wait) plus one between-bytes-timeout per body-chunk gap." Matches the §3.3.4 phase definitions and the §4.3 phase-split formulas | +| Status header bookkeeping was stale: line 8 said Appendix AR is "superseded by Appendices AS / AT / AU / AV" (rounds 44–47), but the file now has Appendix AW (round 48) and AX (this round) | Line 8 pointer extended to "**superseded by Appendices AS / AT / AU / AV / AW / AX** (rounds 44–49)." Readers see a single canonical "what supersedes AR" list that tracks every newer rebase appendix | + +## Appendix AY — Review round 50 resolutions (Fastly SDK correctness pass) + +| Review finding | Resolution | +| --- | --- | +| **HIGH — `lazy-streamed-response-passthrough = Native` on Fastly was based on a non-existent API.** Spec referenced `Response::with_streaming_body` (exists on `Request` only, not `Response`) and claimed lazy passthrough was supported. Fastly's actual lazy/early-streaming API is `Response::stream_to_client(self) -> StreamingBody`, which the SDK explicitly documents as **incompatible with `#[fastly::main]`** — the attribute implicitly calls `send_to_client()` on the returned response | Capability matrix: Fastly `lazy-streamed-response-passthrough` downgraded `Native` → `BestEffort⁶`. New footnote 6 documents the structural constraint: `stream_to_client()` requires dropping `#[fastly::main]` and using an undecorated `main()` + `Request::from_client()`. Default scaffold therefore performs buffered passthrough (drain wrapped `Body::Stream` to `Bytes` within `max_response_bytes`, return via `#[fastly::main]`). Apps that need lazy passthrough on Fastly declare the capability required and get a hard build failure; migration path is target a different adapter (CF or Spin), or wait for §8 risk 12 (new). The §4.3 capability summary and the §4 implementation prose (formerly `Response::with_streaming_body`) updated to match | +| **HIGH — `NameInUse` semantics were based on a false premise.** Spec said "identical name + identical properties is a re-registration that returns Ok"; Fastly's SDK docs state session-uniqueness is unconditional. `NameInUse` carries no property comparison and the SDK's `Backend::from_str(name)` returns a handle only, with no way to inspect the registered backend's properties | §4.3 step 5 rewritten: `NameInUse` is "this name is taken in this session, period" — no property-comparison semantics. SDK's documented recovery is `Backend::from_str(name)`, which the SDK itself caveats as "you should be careful to only use this capability in situations in which you are 100% sure that this name will always lead to the same place." Since EdgeZero owns the `ez_{sha256_128(identity)}` naming scheme and the SDK does not let us inspect external backends' properties, a `NameInUse` on a name not in our adapter's collision map is a **fail-closed** condition with `EdgeError::internal(..)` — explicitly rather than silently inheriting a possibly-mismatched configuration. The carve-out for "identical re-registration returns Ok" is gone | +| **MEDIUM — false claim that `between_bytes_timeout` bounds upload writes.** Spec said Fastly applies `between_bytes_timeout` to both reading from origin **and** writing to origin. Fastly's public Backend API docs describe it as "maximum duration … that Fastly will wait while receiving no data on a download from a backend" — receive-side only. No published Fastly backend-timeout field bounds host-side writes of guest-supplied bytes to origin | §4.3 streamed-upload host-write bullet rewritten. Host write phase downgraded to `BestEffort` (was `BoundedCooperative`). `between_bytes_timeout` cited correctly as receive-side only with a link to the public Backend API docs. Adapter's only recourse on a stalled write is the cooperative `is_expired()` check **between** chunks; mid-write stalls are unbounded. §5.4 row updated to reflect both source-pull and host-write as BestEffort gaps; the "within one between-bytes-timeout" claim removed | +| **MEDIUM — streamed-upload response overshoot was overstated.** Spec said budget could be exceeded "by up to one first-byte-timeout (for the headers wait) plus one between-bytes-timeout per body-chunk gap" — a per-chunk accumulator. Once the deadline expires, the response wrapper's `is_expired()` check fires after the first post-deadline read returns, not after every chunk. Footnote 1 (single-send Fastly bound) omitted the streamed-upload post-upload first-byte overshoot entirely | §4.3 streamed-upload response-phase paragraph rewritten with a **closed-form bound**: post-deadline overshoot ≤ `first_byte_ms` (headers wait) + one `between_bytes_timeout` (worst-case interval during which the host is mid-read of the *first* body chunk when the wrapper fires) — one-shot, not per-chunk. Footnote 1 single-send section gains a "Streamed-upload-specific overshoot" sentence noting the post-upload `first_byte_ms` overshoot for the tiny-positive-remainder case. New §5.4 test row asserts the closed-form bound and that the wrapper preempts after the first body chunk read returns | +| **LOW — streamed-response orchestration path lacked an app-facing consuming accessor.** Spec recommended single `send` + app-side orchestration for streamed responses, but `OutboundResponse`'s only body accessor was `body(&self) -> &Body` (non-consuming); the consuming `into_parts(self) -> (StatusCode, HeaderMap, Body)` was labelled adapter-facing | Added **`OutboundResponse::into_body(self) -> Body`** as the explicit app-facing consuming accessor. Rustdoc names it the canonical orchestration path for streamed responses via single `send` + `futures::join_all` on Axum/CF/Spin. §3.1.1 `send_all` rustdoc updated to point at `into_body()` rather than the adapter-facing `into_parts(..)`. The boundary between "app code" and "adapter / response-converter code" is now explicit in the surface | + +## Appendix AZ — Review round 51 resolutions (round-50 carry-overs) + +| Review finding | Resolution | +| --- | --- | +| **HIGH — Fastly dynamic-backend semantics still contradictory.** Round 50 fixed the §4.3 step-5 algorithm (session-uniqueness unconditional, no property-comparison carve-out, fail-closed on external collisions), but the earlier `Dynamic backends` introductory paragraph still preserved the false "identical name + identical properties re-registers / re-uses (returns Ok); same name but conflicting properties fails with NameInUse" wording. Two historical appendix entries also documented the stale carve-out without flagging it as superseded | §4.3 `Dynamic backends` paragraph rewritten in place: session-uniqueness is **unconditional** per the SDK; `NameInUse` carries no property-comparison semantics; the SDK's documented `Backend::from_str(name)` recovery returns a handle without exposing properties; EdgeZero therefore owns the entire uniqueness story at the guest layer via an adapter-local `Mutex>` cache; identity hashing into the backend name (`ez_{sha256_128(identity)}`) makes distinct identities map to distinct names by construction; a `NameInUse` on a name not in the cache is fail-closed `EdgeError::internal`. Forward-pointer to the §4.3 algorithm later in the section. Two historical appendix entries (Appendix AK round-37, Appendix AK same-section reconciliation entry) tagged with "**Superseded by Appendix AY** (round 50)" forward-pointers so readers don't follow the stale rule | +| **MEDIUM — Fastly buffered-fallback for lazy passthrough named an unavailable cap and the §5.4 matrix still bucketed Fastly with CF/Spin.** Round 50's Fastly fallback prose said "drain to `Bytes` within `max_response_bytes`," but the spec already states the per-request `max_response_bytes` cap is unavailable at response-converter time — `OutboundResponse` carries only status / headers / body, no cap metadata. Three §5.4 lazy-passthrough rows still listed Fastly alongside CF/Spin (yields-first-bytes, mid-stream abort, buffered-fallback) | Added the adapter-level constant **`FASTLY_RESPONSE_STREAM_BUFFER_BYTES`** (default 16 MiB, mirrors `AXUM_RESPONSE_STREAM_BUFFER_BYTES`) to the Fastly buffered passthrough prose. §3.4.1 streaming-decompressor section regrouped to "**CF / Spin**" with an explicit note that Fastly + Axum are both BestEffort for lazy passthrough (different underlying reasons but the same fallback shape). §5.4 lazy-passthrough rows split into three: (a) CF/Spin lazy yield-first-bytes row (Fastly explicitly excluded); (b) CF/Spin mid-stream abort row (Fastly explicitly excluded — its buffered fallback turns mid-stream errors into a clean 502/504 in the drain); (c) buffered-fallback row covering **Axum and Fastly**, with both adapter-level constants named, the `OutboundResponse` no-cap-metadata constraint stated, and the lazy-passthrough capability-required hard-fail path documented | +| **MEDIUM — Fastly streamed-upload write-side downgrade only partially applied.** Round 50 corrected one §5.4 row and the §4.3 host-write bullet, but a second §5.4 row ("Stalled streamed-request-body upload, mechanics differ per adapter") still claimed Fastly's `between_bytes_timeout` bounds inter-chunk write gaps, and §8 risk 7 still treated write-side bounding as the documented Fastly behaviour | Both surfaces updated. §5.4 stalled-upload mechanics row rewritten: Fastly's `between_bytes_timeout` is documented as receive-side only — it does **not** bound guest-to-origin writes — so Fastly's write phase is `BestEffort` (no per-chunk-gap claim); the cooperative inter-chunk `is_expired()` check is the only adapter-side bound. §8 risk 7 retitled "Fastly streamed-upload write-phase has no SDK-configurable bound" and rewritten to say streamed-upload write-phase is `BestEffort` alongside the source-stream-yield gap. The "if a future host change relaxes that" footnote is replaced with the symmetric "if a future Fastly release **adds** a documented guest-write timeout, the claim could upgrade." Three surfaces now agree | +| **MEDIUM — Spin host-write race was mechanically wrong.** §4.4 said "each `OutgoingBody::write` host call is similarly raced against a wasi timer for the remaining deadline." WASI `output-stream` is nonblocking + readiness-polled, not blocking; `write()` itself never waits on host I/O. The implementable pattern is readiness-pollable-vs-timer, then `check_write()` for the permitted byte count, then `write()` within that count | §4.4 host-write race rewritten against the actual WASI contract. New four-step protocol: (a) obtain the stream's `subscribe()` pollable; (b) `futures::select!` the pollable's ready signal against a wasi monotonic-clock timer for `budget.deadline.remaining()`; (c) timer wins → drop the outgoing handle + return `gateway_timeout`; (d) pollable wins → `check_write()` for the permitted byte count + `write()` within that bound, looping until the chunk drains. Explicit clarification that `write()` itself never blocks on host I/O, so the bound is "within one timer-resolution tick of `budget.deadline`," not "during a blocking write call." §5.4 stalled-upload mechanics row updated to reference the readiness-pollable race instead of the implausible blocking-write race | +| **LOW — typo.** `docsare migrated` in §1.3 non-goals | Fixed: "scaffolding templates, and docs are migrated." | diff --git a/docs/superpowers/specs/2026-06-01-spin-kv-config.md b/docs/superpowers/specs/2026-06-01-spin-kv-config.md new file mode 100644 index 00000000..1e69d796 --- /dev/null +++ b/docs/superpowers/specs/2026-06-01-spin-kv-config.md @@ -0,0 +1,1632 @@ +# Plan: Move Spin Config Store onto KV + +**Status:** v12 — REVISION after tenth reviewer pass. Ready for +execution. **Reviewer green-lighted start.** + +**Goal:** Back `SpinConfigStore` with the Spin KV API (`spin_sdk::key_value`) +instead of Spin variables (`spin_sdk::variables`). Bring Spin's config +surface into structural parity with Cloudflare (KV-backed) and Fastly +(Config Store-backed), so `config push` writes through a real per-store +backend on all three cloud adapters. + +## v12 changelog + +Round-10 reviewer gave the verdict "yes, we can start" and +flagged 1 Low + 1 Nit. Both fixed: + +- **L1 (Stage 4/5 should explicitly REPLACE stale Spin-variable + tests)** — fixed. The current tests assert translated keys + - `[variables]` + `[component..variables]` writes at + `examples/app-demo/crates/app-demo-cli/tests/config_flow.rs:257` + and `crates/edgezero-adapter-spin/src/cli.rs:1846`. The plan + implied replacement via Task 4.6 (dry-run shape) and + Task 5.1 (drop variables writes) but didn't say so explicitly. + Added Task 4.7 and Task 5.5 to spell out the test rewrite: + delete the translated-key / two-table assertions; add seed + URL / JSON-body / no-POST-on-dry-run / status-code coverage. +- **Nit (reworded "backward-compatible" around run_app return)** — + fixed. The migration is hard-cutoff; "backward-compatible" + wording suggested legacy Spin-variable support was being + preserved (it isn't). Reworded throughout to + "source-compatible with the generated scaffold handler + signature" — narrower, accurate. + +## v11 changelog + +Round-9 reviewer flagged 1 Medium + 1 Low against v10. Both real +and fixed: + +- **M1 (unused `IntoResponse` import after run_app signature change)** + — fixed. Today `crates/edgezero-adapter-spin/src/lib.rs` imports + `spin_sdk::http::{IntoResponse, Request as SpinRequest, Response +as SpinResponse}` because `run_app` returns + `impl spin_sdk::http::IntoResponse`. After Task 3.5 changes the + return to `SpinFullResponse`, `IntoResponse` is no longer + referenced and the wasm-clippy `-D warnings` gate would fail on + `unused_imports`. Added an explicit substep to Task 3.5: drop + `IntoResponse` from the import line. Documented in the Scope + section under `src/lib.rs` too. +- **L1 (Stage 8 smoke test not executable as written)** — fixed. + `spin up` is foreground/long-running; the v10 step list + couldn't be pasted into a script. Stage 8 now provides a real + shell snippet that backgrounds `spin up`, polls + `127.0.0.1:3000` with `curl --silent --fail` until ready (5s + timeout, fails the test cleanly), runs `config push --local`, + asserts the curl, and cleans up the spin process in a `trap` + so a failed assertion never leaves an orphan listener on + port 3000. + +## v10 changelog + +Round-8 reviewer flagged 1 High against v9. Real and fixed: + +- **H1 (seed branch Result type mismatch)** — fixed. In v9, + `handle_seed_request_spin` returned bare `SpinFullResponse` but + `run_app_with_seeder`'s seed branch was returning that value + while the fall-through `run_app::(req).await` returns + `anyhow::Result`. Mismatched arm types in + the `if/else` would not compile. + + **Resolution**: change `handle_seed_request_spin` to return + `anyhow::Result` so both arms produce the + same type. As a side benefit this drops the `.expect("static- +shaped seed response")` from v9's D10 example, which was a + latent panic in a request handler. Internal failures + (`into_core_request`, `from_core_response`) now propagate via + `?` and surface as runtime errors instead of panics. Updated + in D10, Scope (lib.rs), and Task 3.5. + +## v9 changelog + +Round-7 reviewer flagged 2 High + 1 Medium against v8. All three +are real and fixed: + +- **H1 (`#[non_exhaustive]` + struct-literal across crates)** — + settled in [D8 update](#d8-push-context-schema). Rust rejects + struct-literal construction of a `#[non_exhaustive]` type from + outside its defining crate. Added a builder API: + `AdapterPushContext::new()` (returns the default), plus + `with_seed_url` / `with_seed_token` / `with_local` chained + setters. The CLI's `dispatch_push` builds via the builder + pattern, never the struct literal. `#[non_exhaustive]` stays so + future field additions don't break out-of-tree adapter + implementers (who only RECEIVE it via the trait method anyway). +- **H2 (`run_app_with_seeder` return-type mismatch with `run_app`)** — + settled. Today `run_app` returns + `anyhow::Result`; the opaque return type + can't be implicitly converted to a concrete `SpinFullResponse`, + so `run_app_with_seeder`'s fallthrough `run_app::(req).await` + wouldn't compile. **Resolution: change `run_app` to return + `anyhow::Result`** (the concrete type already + publicly aliased in `lib.rs`). This is **source-compatible with + the generated scaffold handler signature** (NOT a legacy-Spin- + variable carve-out — this migration is still hard-cutoff). The + existing template handler signature + `async fn handle(req: Request) -> anyhow::Result` + keeps compiling because `SpinFullResponse: IntoResponse`, so the + scaffold doesn't need re-running. Both `run_app` and + `run_app_with_seeder` now return the same concrete type, and + the fallthrough is a direct return. + Documented in D9 + Scope + Task 3.5. +- **M1 (D12 401 message omits short-token case)** — settled in + [D12 update](#d12-blocking-http-client). The 401 arm's message + now spells out all four fail-closed reasons (unset / blank / + whitespace-only / shorter than 16 bytes) so an operator who + set a 4-character placeholder doesn't waste time debugging the + wrong side. + +## v8 changelog + +Round-7 reviewer flagged 1 High + 1 Medium + 1 Low against v7. +Triage: + +- **H1 (D1 `label` field unused)** — **already fixed in v7 on + disk.** The reviewer was reading a stale snapshot. Line 329 of + the v7 file matches `SpinConfigBackend::Spin { label, store }` + and the error messages include `store \`{label}\`:`. No change + in v8. +- **M1 (Stage 3.5 stale)** — **already fixed in v7 on disk.** + Same stale-snapshot issue. Task 3.5 in v7 spells out + `anyhow::Result`, the template body swap, and + "unset / blank / shorter than 16 bytes" fail-closed behavior. + No change in v8. +- **L1 (D10 prose test list out-of-sync with Task 3.2)** — + **real.** Fixed in v8. D10's narrative list expanded to match + Task 3.2's full row set, grouped by surface (auth / + request-shape / store-resolution / write). Added a + "keep-in-sync" note so the two lists can't drift again. + +## v7 changelog + +Round-6 reviewer flagged 1 High + 3 Medium against v6. All addressed: + +- **H1 (Stage 8 smoke test would 401 itself)** — fixed. `test-token` + is 10 bytes and falls below v6's 16-byte floor, so the smoke test + would hit the fail-closed 401 path before any real KV write + happens. Replaced with `test-token-1234567890` (21 bytes) in both + the `spin up` env and the `app-demo-cli config push` env. +- **M1 (Stage 3 doesn't pin the 16-byte rule with a test)** — + fixed. Added explicit test rows to Task 3.2 covering + short-server-token paths: token unset → 401; token blank / + whitespace-only → 401; token 15 bytes → 401 (just under the + floor); token 16 bytes (offered correct on the wire) → 204 (just + at the floor). Task 3.5 explicitly references the floor check + when resolving `EDGEZERO__ADAPTERS__SPIN__SEED_TOKEN`. +- **M2 (`run_app_with_seeder` return shape mismatch with template)** — + fixed. Spec'd as `anyhow::Result` to mirror + the existing `run_app` shape and the scaffold template handler. + Operators can switch from `run_app::(req).await` to + `run_app_with_seeder::(req).await` with no signature change + on the `#[http_service]` handler. +- **M/L (`label` unused in `SpinConfigBackend::Spin`)** — fixed. + D1's `get` impl now uses `&self.label` in the unavailable error + messages so the field is read (no `-D warnings` dead-code + failure) AND so error logs name which platform store fired the + error — useful when the operator has multiple config stores. + +## v6 changelog + +Round-5 reviewer flagged 2 Medium + 2 Low + 1 Medium/Low against v5. +All addressed: + +- **M1 (Stage 1 acceptance vs Task 2.5)** — fixed. The Stage 1 + acceptance line previously said `config_store_contract_tests!` + must pass on host + wasm32-wasip2. Task 2.5 (v4 fix) correctly + scoped wasm KV out. Stage 1 now matches: "host-side + `config_store_contract_tests!` against the `InMemory` backend; + real KV write/read coverage lives in the Stage 8 `spin up` smoke + test". +- **M2 (token min-length still open)** — settled. **Q2 closed YES: + enforce a 16-byte minimum token at handler startup.** Below 16 + bytes (or unset/blank/whitespace-only) → fail-closed; every + request to the seed route returns 401. Cheap to implement, + prevents the worst accidental misconfiguration. D9 status table + updated to spell this out. Removed from open questions. +- **M/L (Cargo.toml scope checklist stale)** — fixed. The scope + line previously listed only `reqwest`; updated to mirror D11's + full set: `reqwest` (optional under `cli`), and non-optional + `serde` / `serde_json` / `subtle`. +- **L1 (Task 4.4 stale status list)** — fixed. The "Surface 401 / + 403 / 404 / 422" wording is replaced with "surface every D9 + status (400 / 401 / 403 / 404 / 405 / 415 / 422)" matching D12. +- **L2 (test backend uses `from_utf8_lossy`)** — fixed. The + `InMemory` config-store backend now uses strict UTF-8 (matches + production behavior). Added a doc comment + a "non-utf8 value + → unavailable" test to the contract-test fixture so the + divergence couldn't reappear. + +## v5 changelog + +Round-4 reviewer flagged 1 High + 4 Medium + 1 Low against v4. All +addressed: + +- **H1 (stale `build_config_registry` snippet)** — settled in + [Scope: edgezero-adapter-spin](#cratesedgezero-adapter-spin-the-heavy-crate) + and [Stage 2 Task 2.4](#stage-2--runtime-backend-swap--registry-rewrite). + Updated to async/error-propagating signature: returns + `anyhow::Result>`, awaits + `SpinConfigStore::open(...).await?` per id. The + `dispatch_with_registries` snippet shows + `build_config_registry(config_meta, env).await?`. +- **M1 (`PushContext` naming collision)** — settled. The trait-level + type is now **`AdapterPushContext`**; the CLI's internal + `PushContext` (config.rs:42) keeps its name. Updated everywhere + the new type is mentioned (D8, D12, Scope, Stages). +- **M2 (dispatch_push signature gap)** — settled in + [D8 update](#d8-push-context-schema). `load_push_context` now + resolves the `AdapterPushContext` upstream (it already takes + `&ConfigPushArgs` and reads `env` for store resolution; adding + the seed_url/token/local resolution there is natural). The + resolved `AdapterPushContext` is stashed in the CLI's + internal `PushContext` and `dispatch_push` reads it from there — + no signature change required on `dispatch_push` itself. +- **M3 (stale D9 wording about `subtle` gating)** — fixed. D9's + "gated under the spin feature" line removed; cross-reference to + D11 ("non-optional dep") added. +- **M4 (in-memory store key shape)** — settled in + [D1](#d1-backend-spin-kv-via-spin_sdkkey_valuestore) and + [Scope](#cratesedgezero-adapter-spin-the-heavy-crate). The + `InMemory` test backend is keyed plain `String → Bytes`. Removed + the conflicting "(label, key)" mention in the Scope section and + Task 2.2. The contract-test macro exercises one store at a time, + so plain `key → bytes` is enough. The handler-side + `InMemorySeedWriter` (D10) is the only place that needs to + distinguish stores — that one stays keyed `(label, key)` because + it serves multi-store seed requests. +- **L1 (version labels stale)** — fixed throughout: Stage 1 task + text now says "Move this plan into specs"; the open-questions + header is "(round 5)"; the settled-section header keeps "round 2" + as the historical pointer for when those decisions were taken. + +## v4 changelog + +Round-3 reviewer flagged 4 High + 2 Medium + 1 Low against v3. All +addressed: + +- **H1 (SpinConfigStore won't host-compile)** — settled in + [D1 update](#d1-backend-spin-kv-via-spin_sdkkey_valuestore). + Restored the cfg-gated backend enum pattern (matching the existing + shape in `config_store.rs`). Wasm variant holds the opened + `key_value::Store`; `InMemory` test variant holds a `BTreeMap`. + Construction is async on wasm, sync in tests. The trait `get` + dispatches on the variant. +- **H2 (`subtle` can't be wasm-only if core is host-tested)** — + settled in [D11 update](#d11-dependency-gating). Move `subtle` + out of the `spin` feature into a non-optional dependency. It's + tiny and compiles on both host and wasm; the host tests can + reach `subtle::ConstantTimeEq` without enabling `spin`. +- **H3 (JSON deps missing from scope)** — settled in + [D11 update](#d11-dependency-gating). Add `serde` + `serde_json` + as non-optional dependencies on `edgezero-adapter-spin`. Both + are already workspace deps; both compile on host AND wasm. CLI + POST body, seed handler core parser, and the migration story + all need them. +- **H4 (`--local` could fall back to manifest prod URL)** — + settled in [D3 update](#d3-config-push---local-for-spin) and + [D8 update](#d8-push-context-schema). `--local` short-circuits + the manifest fallback completely. New `PushContext::local: bool` + field. Resolution chain when `local = true`: `--seed-url` CLI + flag → `EDGEZERO__ADAPTERS__SPIN__LOCAL_SEED_URL` env → builtin + default `http://127.0.0.1:3000/__edgezero/config/seed`. NEVER + reads the manifest's prod `seed_url`. +- **M1 (Stage 2.5 overclaims wasm contract)** — settled. CI's spin + wasm matrix runs `wasmtime run`, which doesn't host Spin KV. + Task 2.5 now: host-side `config_store_contract_tests!` against + the `InMemory` backend. Real KV write/read coverage moves to the + end-to-end smoke test in Stage 8 that requires `spin up`. +- **M2 (CLI error mapping incomplete)** — settled in + [D12 update](#d12-blocking-http-client). The CLI match now + covers every intentional status: 400, 401, 403, 404, 405, 415, 422. Each gets a specific message. +- **L1 (`cargo tree | grep '^reqwest'` may miss prefixed entries)** + — settled in [Stage 8 update](#stage-8--verify-gate). Replace + with `cargo tree -i reqwest -p edgezero-adapter-spin --features +spin --target wasm32-wasip2` which errors when `reqwest` is not + in the tree at all (the desired outcome). Pair check uses the + same form for `subtle` (which MUST resolve). + +## v3 changelog + +Round-2 reviewer flagged 4 High + 2 Medium + 1 Low against v2. All +addressed: + +- **H1 (sync trait vs async reqwest)** — settled in + [D12](#d12-blocking-http-client). Use `reqwest::blocking::Client` + so the existing sync `Adapter::push_config_entries*` trait shape + is preserved. Workspace `reqwest` gets the `blocking` + `json` + features added. No runtime needs to be threaded through the + dispatcher. +- **H2 (`subtle` gated to wrong feature)** — settled. The token + comparison runs in the wasm **seed handler**, not in the host + CLI. Move `subtle` from `cli` to the `spin` feature in + `edgezero-adapter-spin/Cargo.toml`. D9 updated to reflect. +- **H3 (store validation vs env-remapped platform names)** — + settled in [D9 update](#d9-seed-handler-security). The seed + handler validates the body's `store` field against the set of + env-resolved **platform** labels (computed from + `A::stores().config` × `EnvConfig::store_name("config", id)`), + not the logical ids. Operators can run with + `EDGEZERO__STORES__CONFIG__APP_CONFIG__NAME=prod-config` and + push a body `{"store": "prod-config", ...}` — the validation + passes because that's the correct platform label. +- **H4 (host-testable seed signature)** — settled in + [D10 update](#d10-testable-seed-writer). Split the handler into + two layers: a host-compilable `handle_seed_request_core` that + takes `edgezero_core::http::Request` / returns + `edgezero_core::http::Response`, and a thin wasm wrapper that + translates Spin types ↔ core types and lives under the wasm + cfg gate. Unit tests target the core layer. +- **M1 (open-on-every-get)** — settled in [D1 update](#d1-backend-spin-kv-via-spin_sdkkey_valuestore). + `SpinConfigStore` holds the opened `key_value::Store` handle. + Construction is async, so `build_config_registry` becomes async + too (called from `dispatch_with_registries`, already async). + Missing `key_value_stores` declaration surfaces at registry + build time, not on first config read. +- **M2 (manifest `seed_url` is open but assumed)** — settled. + `[adapters.spin.commands].seed_url` IS a supported source. + Moved from open questions to settled. Resolution order codified + in D8. +- **L1 (`cargo tree | grep reqwest` exit-code semantics)** — + fixed in Stage 8: use `! cargo tree … | grep -q reqwest` so + the step fails ONLY when reqwest leaks into the wasm tree. + +## v2 changelog + +Reviewer flagged 4 High + 3 Medium + 1 Low against v1. All addressed: + +- **H1 (per-id config registry)** — added Stage 2 Task 2.4: rewrite + `build_config_registry` in `request.rs` to open one + `spin_sdk::key_value::Store` per declared id using + `env.store_name("config", id)` — mirroring the existing + `build_kv_registry`. The old "one shared handle cloned for every id" + shape goes away with Single→Multi. +- **H2 (seed URL/token transport schema)** — settled in new + [D8](#d8-push-context-schema). Adds `PushContext` to the + `push_config_entries*` trait signature, threads adapter command + metadata through `dispatch_push`, and gives `ConfigPushArgs` two + new CLI args (`--seed-url`, `--seed-token`) plus env fallbacks. +- **H3 (config-key validation)** — settled in + [D1.5](#d15-validator-relaxation). `validate_app_config_keys` + becomes a no-op for spin (KV accepts arbitrary key bytes). Existing + uppercase / dash / start-char tests are deleted; new tests pin + "any UTF-8 key passes". +- **H4 (seed handler security spec)** — settled in + [D9](#d9-seed-handler-security). POST-only, fail-closed on missing + or blank token, explicit status code table, and scaffolding is + opt-in (`run_app_with_seeder` is what the scaffold uses; existing + `run_app` is unchanged so downstream apps can opt out). +- **M1 (scaffold spin.toml key_value_stores)** — Stage 5 Task 5.4 + added: generator `spin.toml.hbs` declares + `key_value_stores = ["app_config"]` by default. `provision` + remains the safe path for already-scaffolded projects. +- **M2 (testable seed handler)** — settled in + [D10](#d10-testable-seed-writer). Introduces `trait SeedWriter` so + unit tests inject a fake; production uses a `SpinKvSeedWriter` + that calls the hostcall. +- **M3 (HTTP client gating)** — settled in + [D11](#d11-http-client-feature-gating). `reqwest` becomes a + `cli`-feature-only dep on `edgezero-adapter-spin` (native-only); + confirmed not pulled into the wasm target. Plan lists the exact + Cargo.toml edits. +- **L1 (legacy flag)** — settled. **No `--legacy-spin-variables` + flag.** Hard-cutoff matches the rest of the rewrite's posture. + Removed from open questions. + +Three remaining open questions for round 2 — see [Open questions](#open-questions-round-2). + +## Why + +Today `SpinConfigStore` wraps `spin_sdk::variables`. That has four +practical costs: + +1. **No dynamic config.** Spin variables are baked into `spin.toml` + at build time and override-able only via `SPIN_VARIABLE_` + env vars or `spin up --env`. Pushing a new value mid-run requires + a redeploy. +2. **Shared namespace with secrets.** `SpinSecretStore::get_bytes` + ALSO reads `spin_sdk::variables`, so config keys and `#[secret]` + values share the same flat namespace. We carry an explicit + collision-check in `validate_typed_secrets` to compensate + (`cli.rs:425-449`). +3. **Single-capable.** Spin is forced into the `single_store_kinds` + spec axis for config (one flat variable namespace per app) while + Cloudflare and Fastly are Multi. Operators can't have e.g. + `app_config` + `tenant_overrides` as two separate Spin stores. +4. **No platform parity.** `config push --adapter spin` edits + `spin.toml`; the other two cloud adapters shell out to a + platform-native bulk-write CLI (`fastly config-store-entry create` + / `wrangler kv bulk put`). The mental model split is real. + +KV-backed config fixes all four. + +## Design decisions + +### D1. Backend: Spin KV via `spin_sdk::key_value::Store` + +Runtime change in `crates/edgezero-adapter-spin/src/config_store.rs`: + +**v4**: keep the existing **cfg-gated backend enum** pattern from +today's `config_store.rs` so the file compiles on host (for tests) +without dragging in `spin_sdk` types. The wasm variant holds the +opened `key_value::Store`; the `InMemory` test variant holds a +`BTreeMap` (was `HashMap` in the +variables-backed impl). Construction is async on wasm, sync in +tests; the trait method dispatches on the variant. + +```rust +pub struct SpinConfigStore { + inner: SpinConfigBackend, +} + +enum SpinConfigBackend { + #[cfg(all(feature = "spin", target_arch = "wasm32"))] + Spin { + label: String, + store: spin_sdk::key_value::Store, // opened ONCE at dispatch + }, + #[cfg(test)] + InMemory(BTreeMap), + /// Never constructed; keeps the enum inhabited outside production Spin and tests. + #[cfg(not(any(all(feature = "spin", target_arch = "wasm32"), test)))] + _Uninhabited(std::convert::Infallible), +} + +impl SpinConfigStore { + /// Open the platform store once. Called from + /// `build_config_registry` during dispatch setup. Wasm-only; + /// tests use `from_entries`. + #[cfg(all(feature = "spin", target_arch = "wasm32"))] + pub async fn open(label: String) -> Result { + let store = spin_sdk::key_value::Store::open(&label).await + .map_err(|err| ConfigStoreError::unavailable(format!("open `{label}`: {err}")))?; + Ok(Self { inner: SpinConfigBackend::Spin { label, store } }) + } + + #[cfg(test)] + fn from_entries(entries: impl IntoIterator) -> Self { + Self { inner: SpinConfigBackend::InMemory(entries.into_iter().collect()) } + } +} + +#[async_trait(?Send)] +impl ConfigStore for SpinConfigStore { + async fn get(&self, key: &str) -> Result, ConfigStoreError> { + match &self.inner { + #[cfg(all(feature = "spin", target_arch = "wasm32"))] + SpinConfigBackend::Spin { label, store } => { + // v7 (round-6 M/L): use `label` in error wording so + // (a) the field isn't dead-code under -D warnings, + // (b) the operator running multi-store sees which + // platform store fired the failure. + match store.get(key).await { + Ok(Some(bytes)) => String::from_utf8(bytes).map(Some).map_err(|err| { + ConfigStoreError::unavailable(format!( + "store `{label}`: non-utf8 value for `{key}`: {err}" + )) + }), + Ok(None) => Ok(None), + Err(err) => Err(ConfigStoreError::unavailable(format!( + "store `{label}`: {err}" + ))), + } + } + #[cfg(test)] + SpinConfigBackend::InMemory(map) => match map.get(key) { + Some(bytes) => String::from_utf8(bytes.to_vec()).map(Some).map_err(|err| { + // v6 fix (L2): strict UTF-8 to match the wasm + // backend's behaviour. `from_utf8_lossy` would + // hide a divergence between test and prod. + ConfigStoreError::unavailable(format!("non-utf8 value for `{key}`: {err}")) + }), + None => Ok(None), + }, + #[cfg(not(any(all(feature = "spin", target_arch = "wasm32"), test)))] + SpinConfigBackend::_Uninhabited(never) => match *never {}, + } + } +} +``` + +Drops the `.→__` translation (KV accepts arbitrary key bytes). + +### D1.5. Validator relaxation + +Reviewer (H3): the existing `validate_app_config_keys` enforces Spin +variable syntax (lowercase, `^[a-z][a-z0-9_]*$` after `.→__`). With +KV-backed config, none of that applies — KV stores accept arbitrary +key bytes. + +Concrete change in `crates/edgezero-adapter-spin/src/cli.rs`: + +- `validate_app_config_keys`: collapses to `Ok(())`. The function stays + in place (trait shape) but no longer rejects anything. +- `translate_key_for_spin`: deleted. Callers (push, validator) read + keys verbatim. +- `is_valid_spin_key` / `spin_key_rule_violation`: stay — still used + by `validate_typed_secrets` for `#[secret]` value validation + (secrets still live in variables; see D7). +- Tests deleted (Stage 6 Task 6.1): + - `validate_app_config_keys_*` tests covering uppercase rejection, + dash rejection, leading-digit rejection, etc. +- Tests added (Stage 6 Task 6.2): + - `validate_app_config_keys_accepts_any_utf8` (covers `Greeting`, + `feature-flag`, `1numeric_start`, `with.dots`, `with spaces`). + +### D2. Push: HTTP POST to a seeding handler + +Spin has no `spin kv put` CLI subcommand and no bulk-write hostcall +reachable from outside the wasm runtime. Two options ruled out: + +- **Write Spin's SQLite KV file directly** — Spin doesn't guarantee + schema stability across versions. Brittle. +- **Wait for upstream `spin kv` CLI** — months of latency at best. + +So: the adapter ships a small **seeding handler** that +`app-demo-cli config push --adapter spin` HTTP-POSTs. + +### D3. `config push --local` for Spin + +With D2, `--local` and the default push both HTTP-POST to the +seeding handler, but the URL resolution chains are **strictly +disjoint** — `--local` never falls back to the manifest's prod URL. +This protects an operator who forgets to start `spin up` locally +from accidentally pushing to production. + +**Without `--local`** (prod push), `seed_url` resolves in order: + +1. `--seed-url` CLI arg. +2. `EDGEZERO__ADAPTERS__SPIN__SEED_URL` env. +3. `[adapters.spin.commands].seed_url` in `edgezero.toml`. + +Errors with a clear message if none are set. + +**With `--local`** (local push), `seed_url` resolves in order: + +1. `--seed-url` CLI arg (explicit operator override always wins). +2. `EDGEZERO__ADAPTERS__SPIN__LOCAL_SEED_URL` env (separate from + the prod env var — operators who set both don't accidentally + leak prod URL into local pushes). +3. Builtin default `http://127.0.0.1:3000/__edgezero/config/seed`. + +The manifest's `[adapters.spin.commands].seed_url` is **never read** +when `--local` is set. The dispatcher needs to know about +`args.local` before building `AdapterPushContext` — see D8. + +### D4. Provision: declare the KV store in `spin.toml` + +`provision --adapter spin` already edits `spin.toml`. Extension: for +each declared `[stores.config].id`, append the env-resolved platform +name to the component's `key_value_stores = [...]` list. Idempotent +on existing entries. Same pattern as the existing KV provision flow. + +### D5. Capability: Spin becomes Multi for config + +Drop `"config"` from `Spin::single_store_kinds` (currently +`&["config", "secrets"]` → `&["secrets"]`). Strict validation no +longer rejects `[stores.config].ids.len() > 1` for spin. + +### D6. Collision check goes away + +`validate_typed_secrets` currently builds a Spin variable name set of +`{flattened config keys} ∪ {#[secret] values}` and errors on +duplicates. With config off the variables namespace, the +intersection is empty by construction. Delete the check + spec/doc +text that explains it. + +### D7. Secrets stay on variables (unchanged) + +`SpinSecretStore` continues to use `spin_sdk::variables`. The +single-flat-namespace constraint applies only to secrets now. +`#[secret]` values still get the lowercase-only translation; the +runtime check stays. + +### D8. Push context schema + +Reviewer (H2): the v1 plan said "no CLI-side changes" but then +required the Spin adapter to read seed URL/token from somewhere the +trait signature doesn't expose. Fixed by introducing +`AdapterPushContext` (v5: renamed from v4's `PushContext` to avoid +collision with the CLI's internal `PushContext` struct at +[config.rs:42]). + +Changes to `crates/edgezero-adapter/src/registry.rs`: + +```rust +#[derive(Debug, Clone, Default)] +#[non_exhaustive] +pub struct AdapterPushContext<'a> { + /// Already-resolved seed URL. Caller (CLI dispatch) follows the + /// resolution chain for prod or local per D3 and produces the + /// final string here. `None` means "no URL was set anywhere + /// in the resolution chain" -- the adapter errors loudly. + pub seed_url: Option<&'a str>, + /// Already-resolved seed token. + pub seed_token: Option<&'a str>, + /// `true` when the operator passed `--local`. Adapters that + /// have a separate local-emulator path use this to pick the + /// right writeback target; adapters where local == default + /// can ignore it. + pub local: bool, +} + +impl<'a> AdapterPushContext<'a> { + /// Construct a default context: no seed URL / token, prod (not + /// local). v9 (round-7 H1): Rust rejects struct-literal + /// construction of `#[non_exhaustive]` types from outside the + /// defining crate, so the CLI MUST build via this constructor + /// and the `with_*` setters below. + #[must_use] + pub fn new() -> Self { + Self::default() + } + + #[must_use] + pub fn with_seed_url(mut self, url: &'a str) -> Self { + self.seed_url = Some(url); + self + } + + #[must_use] + pub fn with_seed_token(mut self, token: &'a str) -> Self { + self.seed_token = Some(token); + self + } + + #[must_use] + pub fn with_local(mut self, local: bool) -> Self { + self.local = local; + self + } +} + +fn push_config_entries( + &self, + manifest_root: &Path, + adapter_manifest_path: Option<&str>, + component_selector: Option<&str>, + store: &ResolvedStoreId, + entries: &[(String, String)], + push_ctx: &AdapterPushContext<'_>, // NEW + dry_run: bool, +) -> Result, String> { ... } +``` + +`AdapterPushContext` is non-exhaustive so we can grow it later +without breaking downstream adapters that RECEIVE it via the +trait method. The CLI (which CONSTRUCTS it) is in-tree and uses +the builder API, so the `#[non_exhaustive]` constraint is +honoured at the source-code level. Same shape on +`push_config_entries_local`. + +Changes to `crates/edgezero-cli/src/args.rs`: + +```rust +pub struct ConfigPushArgs { + /* … existing fields … */ + /// Seed URL for adapters that push via HTTP (currently spin). + /// Resolution: this flag → `EDGEZERO__ADAPTERS____SEED_URL` + /// → `[adapters..commands].seed_url`. + #[arg(long)] + pub seed_url: Option, + /// Seed token for adapters that push via HTTP (currently spin). + /// Resolution: this flag → `EDGEZERO__ADAPTERS____SEED_TOKEN`. + /// Never read from `edgezero.toml` (don't put secrets in the + /// manifest). + #[arg(long)] + pub seed_token: Option, +} +``` + +Manifest schema: `ManifestAdapterCommands` (currently lives in +`crates/edgezero-core/src/manifest.rs`) gains an optional +`seed_url: Option` field. Already covered by `#[non_exhaustive]`, +so additive. + +Changes to `crates/edgezero-cli/src/config.rs`: + +The CLI's internal `PushContext` struct (config.rs:42) gains a +field carrying the resolved adapter context: + +```rust +struct PushContext { + // … existing fields … + /// Resolved by `load_push_context` from CLI args + env + + /// manifest per D3's prod/local chains. Stashed here so + /// `dispatch_push` can pass it through to the trait method + /// without re-reading args / env. Owned strings (not + /// borrows) so the lifetime story stays simple. + adapter_push_ctx: ResolvedAdapterPushContext, +} + +struct ResolvedAdapterPushContext { + seed_url: Option, + seed_token: Option, + local: bool, +} +``` + +`load_push_context(args: &ConfigPushArgs)` (which already takes +`&ConfigPushArgs` and reads `env` for store resolution) gains the +resolution logic per D3's disjoint chains: + +```rust +fn load_push_context(args: &ConfigPushArgs) -> Result { + // … existing manifest + store resolution … + + let env = EnvConfig::from_env(); + let name = &args.adapter; + + let seed_url = args.seed_url.clone().or_else(|| { + if args.local { + // D3 local chain: env → builtin default. Manifest NEVER consulted. + env.get(&["adapters", name, "local_seed_url"]) + .map(str::to_owned) + .or_else(|| Some("http://127.0.0.1:3000/__edgezero/config/seed".to_owned())) + } else { + // D3 prod chain: env → manifest. + env.get(&["adapters", name, "seed_url"]).map(str::to_owned) + .or_else(|| manifest.adapters.get(name) + .and_then(|cfg| cfg.adapter.commands.seed_url.clone())) + } + }); + + let seed_token = args.seed_token.clone() + .or_else(|| env.get(&["adapters", name, "seed_token"]).map(str::to_owned)); + // Manifest never consulted for tokens, even on the prod chain. + + Ok(PushContext { + // … existing fields … + adapter_push_ctx: ResolvedAdapterPushContext { + seed_url, seed_token, local: args.local, + }, + }) +} +``` + +`dispatch_push` (unchanged signature) just borrows from the +already-resolved context when building the `AdapterPushContext` +to hand the trait method: + +```rust +fn dispatch_push(ctx: &PushContext, entries: &[(String, String)], + dry_run: bool, local: bool) -> Result<(), String> { + let r = &ctx.adapter_push_ctx; + // v9 (round-7 H1): build via the builder, NOT a struct literal — + // AdapterPushContext is #[non_exhaustive] and external crates + // can't use struct-literal construction. + let mut push_ctx = AdapterPushContext::new().with_local(r.local); + if let Some(url) = r.seed_url.as_deref() { + push_ctx = push_ctx.with_seed_url(url); + } + if let Some(token) = r.seed_token.as_deref() { + push_ctx = push_ctx.with_seed_token(token); + } + let lines = if local { + ctx.adapter.push_config_entries_local(/* … */, &push_ctx, dry_run)? + } else { + ctx.adapter.push_config_entries(/* … */, &push_ctx, dry_run)? + }; + // … existing logging … +} +``` + +For non-Spin adapters this is constructed but unused — costs nothing. + +This change is **breaking** for any out-of-tree adapter that +implements `Adapter::push_config_entries*` (no in-tree adapter +outside the four ships today). Document in the next release notes. + +### D9. Seed handler security + +Reviewer (H4): pin the security contract before code. + +**Route**: `/__edgezero/config/seed`. Single fixed path, not +configurable per app — keeps every Spin deploy's seeding surface +predictable for ops scripts. + +**Method**: POST only. GET/PUT/DELETE/HEAD/OPTIONS/PATCH → 405. + +**Headers**: + +- `x-edgezero-seed: ` — REQUIRED. Compared constant-time + against `EDGEZERO__ADAPTERS__SPIN__SEED_TOKEN`. +- `content-type: application/json` — REQUIRED. Anything else → 415. + +**Body shape** (validated against this schema): + +```json +{ + "store": "app_config", + "entries": [ + { "key": "greeting", "value": "hello" }, + { "key": "service.timeout_ms", "value": "1500" } + ] +} +``` + +The `store` field is the **platform label** (what `Store::open(name)` +needs), not the logical id. The handler builds the set of accepted +labels from `A::stores().config` × `EnvConfig::store_name("config", id)` +— so an operator running with +`EDGEZERO__STORES__CONFIG__APP_CONFIG__NAME=prod-config` pushes +`{"store": "prod-config", …}` and the validation passes. A body +mentioning the logical id `"app_config"` in that environment is +correctly rejected (404). + +The CLI does the resolution before POSTing — `dispatch_push` already +resolves the platform label via `env.store_name("config", id)`, so +the body the CLI emits matches what the handler expects. + +**Status code table**: + +| Code | Condition | +| ---- | ------------------------------------------------------------------------------------------------------------------------------------------------ | +| 204 | Success. Body empty. | +| 400 | Malformed JSON, missing `store`, missing/empty `entries`, or any `key`/`value` not a string. | +| 401 | `x-edgezero-seed` header missing, or `EDGEZERO__ADAPTERS__SPIN__SEED_TOKEN` env unset/blank/whitespace-only/shorter than 16 bytes (fail-closed). | +| 403 | `x-edgezero-seed` header present but does not match the env token. | +| 404 | `store` does not match any env-resolved platform label for a declared `[stores.config].id`. | +| 405 | Non-POST method. | +| 415 | `content-type` not `application/json`. | +| 422 | KV store open / set hostcall returned an error mid-write (partial-write — see body for the failed key). | + +**Fail-closed contract**: if `EDGEZERO__ADAPTERS__SPIN__SEED_TOKEN` +is unset, blank, whitespace-only, OR **shorter than 16 bytes** +(v6 — round-5 Q2 settled), EVERY request to the seed route returns +401 — even with no `x-edgezero-seed` header. We never default a +token, never accept "no token = no auth", and never accept a +short-enough token to brute-force in a reasonable time. An operator +who forgot to set the token, or set a 4-character placeholder, gets +a clean error rather than an open writeable endpoint. + +**Why 16 bytes**: at 8 bits/byte that's 128 bits of token surface. +Even a single-shot guess against a constant-time compare has +~2^-128 odds; rate-limiting from the Spin runtime kills any +practical brute-force. Below 16 bytes the operator is almost +certainly using a placeholder ("dev", "test123") that doesn't +belong in production OR local. + +**Token comparison**: `subtle::ConstantTimeEq` (workspace dep, +non-optional on the spin adapter per [D11](#d11-dependency-gating) +— v4's "gated under `spin` feature" was wrong; the host +unit tests for `handle_seed_request_core` need to reach this type +without enabling `--features spin`). Prevents timing-oracle +leakage of the token prefix. + +**Logging**: log auth failures at `warn` level with the source IP +(via `spin-client-addr` header) but NEVER the offered token. + +**Opt-in vs always-scaffolded**: scaffold-side OPT-IN — the +generator emits `run_app_with_seeder` for new projects, but +`run_app` (no seeding route) stays available for projects that +explicitly opt out by switching the entrypoint. Existing +deployments keep `run_app` and aren't affected. + +### D10. Testable seed writer + +Reviewer (M2): the v1 plan called for unit tests on the seed handler +but `spin_sdk::key_value` is wasm-runtime-bound. Solution: trait + +fake. + +**v3**: split the handler into two layers so tests compile on the +host without dragging in `spin_sdk` types. The core layer is +host-compilable; the wasm wrapper translates Spin types to/from +`edgezero_core::http::{Request, Response}`. + +`crates/edgezero-adapter-spin/src/seed.rs`: + +```rust +// ---- Core layer (host-compilable) --------------------------------- + +#[async_trait(?Send)] +pub(crate) trait SeedWriter { + async fn write(&self, store: &str, key: &str, value: &str) -> Result<(), SeedError>; +} + +/// Host-compilable seed handler core. Takes a core HTTP `Request` +/// (body already buffered into `Body::Once`) and returns a core HTTP +/// `Response`. Parsing, auth, status-code routing, and the writer +/// dispatch all live here. NO spin_sdk references. +pub(crate) async fn handle_seed_request_core( + req: &edgezero_core::http::Request, + writer: &W, + valid_token: Option<&str>, // None → fail-closed (401) + known_platform_labels: &[String], // env-resolved labels per H3 +) -> edgezero_core::http::Response { ... } + +#[cfg(test)] +pub(crate) struct InMemorySeedWriter { + pub(crate) entries: Mutex>, // (label, key) → value +} + +// ---- Wasm wrapper (spin-runtime only) ----------------------------- + +#[cfg(all(feature = "spin", target_arch = "wasm32"))] +pub(crate) struct SpinKvSeedWriter; + +#[cfg(all(feature = "spin", target_arch = "wasm32"))] +#[async_trait(?Send)] +impl SeedWriter for SpinKvSeedWriter { + async fn write(&self, store: &str, key: &str, value: &str) -> Result<(), SeedError> { + let kv = spin_sdk::key_value::Store::open(store).await?; + kv.set(key, value.as_bytes()).await?; + Ok(()) + } +} + +/// Thin wasm wrapper: Spin `Request` → core `Request` → core handler +/// → core `Response` → Spin `Response`. Lives where the existing +/// `into_core_request` / `from_core_response` helpers do. +/// +/// v10 (round-8 H1): returns `anyhow::Result` so +/// it matches `run_app`'s shape (allows `?` at the call site in +/// `run_app_with_seeder` instead of a `.expect()` panic). +#[cfg(all(feature = "spin", target_arch = "wasm32"))] +pub(crate) async fn handle_seed_request_spin( + req: spin_sdk::http::Request, + writer: &SpinKvSeedWriter, + valid_token: Option<&str>, + known_platform_labels: &[String], +) -> anyhow::Result { + let core_req = crate::request::into_core_request(req).await?; + let core_resp = handle_seed_request_core(&core_req, writer, + valid_token, known_platform_labels).await; + Ok(crate::response::from_core_response(core_resp).await?) +} +``` + +Host-compilable unit tests (live in `seed.rs`'s `#[cfg(test)] mod +tests`). The full row set lives in Task 3.2 — keep this list in +sync if either side moves: + +- **Auth surface (v6 16-byte floor + fail-closed)**: + - Token unset (env missing) → 401. + - Token blank (`""`) → 401. + - Token whitespace-only (`" "`) → 401. + - Token 15 bytes (just under the floor) → 401, even when the + client offers the matching token on the wire. + - Token exactly 16 bytes + matching wire token → 204 + (just-at-the-floor sentinel). + - Token 16 bytes + missing `x-edgezero-seed` → 401. + - Token 16 bytes + wrong `x-edgezero-seed` → 403. +- **Request-shape surface**: + - Non-POST method → 405. + - `content-type` not `application/json` → 415. + - Malformed JSON → 400. + - Missing `store` / `entries` / non-string values → 400. +- **Store-resolution surface**: + - Unknown store (no env-resolved label matches) → 404. +- **Write surface**: + - `SeedWriter::write` errors mid-stream → 422 (body names the + failed key). + - Happy path → 204 + `InMemorySeedWriter` recorded all entries. + +### D11. Dependency gating + +Three new deps. Different gates for different reasons: + +| Dep | Gate | Why | +| ---------------------- | ------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `reqwest` | `cli` feature (host-only) | Pulls `tokio` + TLS — would explode the wasm bundle and fail to compile on `wasm32-wasip2`. Only the host CLI uses it. | +| `subtle` | **non-optional** (host + wasm) | Used by the seed handler core (wasm) AND by its host-compilable unit tests (D10). Reviewer H2: can't be `spin`-gated when host tests reach `ConstantTimeEq` without `--features spin`. Tiny dep; compiles cleanly on both targets. | +| `serde` + `serde_json` | **non-optional** (host + wasm) | Reviewer H3: seed core parses JSON (wasm), CLI builds JSON body (host), `--features cli` body type derives `Serialize` / `Deserialize`. Both already workspace deps; both compile on host AND wasm. | + +Concrete `Cargo.toml` change on `crates/edgezero-adapter-spin`: + +```toml +[features] +spin = [ + "dep:spin-sdk", +] +cli = [ + "dep:edgezero-adapter", + "edgezero-adapter/cli", + "dep:ctor", + "dep:reqwest", # NEW (host HTTP push) + "dep:toml", + "dep:toml_edit", + "dep:walkdir", +] + +[dependencies] +# … existing entries … +reqwest = { workspace = true, optional = true } +serde = { workspace = true } # NEW; non-optional +serde_json = { workspace = true } # NEW; non-optional +subtle = { workspace = true } # NEW; non-optional +``` + +**Why subtle is not optional**: gating it under `spin` would hide +it from the host build, but the host unit tests for +`handle_seed_request_core` (D10) need to construct `subtle::Choice` +and friends. Making it non-optional is the simplest correct +answer; the dep is ~5 KB compiled. + +**Why serde/serde_json are not optional**: similarly, the core +seed handler runs JSON parsing on both wasm (production) and host +(tests). The Cargo features model can't express "available in +wasm under `spin` AND in host under `cfg(test)`" cleanly — making +it always-on does the right thing. + +Verification step (added to Stage 8 gate): use `cargo tree -i` +which errors when the dep is not in the tree at all (per L1). Two +checks: + +```sh +# reqwest MUST NOT be in the wasm tree. +# `cargo tree -i ` exits non-zero when isn't a dep -- +# which is the success case here. Invert with `!`: +! cargo tree -i reqwest -p edgezero-adapter-spin \ + --features spin --target wasm32-wasip2 2>/dev/null + +# subtle / serde_json MUST be in the wasm tree. +# `cargo tree -i ` succeeds when the dep IS present: +cargo tree -i subtle -p edgezero-adapter-spin \ + --features spin --target wasm32-wasip2 +cargo tree -i serde_json -p edgezero-adapter-spin \ + --features spin --target wasm32-wasip2 +``` + +### D12. Blocking HTTP client + +Reviewer (H1): the existing `Adapter::push_config_entries*` trait +methods are SYNCHRONOUS. `reqwest::Client::post` is async. Two +options: + +- **(a) `reqwest::blocking`** — keeps the sync trait shape. Needs + `blocking` + `json` features on the workspace `reqwest`. +- **(b) Async trait + runtime in dispatcher** — clean but bigger + blast radius (every adapter impl signature changes; CLI gets a + tokio dep). + +**Resolution: (a).** Workspace `Cargo.toml` change: + +```toml +reqwest = { version = "0.13", default-features = false, + features = ["rustls", "blocking", "json"] } +``` + +Spin's `push_config_entries`: + +```rust +let client = reqwest::blocking::Client::new(); +let response = client + .post(&seed_url) + .header("x-edgezero-seed", token) + .json(&body) // serde-derived; `json` feature + .send() + .map_err(|err| match err.is_connect() { + true => format!("seed POST to {seed_url} failed: connection refused. Is the Spin app running?"), + false => format!("seed POST to {seed_url} failed: {err}"), + })?; +// Map every status the handler intentionally emits (D9 status table). +match response.status().as_u16() { + 204 => Ok(vec![format!( + "pushed {} entries to seed handler at {seed_url}", + entries.len() + )]), + 400 => Err(format!( + "seed handler rejected (400 Bad Request): {}. Check CLI version / store id.", + response.text().unwrap_or_default() + )), + 401 => Err(format!( + "seed handler rejected (401 Unauthorized). Fail-closed reasons (D9): \ + server-side `EDGEZERO__ADAPTERS__SPIN__SEED_TOKEN` is unset, blank, \ + whitespace-only, or shorter than 16 bytes; OR your client-side \ + `--seed-token` / `EDGEZERO__ADAPTERS__SPIN__SEED_TOKEN` is missing. \ + Check the server's env first -- a 4-character placeholder triggers \ + this even when the wire token matches." + )), + 403 => Err(format!( + "seed handler rejected (403 Forbidden): x-edgezero-seed mismatch. \ + Check that the token on the client matches the server's \ + EDGEZERO__ADAPTERS__SPIN__SEED_TOKEN" + )), + 404 => Err(format!( + "seed handler rejected (404 Not Found): store `{}` is not a recognised platform label. \ + Check `[stores.config].ids` and any EDGEZERO__STORES__CONFIG____NAME overrides", + store.platform + )), + 405 => Err(format!( + "seed handler rejected (405 Method Not Allowed). \ + This usually means a transparent proxy rewrote the POST -- check intermediaries" + )), + 415 => Err(format!( + "seed handler rejected (415 Unsupported Media Type). \ + Internal: the CLI should always set content-type: application/json" + )), + 422 => Err(format!( + "seed handler rejected (422 Unprocessable): KV write failed mid-stream: {}", + response.text().unwrap_or_default() + )), + other => Err(format!( + "seed handler returned unexpected status {other}: {}", + response.text().unwrap_or_default() + )), +} +``` + +The blocking client is fine for a CLI binary; it spins up its own +single-thread tokio runtime under the hood. No external runtime +needed. + +## Migration story (hard-cutoff) + +Existing Spin deployments break on upgrade. No legacy flag. + +- Apps that read config via `ctx.config_store_default()` keep working + unchanged after a `config push --adapter spin` against the new + backend. +- Apps that read config via `spin_sdk::variables::get(...)` directly + break. They must either (a) move to the EdgeZero abstraction, or + (b) keep their values in `[variables]` and stop using EdgeZero's + config store for those keys. +- Existing `spin.toml` files that declare config keys in + `[variables]` need a one-time migration: the values move from + `[variables].` (and `[component..variables].`) to + the KV store via `config push --adapter spin`. After confirming + the values land in KV, the operator manually removes the + now-orphaned `[variables].` entries. + +Migration guide section title: "Spin: variables → KV for config +(2026-Q3)". + +## Scope (files touched) + +### crates/edgezero-adapter-spin (the heavy crate) + +- `src/config_store.rs` — rewrite `SpinConfigStore` per + [D1](#d1-backend-spin-kv-via-spin_sdkkey_valuestore). Cfg-gated + backend enum: wasm variant holds the opened + `key_value::Store`; the `InMemory` test variant is keyed + plain `String → bytes::Bytes` (one store at a time — that's all + the contract-test macro exercises). Drop `translate_key`. +- `src/request.rs` — rewrite `build_config_registry` as **async** + per H1 (v5: returns `anyhow::Result` so registry-build errors + propagate up the dispatcher): + ```rust + async fn build_config_registry( + meta: Option, + env: &EnvConfig, + ) -> anyhow::Result> { + let Some(meta) = meta else { return Ok(None); }; + let mut by_id = BTreeMap::new(); + for id in meta.ids { + let label = env.store_name("config", id); // per-id env resolution + let store = SpinConfigStore::open(label).await + .map_err(|err| anyhow::anyhow!( + "open config store for id `{id}`: {err}" + ))?; + by_id.insert((*id).to_owned(), + ConfigStoreHandle::new(Arc::new(store))); + } + Ok(StoreRegistry::from_parts(by_id, meta.default.to_owned())) + } + ``` + And in `dispatch_with_registries`: + ```rust + let config_registry = build_config_registry(config_meta, env).await?; + ``` + Mirrors `build_kv_registry`'s existing async + Result shape. +- `src/cli.rs` — + - `push_config_entries`: HTTP POST against `seed_url` (resolved + from `AdapterPushContext` via D8). Body is the D9 schema. + Uses `reqwest` (D11/D12). Surfaces every status code from D9 + with clear messages (D12). + - `push_config_entries_local`: defaults `seed_url` to + `http://127.0.0.1:3000/__edgezero/config/seed` if + `AdapterPushContext` didn't supply one. Otherwise identical. + - `provision`: emit `key_value_stores = [...]` entries per D4. + Drop the `[variables]` / `[component..variables]` + config-declaration writes (the migration guide tells operators + to remove existing ones). + - `validate_app_config_keys`: no-op per D1.5. Delete + `translate_key_for_spin`. + - `validate_typed_secrets`: delete the collision-check block per + D6. Keep the secret-name format check. + - `single_store_kinds`: returns `&["secrets"]`. +- `src/seed.rs` — NEW. `SeedWriter` trait + `SpinKvSeedWriter` + + `handle_seed_request`. ~200 LoC + tests. +- `src/lib.rs` — `pub mod seed;`. Plus two functions sharing + the same concrete return type (v9 round-7 H2 fix — `run_app`'s + old `impl IntoResponse` opaque return type made the fall-through + uninvocable from `run_app_with_seeder`). **v11 round-9 M1**: + drop `IntoResponse` from the + `use spin_sdk::http::{IntoResponse, Request as SpinRequest, +Response as SpinResponse}` import line — once `run_app` returns + `SpinFullResponse`, `IntoResponse` is no longer referenced and + the wasm-clippy gate would fail on `unused_imports`. + + ```rust + pub async fn run_app(req: SpinRequest) + -> anyhow::Result { /* existing body */ } + + pub async fn run_app_with_seeder(req: SpinRequest) + -> anyhow::Result { + // Route /__edgezero/config/seed to the seed handler, else + // fall through to run_app::. v10 (round-8 H1): + // handle_seed_request_spin now also returns + // anyhow::Result, so both arms are + // type-compatible. + if req.uri().path() == "/__edgezero/config/seed" { + handle_seed_request_spin(req, &SpinKvSeedWriter, …).await + } else { + run_app::(req).await + } + } + ``` + + Changing `run_app` from `impl IntoResponse` → `SpinFullResponse` + is **source-compatible with the generated scaffold handler + signature** (NOT a Spin-variable backwards-compat carve-out — + this migration stays hard-cutoff). `SpinFullResponse: IntoResponse`, + so the existing + `async fn handle(req: Request) -> anyhow::Result` + template signature keeps accepting the value through type + coercion — no need to regenerate already-scaffolded projects. + Token resolved from + `EnvConfig::get(&["adapters", "spin", "seed_token"])`; if unset + / blank / shorter than 16 bytes (D9), every request hitting the + seed route returns 401 (fail-closed). + +- `src/templates/src/lib.rs.hbs` — scaffold uses + `run_app_with_seeder` per + [D9 opt-in scaffolding](#d9-seed-handler-security). +- `src/templates/spin.toml.hbs` — add + `key_value_stores = ["app_config"]` to the default + `[component.*]` block per M1. Scaffolded projects work with + `config push --adapter spin --local` out of the box. +- `Cargo.toml` — per D11: `reqwest` optional under `cli` feature + (host HTTP push); `serde`, `serde_json`, `subtle` non-optional + (used by both the wasm seed handler core and its host-compilable + unit tests, so feature-gating would break the test layer). + +### crates/edgezero-adapter (the trait) + +- `src/registry.rs` — `AdapterPushContext` struct + threaded + through `push_config_entries` / `push_config_entries_local` + per D8. + +### crates/edgezero-core + +- `src/manifest.rs` — `ManifestAdapterCommands::seed_url: +Option` per D8 (additive; `#[non_exhaustive]` already in + place). + +### crates/edgezero-cli + +- `src/args.rs` — `ConfigPushArgs::seed_url` / `seed_token` per D8. +- `src/config.rs` — per D8: `load_push_context` resolves the + `ResolvedAdapterPushContext` (owned `String`s) and stashes it + on the CLI's `PushContext`. `dispatch_push` constructs the + borrowing `AdapterPushContext<'_>` from it and hands that to + the trait method. Update the `push_args` test fixture. + +### examples/app-demo + +- `crates/app-demo-adapter-spin/src/lib.rs` — switch + `run_app` → `run_app_with_seeder`. +- `crates/app-demo-adapter-spin/spin.toml` — add `app_config` to + `key_value_stores = [...]`. Remove `[variables].greeting` / + `feature__new_checkout` / `service__timeout_ms` (now in KV). +- `edgezero.toml` — `[adapters.spin.commands].seed_url = +"http://127.0.0.1:3000/__edgezero/config/seed"` so contributors + don't need to set the env var locally. + +### Workspace + +- `Cargo.toml` — three changes: + - `reqwest`: add `blocking` + `json` features to the existing + workspace declaration so the CLI's sync push (D12) works: + `reqwest = { version = "0.13", default-features = false, +features = ["rustls", "blocking", "json"] }`. + - `subtle`: NEW workspace dep for constant-time token + comparison: `subtle = "2"` (non-optional per D11; used by + both the wasm seed handler core and its host tests). + - `serde` / `serde_json`: already workspace deps; just declared + as non-optional on `edgezero-adapter-spin` per D11. + +### docs + +- `guide/adapters/spin.md` — rewrite config-store section: + KV-backed, no `.→__` translation, no collision check. New + seed-handler section explaining the security model + token + rotation guidance. +- `guide/manifest-store-migration.md` — new section "Spin: + variables → KV for config". +- `guide/cli-walkthrough.md` — update the Spin row in the + `config push` section. Add a `config push --adapter spin --local` + example that mirrors the Fastly one. +- `guide/cli-reference.md` — document `--seed-url` / + `--seed-token` on `config push`. + +## Stages + +### Stage 1 — Spec promotion + tracking issue + +- [ ] Move this plan into + `docs/superpowers/specs/2026-06-01-spin-kv-config.md`. +- [ ] Open a tracking issue with the acceptance criteria + (matches Task 2.5 + Stage 8 — wasm KV hostcalls aren't + reachable under the CI wasm matrix's `wasmtime run`, so + real KV coverage lives in the `spin up` smoke test): - host-side `config_store_contract_tests!` passes against + the `InMemory` backend; - the wasm32-wasip2 contract test compiles + runs (no live + KV hostcalls — those are runtime-bound); - collision check gone; - provision writes the right `key_value_stores`; - seed handler hits all status codes from D9's table; - `app-demo` works end-to-end under `spin up` with real + KV writes via `config push --adapter spin --local`. + +### Stage 2 — Runtime backend swap + registry rewrite + +- [ ] **Task 2.1**: Rewrite `SpinConfigStore` per D1. +- [ ] **Task 2.2** (M4 fix): `InMemory` test backend is keyed + plain `String → bytes::Bytes`. (One store per + `config_store_contract_tests!` invocation — no need to track + labels at this layer. The multi-store seed-handler test + fixture `InMemorySeedWriter` IS the place that tracks + `(label, key)`; see D10.) **v6**: `get` uses strict + `String::from_utf8` (NOT `from_utf8_lossy`) to match the + wasm backend's error path. New contract-test case + `non_utf8_value_returns_unavailable` documents the + behaviour and prevents future divergence. +- [ ] **Task 2.3**: Delete `translate_key_for_spin` and its callers + inside `config_store.rs`. +- [ ] **Task 2.4** (H1 + M1): Rewrite `build_config_registry` in + `request.rs` as **async**. Per declared id, await + `SpinConfigStore::open(env.store_name("config", id))` so the + `key_value::Store` handle is opened ONCE at dispatch setup + and cached in `SpinConfigStore`. Thread `&env` to + `dispatch_with_registries`'s config branch. Missing + `key_value_stores = [...]` surfaces as a registry-build + error, not a first-read error. +- [ ] **Task 2.5** (M1 update): `config_store_contract_tests!` + against the `InMemory` backend on the **host** target. Real + KV write/read coverage CANNOT live in the wasm contract test + — CI runs that via plain `wasmtime run`, which does not host + Spin's KV hostcalls. Real coverage moves to the Stage 8 + end-to-end smoke test (which requires `spin up`). + +### Stage 3 — Seed handler + testable writer + +- [ ] **Task 3.1** (D10 split): `crates/edgezero-adapter-spin/src/seed.rs`. + Build the host-compilable core: `SeedWriter` trait, + `InMemorySeedWriter`, `handle_seed_request_core(req: &Request, + …) -> Response` using `edgezero_core::http` types only. NO + `spin_sdk` references in the core layer. +- [ ] **Task 3.2**: Host unit tests against `InMemorySeedWriter` + covering every row of the D9 status code table PLUS the + v6 short-token fail-closed cases (M1 fix). Required test + rows: - Token unset (env var missing) → 401. - Token blank ("") → 401. - Token whitespace-only (" ") → 401. - Token 15 bytes (one under the floor) → 401, EVEN when + the client offers the matching token on the wire. - Token exactly 16 bytes + matching wire token → 204. - Token 16 bytes + missing wire header → 401. - Token 16 bytes + wrong wire token → 403. - Non-POST method → 405. - `content-type` not `application/json` → 415. - Malformed JSON → 400. - Missing `store` / `entries` / non-string values → 400. - Unknown store (no env-resolved label matches) → 404. - `SeedWriter::write` errors mid-stream → 422. - Happy path → 204 + `InMemorySeedWriter` recorded all + entries. +- [ ] **Task 3.3** (H3): Token comparison uses + `subtle::ConstantTimeEq`. The `known_platform_labels` arg is + computed by the caller (the wasm wrapper / lib.rs) from + `A::stores().config` × `env.store_name("config", id)`. +- [ ] **Task 3.4** (D10 wrapper, wasm-gated): Thin + `rust + pub(crate) async fn handle_seed_request_spin( + req: spin_sdk::http::Request, + writer: &SpinKvSeedWriter, + valid_token: Option<&str>, + known_platform_labels: &[String], + ) -> anyhow::Result + ` + that translates Spin `Request` → `edgezero_core::http::Request` + via `into_core_request` (uses `?`), calls the core handler, + translates back via `from_core_response` (uses `?`). v10 + (round-8 H1): returns `anyhow::Result` so + `run_app_with_seeder`'s seed branch is type-compatible with + the fall-through `run_app::` branch. NO `.expect()` panic + in the request path. +- [ ] **Task 3.5** (M2 + v9 round-7 H2 + v10 round-8 H1 + v11 + round-9 M1): 1. Change `run_app`'s signature from + `anyhow::Result` to + `anyhow::Result` (concrete type already + publicly aliased). **Source-compatible with the generated + scaffold handler signature** (NOT a Spin-variable + carve-out — this migration stays hard-cutoff): + `SpinFullResponse: IntoResponse`, so the template + `async fn handle(...) -> anyhow::Result` + keeps compiling without re-scaffolding. + 1a. Drop `IntoResponse` from the + `use spin_sdk::http::{...}` import in `src/lib.rs` — once + `run_app` no longer returns `impl IntoResponse`, the + import is unused and the wasm-clippy `-D warnings` gate + fails on `unused_imports`. 2. Add `run_app_with_seeder` with the SAME return shape: + `rust + pub async fn run_app_with_seeder(req: SpinRequest) + -> anyhow::Result + ` + Routes `/__edgezero/config/seed` to + `handle_seed_request_spin(req, &SpinKvSeedWriter, …).await` + (returns `anyhow::Result` per Task 3.4) + and falls through to `run_app::(req).await`. Both + arms produce `anyhow::Result` so the + `if/else` typechecks and either result propagates via + the outer `?` at the handler call site. 3. Scaffold template handler stays + `async fn handle(req: Request) -> anyhow::Result` + with the body swapped from + `edgezero_adapter_spin::run_app::(req).await` to + `edgezero_adapter_spin::run_app_with_seeder::(req).await`. 4. Token resolved from `EnvConfig::get(&["adapters", "spin", + "seed_token"])`; if unset / blank / shorter than 16 bytes + (D9), every request hitting the seed route returns 401 + (fail-closed). + +### Stage 4 — CLI push rewrite + +- [ ] **Task 4.1** (D8): Add `AdapterPushContext` to the trait + (renamed from v4's `PushContext` to avoid colliding with + the CLI's internal `PushContext`). Update all four existing + impls to take it (no-ops for fastly/cloudflare/axum; spin + reads from it). +- [ ] **Task 4.2**: Add `seed_url` / `seed_token` to + `ConfigPushArgs`. Update the `push_args` test fixture and the + `app-demo-cli/tests/config_flow.rs` helper. +- [ ] **Task 4.3**: Rewrite `load_push_context` to resolve the + `ResolvedAdapterPushContext` (D3's disjoint prod/local + chains per D8). `dispatch_push` converts to the + borrow-shaped `AdapterPushContext<'_>` at call time. +- [ ] **Task 4.4** (D12): Implement spin `push_config_entries` via + `reqwest::blocking::Client::post`. The CLI must resolve the + body's `store` field to the **platform label** (via + `env.store_name("config", id)`), per H3. JSON body per D9. + Surface every status from D9's table — 400 / 401 / 403 / + 404 / 405 / 415 / 422 — per D12's match block. Handle + connection-refused with a specific hint ("is the spin app + running?"). +- [ ] **Task 4.5**: Implement spin `push_config_entries_local`. + Defaults `seed_url` to local. Otherwise delegates to the + Task 4.4 impl. +- [ ] **Task 4.6**: `--dry-run` prints the planned URL + entries + without POSTing. Tests for the dry-run shape. +- [ ] **Task 4.7** (v12 round-10 L1): **Delete and replace stale + Spin-variable push tests.** Today's push tests in + `examples/app-demo/crates/app-demo-cli/tests/config_flow.rs` + (around line 257) and `crates/edgezero-adapter-spin/src/cli.rs` + (around line 1846) assert: - dotted-key → underscore translation - `[variables].` writes - `[component..variables].` writes + Under KV-backed push these assertions are wrong (variables + table is no longer touched). Delete them; add coverage for + the new contract: - Push body contains the resolved platform-label `store` + (with and without `EDGEZERO__STORES__CONFIG__APP_CONFIG__NAME=…` + override). - Push body's `entries` array is the flattened typed + `AppDemoConfig` minus `#[secret]` / `#[secret(store_ref)]` + (mirrors the existing config-flow assertions, just on the + body shape instead of the manifest edit). - `--dry-run` produces NO POST (verify via a mock seed + endpoint that records hits). - Each D9 status code surfaces as the matching D12 error + string (covers 400 / 401 / 403 / 404 / 405 / 415 / 422 + happy 204). + +### Stage 5 — Provision + scaffold + manifest updates + +- [ ] **Task 5.1**: Drop `[variables]` / + `[component..variables]` config-key writes from spin's + `provision`. +- [ ] **Task 5.2**: For each `[stores.config].id`, append the + platform name to the component's `key_value_stores = [...]`. + Idempotent. New `provision_writes_config_kv_store_entry` + test. +- [ ] **Task 5.3**: `single_store_kinds` returns `&["secrets"]`. +- [ ] **Task 5.4** (M1): Generator `spin.toml.hbs` declares + `key_value_stores = ["app_config"]` by default. Add a test + in `generated_project_builds.rs` that checks the rendered + spin.toml contains the entry. +- [ ] **Task 5.5** (v12 round-10 L1): **Delete stale + provision-side variable-write assertions** that pair with + the Stage 4.7 deletions. Concrete sites in + `crates/edgezero-adapter-spin/src/cli.rs` (around line 1846) + currently assert the provision step emits `[variables]` / + `[component..variables]` blocks for declared config + ids. Under D4 those writes are gone. Replace with assertions + that: - For each `[stores.config].id`, the platform label appears + in the component's `key_value_stores = [...]` (Task 5.2's + change). - `[variables]` / `[component..variables]` are NOT + touched for config ids (regression guard so a future + change doesn't silently revive the old path). - Existing `[variables]` entries for `#[secret]` fields + (Task 6.2 keeps these) are preserved. + +### Stage 6 — Validator changes + +- [ ] **Task 6.1** (H3): Delete uppercase/dash/leading-digit tests + on `validate_app_config_keys`. Replace with + `validate_app_config_keys_accepts_any_utf8`. +- [ ] **Task 6.2**: Delete `validate_typed_secrets`'s + collision-check block per D6. Keep the secret-name format + check (it still validates `#[secret]` values against Spin + variable rules). +- [ ] **Task 6.3**: Update strict-completeness tests: + `[stores.config].ids.len() > 1` now PASSES for spin. + +### Stage 7 — Docs + app-demo migration + +- [ ] **Task 7.1**: Rewrite `docs/guide/adapters/spin.md` config + section. Add seed-handler section with the D9 security table. +- [ ] **Task 7.2**: Add the migration section to + `docs/guide/manifest-store-migration.md`. +- [ ] **Task 7.3**: Update `docs/guide/cli-walkthrough.md` Spin row + add `--adapter spin --local` example. +- [ ] **Task 7.4**: Update `docs/guide/cli-reference.md` for + `--seed-url` / `--seed-token`. +- [ ] **Task 7.5**: app-demo migration in ONE commit (per + resolved Q5): switch entrypoint to `run_app_with_seeder`, + update `spin.toml`, set `seed_url` in `edgezero.toml`. + +### Stage 8 — Verify gate + +- [ ] Full gate: cargo fmt, host clippy --workspace, workspace + tests, all three adapter wasm-clippy gates, docs + lint/format/build. +- [ ] Spin wasm contract test under wasmtime (wasm32-wasip2). +- [ ] **Wasm dep gating checks** (D11, fixed per L1 — use + `cargo tree -i` which errors when the dep is absent). + ``sh + # reqwest MUST NOT leak into the wasm tree. `cargo tree -i` + # errors when reqwest isn't a dep; invert with `!`: + ! cargo tree -i reqwest -p edgezero-adapter-spin \ + --features spin --target wasm32-wasip2 2>/dev/null + # subtle / serde_json MUST be in the wasm tree. + cargo tree -i subtle -p edgezero-adapter-spin \ + --features spin --target wasm32-wasip2 + cargo tree -i serde_json -p edgezero-adapter-spin \ + --features spin --target wasm32-wasip2 + `` +- [ ] **End-to-end smoke test** in `examples/app-demo` (v11 + round-9 L1: shell-form, backgrounded, port-wait + trap + cleanup so the test can actually be run in CI / pasted + into a shell). + + ```sh + #!/usr/bin/env bash + set -euo pipefail + + readonly TOKEN="test-token-1234567890" + readonly PORT=3000 + readonly URL="http://127.0.0.1:${PORT}" + export EDGEZERO__ADAPTERS__SPIN__SEED_TOKEN="$TOKEN" + + cd examples/app-demo + + # 1. Build the wasm so `spin up` has something to serve. + (cd crates/app-demo-adapter-spin && \ + cargo build --target wasm32-wasip2 --release \ + -p app-demo-adapter-spin) + + # 2. Background `spin up` and arrange to kill it on exit. + (cd crates/app-demo-adapter-spin && spin up --listen "127.0.0.1:${PORT}") \ + &> /tmp/edgezero-spin-smoke.log & + readonly SPIN_PID=$! + trap 'kill $SPIN_PID 2>/dev/null || true; wait $SPIN_PID 2>/dev/null || true' \ + EXIT INT TERM + + # 3. Wait up to 10s for the listener (Spin warm-up + KV + # backend init). 20 × 0.5s = 10s. Fail clean on timeout. + for _ in $(seq 1 20); do + if curl --silent --fail --max-time 1 "${URL}/" \ + > /dev/null 2>&1; then + break + fi + sleep 0.5 + done + if ! curl --silent --fail --max-time 1 "${URL}/" \ + > /dev/null 2>&1; then + echo "spin up did not bind ${URL} within 10s" >&2 + tail -n 100 /tmp/edgezero-spin-smoke.log >&2 + exit 1 + fi + + # 4. Push config to the LOCAL endpoint. The token env var + # is inherited from the parent shell (line 5). + cargo run -p app-demo-cli --quiet -- \ + config push --adapter spin --local + + # 5. Assert the pushed value flows through to the handler. + readonly GOT="$(curl --silent --fail "${URL}/config/greeting")" + readonly WANT="hello from app-demo" + if [[ "$GOT" != "$WANT" ]]; then + echo "smoke test FAILED: got=${GOT@Q} want=${WANT@Q}" >&2 + exit 1 + fi + echo "smoke test PASSED: GET /config/greeting → ${GOT@Q}" + # trap kills SPIN_PID on exit. + ``` + + The token value (`test-token-1234567890`, 21 bytes) clears + the v6 16-byte floor on BOTH sides (server `spin up` + inherits the var; CLI `config push` inherits the var). + The `trap` ensures no orphan `spin up` lingers on port 3000 + if the assertion fails — important for re-runnability. + +## Open questions + +None outstanding. All round-2/3/5 questions are settled. See the +"Settled" section below for the historical decisions. + +## Settled + +- **Q1 (round 2) → YES**: `[adapters.spin.commands].seed_url` IS a + valid source (third in the resolution order after CLI flag and + env). `seed_token` stays env/CLI only — never manifest. +- **Q2 (round 5) → YES, 16-byte floor**: The seed handler rejects + tokens shorter than 16 bytes at startup with a fail-closed 401 + on every request. See D9 "Fail-closed contract" for rationale. +- **Q3 (round 2) → ONE COMMIT**: Stage 7.5 ships + `run_app_with_seeder` switch + `spin.toml` KV declaration + + `edgezero.toml` seed_url together for atomic reversibility. + +## Estimated scope (v4) + +- **Code**: 14 files modified, 1 new (`seed.rs`), ~820 LoC impl + - ~430 LoC tests. (Up from v3 — D1's cfg-gated backend enum, + the H4 disjoint local resolution chain in `dispatch_push`, and + the extra D12 status-code arms add ~70 LoC; H2/H3 non-optional + dep moves are zero-LoC on the runtime side.) +- **Docs**: 4 files modified, ~100 LoC prose. +- **Migration**: hard-cutoff (resolved per L1). +- **Time**: 2 focused days assuming no surprises in the spin + hostcall surface. + +## Risks (v2 additions) + +- **`PushContext` is a breaking trait change for any out-of-tree + adapter**. Document in release notes; no in-tree adapter outside + the four ships today. +- **`reqwest` adds ~3 MB to the host CLI binary**. Acceptable for + a dev tool; flag if it ever becomes a problem. +- **Token enforcement in CI**: the end-to-end smoke test needs the + `EDGEZERO__ADAPTERS__SPIN__SEED_TOKEN` env var to flow into both + `spin up` and `app-demo-cli`. Test harness sets it once. diff --git a/examples/app-demo/Cargo.lock b/examples/app-demo/Cargo.lock index fc3583bd..53d9dbc9 100644 --- a/examples/app-demo/Cargo.lock +++ b/examples/app-demo/Cargo.lock @@ -8,6 +8,18 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "once_cell", + "version_check", + "zerocopy", +] + [[package]] name = "aho-corasick" version = "1.1.4" @@ -41,6 +53,56 @@ dependencies = [ "libc", ] +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + [[package]] name = "anyhow" version = "1.0.102" @@ -95,6 +157,24 @@ dependencies = [ "spin-sdk", ] +[[package]] +name = "app-demo-cli" +version = "0.1.0" +dependencies = [ + "app-demo-core", + "clap", + "edgezero-adapter", + "edgezero-adapter-axum", + "edgezero-adapter-spin", + "edgezero-cli", + "edgezero-core", + "futures", + "log", + "rusqlite", + "serde_json", + "tempfile", +] + [[package]] name = "app-demo-core" version = "0.1.0" @@ -105,6 +185,7 @@ dependencies = [ "futures", "serde", "serde_json", + "tempfile", "validator", ] @@ -253,9 +334,9 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" [[package]] name = "bitflags" -version = "2.11.0" +version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" +checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" [[package]] name = "block-buffer" @@ -266,6 +347,15 @@ dependencies = [ "generic-array", ] +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + [[package]] name = "brotli" version = "8.0.2" @@ -342,6 +432,46 @@ dependencies = [ "windows-link", ] +[[package]] +name = "clap" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + [[package]] name = "cmake" version = "0.1.57" @@ -351,6 +481,12 @@ dependencies = [ "cc", ] +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + [[package]] name = "colored" version = "2.2.0" @@ -432,6 +568,26 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "crypto-common" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "ctor" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d765eb1c0bda10d31e0ea185f5ee15da532d60b0912d2bd1441783439e749c5" +dependencies = [ + "link-section", + "linktime-proc-macro", +] + [[package]] name = "darling" version = "0.20.11" @@ -477,6 +633,37 @@ dependencies = [ "serde_core", ] +[[package]] +name = "derive_builder" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "507dfb09ea8b7fa618fcf76e953f4f5e192547945816d5358edffe39f6f94947" +dependencies = [ + "derive_builder_macro", +] + +[[package]] +name = "derive_builder_core" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d5bcf7b024d6835cfb3d473887cd966994907effbe9227e8c8219824d06c4e8" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "derive_builder_macro" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab63b0e2bf4d5928aff72e83a7dace85d7bba5fe12dcc3c5a572d78caffd3f3c" +dependencies = [ + "derive_builder_core", + "syn 2.0.117", +] + [[package]] name = "digest" version = "0.9.0" @@ -486,6 +673,16 @@ dependencies = [ "generic-array", ] +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer 0.10.4", + "crypto-common", +] + [[package]] name = "displaydoc" version = "0.2.5" @@ -509,6 +706,13 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" +[[package]] +name = "edgezero-adapter" +version = "0.1.0" +dependencies = [ + "toml", +] + [[package]] name = "edgezero-adapter-axum" version = "0.1.0" @@ -517,6 +721,8 @@ dependencies = [ "async-trait", "axum", "bytes", + "ctor", + "edgezero-adapter", "edgezero-core", "futures", "futures-util", @@ -524,11 +730,14 @@ dependencies = [ "log", "redb", "reqwest", + "serde_json", "simple_logger 5.1.0", "thiserror 2.0.18", "tokio", + "toml", "tower", "tracing", + "walkdir", ] [[package]] @@ -539,12 +748,17 @@ dependencies = [ "async-trait", "brotli", "bytes", + "ctor", + "edgezero-adapter", "edgezero-core", "flate2", "futures", "futures-util", "log", "serde_json", + "tempfile", + "toml_edit", + "walkdir", "worker", ] @@ -558,6 +772,8 @@ dependencies = [ "brotli", "bytes", "chrono", + "ctor", + "edgezero-adapter", "edgezero-core", "fastly", "fern", @@ -566,6 +782,10 @@ dependencies = [ "futures-util", "log", "log-fastly", + "serde_json", + "thiserror 2.0.18", + "toml_edit", + "walkdir", ] [[package]] @@ -576,12 +796,44 @@ dependencies = [ "async-trait", "brotli", "bytes", + "ctor", + "edgezero-adapter", "edgezero-core", "flate2", "futures", "futures-util", "log", + "rusqlite", + "serde", + "serde_json", "spin-sdk", + "subtle", + "thiserror 2.0.18", + "toml", + "toml_edit", + "walkdir", +] + +[[package]] +name = "edgezero-cli" +version = "0.1.0" +dependencies = [ + "clap", + "edgezero-adapter", + "edgezero-adapter-axum", + "edgezero-adapter-cloudflare", + "edgezero-adapter-fastly", + "edgezero-adapter-spin", + "edgezero-core", + "futures", + "handlebars", + "log", + "serde", + "serde_json", + "simple_logger 5.1.0", + "thiserror 2.0.18", + "toml", + "validator", ] [[package]] @@ -656,6 +908,18 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "fallible-iterator" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" + +[[package]] +name = "fallible-streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" + [[package]] name = "fastly" version = "0.12.0" @@ -677,7 +941,7 @@ dependencies = [ "serde_json", "serde_repr", "serde_urlencoded", - "sha2", + "sha2 0.9.9", "smallvec", "thiserror 1.0.69", "time", @@ -715,9 +979,15 @@ dependencies = [ "fastly-shared", "http", "wasip2", - "wit-bindgen", + "wit-bindgen 0.51.0", ] +[[package]] +name = "fastrand" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" + [[package]] name = "fern" version = "0.7.1" @@ -751,9 +1021,9 @@ checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" [[package]] name = "foldhash" -version = "0.1.5" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" [[package]] name = "form_urlencoded" @@ -877,7 +1147,7 @@ dependencies = [ "cfg-if", "js-sys", "libc", - "wasi 0.11.1+wasi-snapshot-preview1", + "wasi", "wasm-bindgen", ] @@ -895,20 +1165,48 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "handlebars" +version = "6.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d43ccdfe15a81ab0a8af639e90254227c9a46afd9c5f5b6ec7efaa345c4b0f00" +dependencies = [ + "derive_builder", + "log", + "num-order", + "pest", + "pest_derive", + "serde", + "serde_json", + "thiserror 2.0.18", +] + [[package]] name = "hashbrown" -version = "0.15.5" +version = "0.14.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" dependencies = [ - "foldhash", + "ahash", ] [[package]] name = "hashbrown" -version = "0.16.1" +version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +dependencies = [ + "foldhash", +] + +[[package]] +name = "hashlink" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ba4ff7128dee98c7dc9794b6a411377e1404dba1c97deb8d1a55297bd25d8af" +dependencies = [ + "hashbrown 0.14.5", +] [[package]] name = "heck" @@ -1162,12 +1460,12 @@ dependencies = [ [[package]] name = "indexmap" -version = "2.13.0" +version = "2.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" dependencies = [ "equivalent", - "hashbrown 0.16.1", + "hashbrown 0.17.1", "serde", "serde_core", ] @@ -1188,6 +1486,12 @@ dependencies = [ "serde", ] +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + [[package]] name = "itertools" version = "0.13.0" @@ -1265,6 +1569,35 @@ version = "0.2.183" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d" +[[package]] +name = "libsqlite3-sys" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "link-section" +version = "0.17.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d1e908a416d6e9f725743b84a36feea40c4c131e805fbc26d61f9f451f36080" + +[[package]] +name = "linktime-proc-macro" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a44cd706ff0d503ee32b2071166510ca27e281228de10cd3aa8d35ff94560f81" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + [[package]] name = "litemap" version = "0.8.1" @@ -1341,7 +1674,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" dependencies = [ "libc", - "wasi 0.11.1+wasi-snapshot-preview1", + "wasi", "windows-sys 0.61.2", ] @@ -1351,6 +1684,21 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf97ec579c3c42f953ef76dbf8d55ac91fb219dde70e49aa4a6b7d74e9919050" +[[package]] +name = "num-modular" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17bb261bf36fa7d83f4c294f834e91256769097b3cb505d44831e0a179ac647f" + +[[package]] +name = "num-order" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "537b596b97c40fcf8056d153049eb22f481c17ebce72a513ec9286e4986d1bb6" +dependencies = [ + "num-modular", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -1375,6 +1723,12 @@ version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + [[package]] name = "opaque-debug" version = "0.3.1" @@ -1393,6 +1747,49 @@ version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" +[[package]] +name = "pest" +version = "2.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0848c601009d37dfa3430c4666e147e49cdcf1b92ecd3e63657d8a5f19da662" +dependencies = [ + "memchr", + "ucd-trie", +] + +[[package]] +name = "pest_derive" +version = "2.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11f486f1ea21e6c10ed15d5a7c77165d0ee443402f0780849d1768e7d9d6fe77" +dependencies = [ + "pest", + "pest_generator", +] + +[[package]] +name = "pest_generator" +version = "2.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8040c4647b13b210a963c1ed407c1ff4fdfa01c31d6d2a098218702e6664f94f" +dependencies = [ + "pest", + "pest_meta", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "pest_meta" +version = "2.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89815c69d36021a140146f26659a81d6c2afa33d216d736dd4be5381a7362220" +dependencies = [ + "pest", + "sha2 0.10.9", +] + [[package]] name = "pin-project" version = "1.1.11" @@ -1425,6 +1822,12 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" +[[package]] +name = "pkg-config" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" + [[package]] name = "potential_utf" version = "0.1.4" @@ -1592,9 +1995,9 @@ dependencies = [ [[package]] name = "redb" -version = "4.0.0" +version = "4.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67f7f231ea7b1172b7ac00ccf96b1250f0fb5a16d5585836aa4ebc997df7cbde" +checksum = "8e925444704b5f17d32bf42f5b6e2df050bceebc3dcd6e71cc73dafe8092e839" dependencies = [ "libc", ] @@ -1636,7 +2039,9 @@ checksum = "ab3f43e3283ab1488b624b44b0e988d0acea0b3214e694730a055cb6b2efa801" dependencies = [ "base64", "bytes", + "futures-channel", "futures-core", + "futures-util", "http", "http-body", "http-body-util", @@ -1651,6 +2056,8 @@ dependencies = [ "rustls", "rustls-pki-types", "rustls-platform-verifier", + "serde", + "serde_json", "sync_wrapper", "tokio", "tokio-rustls", @@ -1678,13 +2085,17 @@ dependencies = [ ] [[package]] -name = "routefinder" -version = "0.5.4" +name = "rusqlite" +version = "0.32.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0971d3c8943a6267d6bd0d782fdc4afa7593e7381a92a3df950ff58897e066b5" +checksum = "7753b721174eb8ff87a9a0e799e2d7bc3749323e773db92e0984debb00019d6e" dependencies = [ - "smartcow", - "smartstring", + "bitflags 2.11.1", + "fallible-iterator", + "fallible-streaming-iterator", + "hashlink", + "libsqlite3-sys", + "smallvec", ] [[package]] @@ -1693,6 +2104,19 @@ version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags 2.11.1", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + [[package]] name = "rustls" version = "0.23.37" @@ -1804,7 +2228,7 @@ version = "3.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.11.1", "core-foundation", "core-foundation-sys", "libc", @@ -1930,13 +2354,24 @@ version = "0.9.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4d58a1e1bf39749807d89cf2d98ac2dfa0ff1cb3faa38fbb64dd88ac8013d800" dependencies = [ - "block-buffer", + "block-buffer 0.9.0", "cfg-if", "cpufeatures", - "digest", + "digest 0.9.0", "opaque-debug", ] +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest 0.10.7", +] + [[package]] name = "shlex" version = "1.3.0" @@ -1995,26 +2430,6 @@ version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" -[[package]] -name = "smartcow" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "656fcb1c1fca8c4655372134ce87d8afdf5ec5949ebabe8d314be0141d8b5da2" -dependencies = [ - "smartstring", -] - -[[package]] -name = "smartstring" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3fb72c633efbaa2dd666986505016c32c3044395ceaf881518399d2f4127ee29" -dependencies = [ - "autocfg", - "static_assertions", - "version_check", -] - [[package]] name = "socket2" version = "0.6.3" @@ -2025,25 +2440,12 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "spin-executor" -version = "5.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bba409d00af758cd5de128da4a801e891af0545138f66a688f025f6d4e33870b" -dependencies = [ - "futures", - "once_cell", - "wasi 0.13.1+wasi-0.2.0", -] - [[package]] name = "spin-macro" -version = "5.2.0" +version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f959f16928e3c023468e41da9ebb77442e2ce22315e8dab11508fe76b3567ee1" +checksum = "11e483b94d5bcfac493caf0427fa875063e3e8604d0466a4ab491ec200a42857" dependencies = [ - "anyhow", - "bytes", "proc-macro2", "quote", "syn 1.0.109", @@ -2051,24 +2453,19 @@ dependencies = [ [[package]] name = "spin-sdk" -version = "5.2.0" +version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8951c7c4ab7f87f332d497789eeed9631c8116988b628b4851eb2fa999ead019" +checksum = "4fd2abac3eb2ee249c2241ab87f7b1287f36172c8cc1ea815c19c85e41ede44d" dependencies = [ "anyhow", - "async-trait", "bytes", - "chrono", - "form_urlencoded", "futures", "http", - "once_cell", - "routefinder", - "spin-executor", + "http-body", + "http-body-util", "spin-macro", "thiserror 2.0.18", - "wasi 0.13.1+wasi-0.2.0", - "wit-bindgen", + "wasip3", ] [[package]] @@ -2077,12 +2474,6 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" -[[package]] -name = "static_assertions" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" - [[package]] name = "strsim" version = "0.11.1" @@ -2158,6 +2549,19 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.3.4", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + [[package]] name = "thiserror" version = "1.0.69" @@ -2302,10 +2706,19 @@ dependencies = [ "indexmap", "serde_core", "serde_spanned", - "toml_datetime", + "toml_datetime 1.1.1+spec-1.1.0", "toml_parser", "toml_writer", - "winnow", + "winnow 1.0.0", +] + +[[package]] +name = "toml_datetime" +version = "0.7.5+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347" +dependencies = [ + "serde_core", ] [[package]] @@ -2317,13 +2730,26 @@ dependencies = [ "serde_core", ] +[[package]] +name = "toml_edit" +version = "0.23.10+spec-1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84c8b9f757e028cee9fa244aea147aab2a9ec09d5325a9b01e0a49730c2b5269" +dependencies = [ + "indexmap", + "toml_datetime 0.7.5+spec-1.1.0", + "toml_parser", + "toml_writer", + "winnow 0.7.15", +] + [[package]] name = "toml_parser" version = "1.1.2+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" dependencies = [ - "winnow", + "winnow 1.0.0", ] [[package]] @@ -2354,7 +2780,7 @@ version = "0.6.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.11.1", "bytes", "futures-util", "http", @@ -2422,6 +2848,12 @@ version = "1.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" +[[package]] +name = "ucd-trie" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" + [[package]] name = "unicode-ident" version = "1.0.24" @@ -2458,6 +2890,12 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + [[package]] name = "validator" version = "0.20.0" @@ -2488,6 +2926,12 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + [[package]] name = "version_check" version = "0.9.5" @@ -2520,21 +2964,25 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" [[package]] -name = "wasi" -version = "0.13.1+wasi-0.2.0" +name = "wasip2" +version = "1.0.2+wasi-0.2.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f43d1c36145feb89a3e61aa0ba3e582d976a8ab77f1474aa0adb80800fe0cf8" +checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" dependencies = [ - "wit-bindgen-rt", + "wit-bindgen 0.51.0", ] [[package]] -name = "wasip2" -version = "1.0.2+wasi-0.2.9" +name = "wasip3" +version = "0.6.0+wasi-0.3.0-rc-2026-03-15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" +checksum = "ed83456dd6a0b8581998c0365e4651fa2997e5093b49243b7f35391afaa7a3d9" dependencies = [ - "wit-bindgen", + "bytes", + "http", + "http-body", + "thiserror 2.0.18", + "wit-bindgen 0.57.1", ] [[package]] @@ -2594,9 +3042,9 @@ dependencies = [ [[package]] name = "wasm-encoder" -version = "0.244.0" +version = "0.247.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +checksum = "30b6733b8b91d010a6ac5b0fb237dc46a19650bc4c67db66857e2e787d437204" dependencies = [ "leb128fmt", "wasmparser", @@ -2604,9 +3052,9 @@ dependencies = [ [[package]] name = "wasm-metadata" -version = "0.244.0" +version = "0.247.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +checksum = "665fe59e56cc9b419ca6fcca56673e3421d1a5011e3b65caf6b726fd9e041d10" dependencies = [ "anyhow", "indexmap", @@ -2629,12 +3077,12 @@ dependencies = [ [[package]] name = "wasmparser" -version = "0.244.0" +version = "0.247.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +checksum = "8e6fb4c2bee46c5ea4d40f8cdb5c131725cd976718ec56f1c8e82fbde5fa2a80" dependencies = [ - "bitflags 2.11.0", - "hashbrown 0.15.5", + "bitflags 2.11.1", + "hashbrown 0.17.1", "indexmap", "semver", ] @@ -3033,6 +3481,15 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" +dependencies = [ + "memchr", +] + [[package]] name = "winnow" version = "1.0.0" @@ -3045,35 +3502,36 @@ version = "0.51.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" dependencies = [ - "bitflags 2.11.0", - "wit-bindgen-rust-macro", + "bitflags 2.11.1", ] [[package]] -name = "wit-bindgen-core" -version = "0.51.0" +name = "wit-bindgen" +version = "0.57.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" dependencies = [ - "anyhow", - "heck", - "wit-parser", + "bitflags 2.11.1", + "futures", + "wit-bindgen-rust-macro", ] [[package]] -name = "wit-bindgen-rt" -version = "0.24.0" +name = "wit-bindgen-core" +version = "0.57.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b0780cf7046630ed70f689a098cd8d56c5c3b22f2a7379bbdb088879963ff96" +checksum = "02dee27a2dc20d1008016c742ec9fc6ea498492994ba3750be7454cbc97ff04c" dependencies = [ - "bitflags 2.11.0", + "anyhow", + "heck", + "wit-parser", ] [[package]] name = "wit-bindgen-rust" -version = "0.51.0" +version = "0.57.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +checksum = "b5007dae772945b7a5003d69d90a3a4a78929d41f19d004e980c4259a6af4484" dependencies = [ "anyhow", "heck", @@ -3087,9 +3545,9 @@ dependencies = [ [[package]] name = "wit-bindgen-rust-macro" -version = "0.51.0" +version = "0.57.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +checksum = "af9237d678e3513ad24e96fe98beacdc0db6405284ba2a2400418cf0d42caa89" dependencies = [ "anyhow", "prettyplease", @@ -3102,12 +3560,12 @@ dependencies = [ [[package]] name = "wit-component" -version = "0.244.0" +version = "0.247.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +checksum = "9d567162a6b9843080e5e0053f696623ff694bae8ae017c9ec536d1873bbe3d8" dependencies = [ "anyhow", - "bitflags 2.11.0", + "bitflags 2.11.1", "indexmap", "log", "serde", @@ -3121,11 +3579,12 @@ dependencies = [ [[package]] name = "wit-parser" -version = "0.244.0" +version = "0.247.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +checksum = "8ffe4064318cdf3c08cb99343b44c039fcefe61ccdf58aa9975285f13d74d1fc" dependencies = [ "anyhow", + "hashbrown 0.17.1", "id-arena", "indexmap", "log", diff --git a/examples/app-demo/Cargo.toml b/examples/app-demo/Cargo.toml index f702329e..c608b82e 100644 --- a/examples/app-demo/Cargo.toml +++ b/examples/app-demo/Cargo.toml @@ -1,6 +1,7 @@ [workspace] members = [ "crates/app-demo-core", + "crates/app-demo-cli", "crates/app-demo-adapter-axum", "crates/app-demo-adapter-cloudflare", "crates/app-demo-adapter-fastly", @@ -16,20 +17,28 @@ anyhow = "1" async-trait = "0.1" axum = "0.8" bytes = "1" +clap = { version = "4", features = ["derive"] } +edgezero-adapter = { path = "../../crates/edgezero-adapter" } edgezero-adapter-axum = { path = "../../crates/edgezero-adapter-axum" } edgezero-adapter-cloudflare = { path = "../../crates/edgezero-adapter-cloudflare" } edgezero-adapter-fastly = { path = "../../crates/edgezero-adapter-fastly" } edgezero-adapter-spin = { path = "../../crates/edgezero-adapter-spin" } +edgezero-cli = { path = "../../crates/edgezero-cli" } edgezero-core = { path = "../../crates/edgezero-core" } -spin-sdk = { version = "5.2", default-features = false } +spin-sdk = { version = "6", default-features = false } fastly = "0.12" futures = { version = "0.3", default-features = false, features = ["std", "executor"] } log = "0.4" once_cell = "1" +# `bundled` so the demo workspace also vendors SQLite source. Used by +# the `app-demo-cli` integration test for `config push --adapter spin` +# (which writes through our SQLite-direct backend writer). +rusqlite = { version = "0.32", default-features = false, features = ["bundled"] } serde = { version = "1", features = ["derive"] } serde_json = "1" validator = { version = "0.20", features = ["derive"] } simple_logger = "4" +tempfile = "3" tokio = { version = "1", features = ["macros", "rt-multi-thread"] } tracing = "0.1" worker = { version = "0.8", default-features = false, features = ["http"] } @@ -38,3 +47,33 @@ worker = { version = "0.8", default-features = false, features = ["http"] } debug = 1 codegen-units = 1 lto = "fat" + +[workspace.lints.clippy] +# Same strict gate as the main workspace. Allow-list mirrors the parent +# `Cargo.toml` only where the demo legitimately needs the same exemption — +# new entries should be added lazily when a real failure surfaces. +pedantic = { level = "warn", priority = -1 } +restriction = { level = "deny", priority = -1 } + +# Meta — required when enabling `restriction` as a group. +blanket_clippy_restriction_lints = "allow" + +# Documentation — demo is illustrative; private items don't need full docs. +missing_docs_in_private_items = "allow" + +# Style / formatting — match the main workspace's idiomatic-Rust stance. +implicit_return = "allow" +question_mark_used = "allow" +single_call_fn = "allow" +separated_literal_suffix = "allow" + +# API design — `exhaustive_structs` fires once on the unit struct generated +# by the `app!` macro. +exhaustive_structs = "allow" + +# Imports / paths — demo binaries are std applications, not no_std libraries. +std_instead_of_alloc = "allow" +std_instead_of_core = "allow" + +[workspace.lints.rust] +unsafe_code = "deny" diff --git a/examples/app-demo/app-demo.toml b/examples/app-demo/app-demo.toml new file mode 100644 index 00000000..695458fe --- /dev/null +++ b/examples/app-demo/app-demo.toml @@ -0,0 +1,34 @@ +# `app-demo.toml` — typed application config for the `app-demo` example. +# +# The file's top-level table maps 1:1 to the `AppDemoConfig` struct in +# `crates/app-demo-core/src/config.rs`. There is no `[config]` +# wrapper. +# +# Env-var overlay: every key here can be overridden at runtime by +# `APP_DEMO__
__…__` (the prefix is the project name +# uppercased with `-`→`_`; nested sections are joined by `__`) as +# long as the key already exists below. Example: +# `APP_DEMO__SERVICE__TIMEOUT_MS=2500` overrides the +# `[service] timeout_ms` field below. + +# `api_token` is the *key* inside the resolved default secret store +# (see `[stores.secrets]` in `edgezero.toml`). The handler resolves it +# via `ctx.secret_store_default()?.require_str(&cfg.api_token)`. +api_token = "demo_api_token" +greeting = "hello from app-demo" +# `vault` is a `#[secret(store_ref)]` value — the logical id of a +# secret store declared in `[stores.secrets].ids`. The app-demo +# manifest declares a single id, `"default"`. +vault = "default" + +# Nested so `config push` writes the dotted key +# `feature.new_checkout` — matching the handler that reads +# `feature.new_checkout` from the config store and the per-adapter +# seeds in `fastly.toml`/`spin.toml`. Spin's config store is now +# KV-backed and stores dotted keys verbatim, so no translation is +# applied: the runtime reads back exactly `feature.new_checkout`. +[feature] +new_checkout = false + +[service] +timeout_ms = 1500 diff --git a/examples/app-demo/clippy.toml b/examples/app-demo/clippy.toml new file mode 100644 index 00000000..99dd0fdd --- /dev/null +++ b/examples/app-demo/clippy.toml @@ -0,0 +1,9 @@ +# Clippy configuration. See https://doc.rust-lang.org/clippy/lint_configuration.html +# +# Test code uses `.unwrap()`, `.expect()`, `panic!`, `assert!`, indexing, and +# other "if-this-fails-the-test-fails" idioms by convention. Mirror the main +# workspace and exempt tests from the corresponding restriction lints. +allow-expect-in-tests = true +allow-indexing-slicing-in-tests = true +allow-panic-in-tests = true +allow-unwrap-in-tests = true diff --git a/examples/app-demo/crates/app-demo-adapter-axum/Cargo.toml b/examples/app-demo/crates/app-demo-adapter-axum/Cargo.toml index 56454998..3f0621d0 100644 --- a/examples/app-demo/crates/app-demo-adapter-axum/Cargo.toml +++ b/examples/app-demo/crates/app-demo-adapter-axum/Cargo.toml @@ -5,6 +5,9 @@ edition = "2021" license.workspace = true publish = false +[lints] +workspace = true + [[bin]] name = "app-demo-adapter-axum" path = "src/main.rs" diff --git a/examples/app-demo/crates/app-demo-adapter-axum/src/main.rs b/examples/app-demo/crates/app-demo-adapter-axum/src/main.rs index da4b61b5..93d6ee64 100644 --- a/examples/app-demo/crates/app-demo-adapter-axum/src/main.rs +++ b/examples/app-demo/crates/app-demo-adapter-axum/src/main.rs @@ -1,9 +1,6 @@ use app_demo_core::App; +use edgezero_adapter_axum::dev_server::run_app; -fn main() { - if let Err(err) = edgezero_adapter_axum::run_app::(include_str!("../../../edgezero.toml")) - { - eprintln!("axum adapter failed: {err}"); - std::process::exit(1); - } +fn main() -> anyhow::Result<()> { + run_app::() } diff --git a/examples/app-demo/crates/app-demo-adapter-cloudflare/Cargo.toml b/examples/app-demo/crates/app-demo-adapter-cloudflare/Cargo.toml index fd040e1f..9bba19de 100644 --- a/examples/app-demo/crates/app-demo-adapter-cloudflare/Cargo.toml +++ b/examples/app-demo/crates/app-demo-adapter-cloudflare/Cargo.toml @@ -5,6 +5,9 @@ edition = "2021" license.workspace = true publish = false +[lints] +workspace = true + [[bin]] name = "app-demo-adapter-cloudflare" path = "src/main.rs" diff --git a/examples/app-demo/crates/app-demo-adapter-cloudflare/src/lib.rs b/examples/app-demo/crates/app-demo-adapter-cloudflare/src/lib.rs index 43d2c58d..12ae0f3e 100644 --- a/examples/app-demo/crates/app-demo-adapter-cloudflare/src/lib.rs +++ b/examples/app-demo/crates/app-demo-adapter-cloudflare/src/lib.rs @@ -8,11 +8,5 @@ use worker::*; #[cfg(target_arch = "wasm32")] #[event(fetch)] pub async fn main(req: Request, env: Env, ctx: Context) -> Result { - edgezero_adapter_cloudflare::run_app::( - include_str!("../../../edgezero.toml"), - req, - env, - ctx, - ) - .await + edgezero_adapter_cloudflare::run_app::(req, env, ctx).await } diff --git a/examples/app-demo/crates/app-demo-adapter-cloudflare/src/main.rs b/examples/app-demo/crates/app-demo-adapter-cloudflare/src/main.rs index 910a2cbf..96d0dbf9 100644 --- a/examples/app-demo/crates/app-demo-adapter-cloudflare/src/main.rs +++ b/examples/app-demo/crates/app-demo-adapter-cloudflare/src/main.rs @@ -1,3 +1,7 @@ +#[expect( + clippy::print_stderr, + reason = "host stub; the real binary only runs on wasm32-unknown-unknown" +)] fn main() { eprintln!( "Run `wrangler dev` or target wasm32-unknown-unknown to execute app-demo-adapter-cloudflare." diff --git a/examples/app-demo/crates/app-demo-adapter-cloudflare/wrangler.toml b/examples/app-demo/crates/app-demo-adapter-cloudflare/wrangler.toml index 9877065f..6840f654 100644 --- a/examples/app-demo/crates/app-demo-adapter-cloudflare/wrangler.toml +++ b/examples/app-demo/crates/app-demo-adapter-cloudflare/wrangler.toml @@ -5,15 +5,27 @@ compatibility_date = "2023-05-01" [build] command = "worker-build --release" -# Config store as a single JSON string var, keyed by the binding name from edgezero.toml. -# CloudflareConfigStore parses this at startup into a HashMap, enabling arbitrary key names. -[vars] -app_config = '{"greeting":"hello from config store","feature.new_checkout":"false","service.timeout_ms":"1500"}' +# KV namespace bindings, one per logical store id from `edgezero.toml`. +# `wrangler dev` auto-provisions local KV namespaces for each binding; +# `id` values are placeholders for production — replace with the output of +# `wrangler kv namespace create ` per environment. +# +# Each binding name matches the logical id by default; override with +# `EDGEZERO__STORES______NAME=` at runtime if you need +# to remap a namespace per environment. -# KV namespace binding — used by KV demo handlers. -# For local dev (`wrangler dev`), this creates a local KV store automatically. -# For production, replace `id` with the output of: -# wrangler kv:namespace create EDGEZERO_KV +# `[stores.kv].ids = ["sessions", "cache"]` [[kv_namespaces]] -binding = "EDGEZERO_KV" +binding = "sessions" +id = "local-dev-placeholder" + +[[kv_namespaces]] +binding = "cache" +id = "local-dev-placeholder" + +# `[stores.config].ids = ["app_config"]` — config is KV-backed on Cloudflare +#. Seed values via `wrangler kv key put` against this namespace; +# the pre-rewrite `[vars] app_config = '{ … }'` form is gone. +[[kv_namespaces]] +binding = "app_config" id = "local-dev-placeholder" diff --git a/examples/app-demo/crates/app-demo-adapter-fastly/Cargo.toml b/examples/app-demo/crates/app-demo-adapter-fastly/Cargo.toml index 4f365ecf..e4a259a7 100644 --- a/examples/app-demo/crates/app-demo-adapter-fastly/Cargo.toml +++ b/examples/app-demo/crates/app-demo-adapter-fastly/Cargo.toml @@ -5,6 +5,9 @@ edition = "2021" license.workspace = true publish = false +[lints] +workspace = true + [[bin]] name = "app-demo-adapter-fastly" path = "src/main.rs" diff --git a/examples/app-demo/crates/app-demo-adapter-fastly/fastly.toml b/examples/app-demo/crates/app-demo-adapter-fastly/fastly.toml index 330a20c6..bbb2beff 100644 --- a/examples/app-demo/crates/app-demo-adapter-fastly/fastly.toml +++ b/examples/app-demo/crates/app-demo-adapter-fastly/fastly.toml @@ -8,7 +8,9 @@ service_id = "" [local_server] # Config store entries for local Viceroy testing. -# Mirrors [stores.config.defaults] in edgezero.toml so smoke tests pass on all adapters. +# The platform name matches the logical id from edgezero.toml +# (`[stores.config].ids = ["app_config"]`); override at runtime with +# `EDGEZERO__STORES__CONFIG__APP_CONFIG__NAME=` if you need to remap. [local_server.config_stores.app_config] format = "inline-toml" @@ -17,28 +19,39 @@ greeting = "hello from config store" "feature.new_checkout" = "false" "service.timeout_ms" = "1500" +# KV stores, one per logical id from `[stores.kv].ids = ["sessions", "cache"]`. +# The platform store names match the logical ids by default; override per-id +# via `EDGEZERO__STORES__KV____NAME` (e.g. `…__SESSIONS__NAME=prod-store`). [local_server.kv_stores] -[[local_server.kv_stores.EDGEZERO_KV]] -# We use a dummy key to initialize the store. -# 'data' provides inline content (empty string here). -# 'path' would load content from a file (e.g. path="./README.md"), but we don't need that. +[[local_server.kv_stores.sessions]] +# Dummy `__init__` key keeps the store materialised under Viceroy without seeding data. key = "__init__" data = "" +[[local_server.kv_stores.cache]] +key = "__init__" +data = "" + +# Secret store. The platform name matches the logical id from edgezero.toml +# (`[stores.secrets].ids = ["default"]`) so `BoundSecretStore` resolves to +# this store with no env override. To remap, set +# `EDGEZERO__STORES__SECRETS__DEFAULT__NAME=`. [local_server.secret_stores] -[[local_server.secret_stores.EDGEZERO_SECRETS]] +[[local_server.secret_stores.default]] key = "SMOKE_SECRET" env = "SMOKE_SECRET" [setup] [setup.kv_stores] -[setup.kv_stores.EDGEZERO_KV] -description = "KV store for EdgeZero demo" +[setup.kv_stores.sessions] +description = "KV store for EdgeZero demo (sessions)" +[setup.kv_stores.cache] +description = "KV store for EdgeZero demo (cache)" [setup.secret_stores] -[setup.secret_stores.EDGEZERO_SECRETS] +[setup.secret_stores.default] description = "Secret store for EdgeZero demo" diff --git a/examples/app-demo/crates/app-demo-adapter-fastly/rust-toolchain.toml b/examples/app-demo/crates/app-demo-adapter-fastly/rust-toolchain.toml index e5ca0d66..cb5b89d3 100644 --- a/examples/app-demo/crates/app-demo-adapter-fastly/rust-toolchain.toml +++ b/examples/app-demo/crates/app-demo-adapter-fastly/rust-toolchain.toml @@ -1,3 +1,3 @@ [toolchain] -channel = "1.91.1" +channel = "1.95.0" targets = ["wasm32-wasip1"] diff --git a/examples/app-demo/crates/app-demo-adapter-fastly/src/main.rs b/examples/app-demo/crates/app-demo-adapter-fastly/src/main.rs index f81b984d..b8ba7515 100644 --- a/examples/app-demo/crates/app-demo-adapter-fastly/src/main.rs +++ b/examples/app-demo/crates/app-demo-adapter-fastly/src/main.rs @@ -1,4 +1,7 @@ -#![cfg_attr(not(target_arch = "wasm32"), allow(dead_code))] +#![cfg_attr( + not(target_arch = "wasm32"), + allow(dead_code, reason = "Fastly entrypoint is wasm32-only") +)] #[cfg(target_arch = "wasm32")] use app_demo_core::App; @@ -7,10 +10,14 @@ use fastly::{Error, Request, Response}; #[cfg(target_arch = "wasm32")] #[fastly::main] pub fn main(req: Request) -> Result { - edgezero_adapter_fastly::run_app::(include_str!("../../../edgezero.toml"), req) + edgezero_adapter_fastly::run_app::(req) } #[cfg(not(target_arch = "wasm32"))] +#[expect( + clippy::print_stderr, + reason = "host stub; the real binary only runs on wasm32-wasip1" +)] fn main() { eprintln!("app-demo-adapter-fastly: target wasm32-wasip1 to run on Fastly."); } diff --git a/examples/app-demo/crates/app-demo-adapter-spin/Cargo.toml b/examples/app-demo/crates/app-demo-adapter-spin/Cargo.toml index b18a9242..c5df0d0d 100644 --- a/examples/app-demo/crates/app-demo-adapter-spin/Cargo.toml +++ b/examples/app-demo/crates/app-demo-adapter-spin/Cargo.toml @@ -5,6 +5,9 @@ edition = "2021" license.workspace = true publish = false +[lints] +workspace = true + [lib] crate-type = ["cdylib"] path = "src/lib.rs" diff --git a/examples/app-demo/crates/app-demo-adapter-spin/runtime-config.toml b/examples/app-demo/crates/app-demo-adapter-spin/runtime-config.toml new file mode 100644 index 00000000..bf3431b7 --- /dev/null +++ b/examples/app-demo/crates/app-demo-adapter-spin/runtime-config.toml @@ -0,0 +1,21 @@ +# Spin runtime configuration for app-demo — declares the KV +# labels the component is allowed to open at runtime. Each +# label uses the default SQLite-backed Spin KV backend, which +# persists to `.spin/sqlite_key_value.db` next to this file. +# +# Custom labels (anything other than `default`) require a +# declaration here; without one, `spin up` errors with +# "unknown key_value_stores label ". `app_config` is the +# KV-backed config store; `sessions` and `cache` are the KV +# labels app-demo declares in `edgezero.toml`. Add a stanza +# below for every additional `[stores.kv]` / `[stores.config]` +# id you wire up. + +[key_value_store.app_config] +type = "spin" + +[key_value_store.sessions] +type = "spin" + +[key_value_store.cache] +type = "spin" diff --git a/examples/app-demo/crates/app-demo-adapter-spin/spin.toml b/examples/app-demo/crates/app-demo-adapter-spin/spin.toml index e0bf005e..54b9a1b7 100644 --- a/examples/app-demo/crates/app-demo-adapter-spin/spin.toml +++ b/examples/app-demo/crates/app-demo-adapter-spin/spin.toml @@ -5,12 +5,22 @@ name = "app-demo-adapter-spin" version = "0.1.0" # Application-level variable declarations. -# Spin variable names are lowercase; set overrides at runtime via -# SPIN_VARIABLE_=value or `spin up --env KEY=value`. +# +# As of the Spin KV-config migration, app-config keys live in the +# KV-backed `app_config` store (see `[component.app-demo].key_value_stores` +# below) — `[variables]` here is now SECRETS-ONLY. +# +# `api_token` is the `#[secret]` field from `AppDemoConfig`; its value +# resolves through Spin's flat variable namespace, so it must be declared +# with `secret = true` for the wasm component to read it. `vault` is +# `#[secret(store_ref)]` — the value is a runtime store id, not material +# to keep secret, but bound the same way for consistency with the +# `AppDemoConfig` surface. `smoke_secret` keeps an empty default so the +# server starts without a value set; pass +# `SPIN_VARIABLE_SMOKE_SECRET=` when running smoke_test_secrets.sh. [variables] -greeting = { default = "hello from config store" } -# smoke_secret has an empty default so the server starts without a value set. -# Pass SPIN_VARIABLE_SMOKE_SECRET= when running smoke_test_secrets.sh. +api_token = { required = true, secret = true } +vault = { default = "default" } smoke_secret = { default = "" } # Component name is shortened for brevity; scaffolded projects use the full @@ -20,15 +30,20 @@ route = "/..." component = "app-demo" [component.app-demo] -source = "../../target/wasm32-wasip1/release/app_demo_adapter_spin.wasm" +source = "../../target/wasm32-wasip2/release/app_demo_adapter_spin.wasm" allowed_outbound_hosts = ["https://*:*"] -# KV store label must match [stores.kv.adapters.spin] in edgezero.toml. -key_value_stores = ["default"] +# Each label is the platform name of a `[stores.kv]` / `[stores.config]` +# id from `edgezero.toml`. `app_config` is the KV-backed config store the +# adapter opens on demand; `sessions` and `cache` are the KV labels. +# Override per-id via `EDGEZERO__STORES______NAME=