diff --git a/.github/workflows/integration-tests.yml b/.github/workflows/integration-tests.yml index da467583c..05cc6ac66 100644 --- a/.github/workflows/integration-tests.yml +++ b/.github/workflows/integration-tests.yml @@ -110,6 +110,17 @@ jobs: - name: Load integration test Docker images run: docker load --input "$DOCKER_ARTIFACT_PATH" + - name: Seed app_config config store for Viceroy + run: >- + cargo run --quiet + --manifest-path crates/integration-tests/Cargo.toml + --target x86_64-unknown-linux-gnu + --bin seed-viceroy-config -- + --template crates/integration-tests/fixtures/configs/viceroy-template.toml + --fixture crates/integration-tests/fixtures/configs/trusted-server-integration.toml + --port ${{ env.ORIGIN_PORT }} + --out ${{ env.ARTIFACTS_DIR }}/viceroy-seeded.toml + - name: Set up Node.js uses: actions/setup-node@v4 with: @@ -128,7 +139,7 @@ jobs: env: WASM_BINARY_PATH: ${{ env.WASM_ARTIFACT_PATH }} INTEGRATION_ORIGIN_PORT: ${{ env.ORIGIN_PORT }} - VICEROY_CONFIG_PATH: ${{ github.workspace }}/crates/integration-tests/fixtures/configs/viceroy-template.toml + VICEROY_CONFIG_PATH: ${{ env.ARTIFACTS_DIR }}/viceroy-seeded.toml TEST_FRAMEWORK: nextjs PLAYWRIGHT_HTML_REPORT: playwright-report-nextjs run: npx playwright test @@ -147,7 +158,7 @@ jobs: env: WASM_BINARY_PATH: ${{ env.WASM_ARTIFACT_PATH }} INTEGRATION_ORIGIN_PORT: ${{ env.ORIGIN_PORT }} - VICEROY_CONFIG_PATH: ${{ github.workspace }}/crates/integration-tests/fixtures/configs/viceroy-template.toml + VICEROY_CONFIG_PATH: ${{ env.ARTIFACTS_DIR }}/viceroy-seeded.toml TEST_FRAMEWORK: wordpress PLAYWRIGHT_HTML_REPORT: playwright-report-wordpress run: npx playwright test diff --git a/crates/integration-tests/Cargo.lock b/crates/integration-tests/Cargo.lock index 32d8f1dbb..107502e3b 100644 --- a/crates/integration-tests/Cargo.lock +++ b/crates/integration-tests/Cargo.lock @@ -126,12 +126,6 @@ version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" -[[package]] -name = "arraydeque" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7d902e3d592a523def97af8f317b08ce16b7ab854c1985a0c671e6f15cebc236" - [[package]] name = "astral-tokio-tar" version = "0.5.6" @@ -277,9 +271,6 @@ name = "bitflags" version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" -dependencies = [ - "serde_core", -] [[package]] name = "bitstream-io" @@ -528,61 +519,12 @@ version = "0.4.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cc14f565cf027a105f7a44ccf9e5b424348421a1d8952a8fc9d499d313107789" -[[package]] -name = "config" -version = "0.15.22" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e68cfe19cd7d23ffde002c24ffa5cda73931913ef394d5eaaa32037dc940c0c" -dependencies = [ - "async-trait", - "convert_case 0.6.0", - "json5", - "pathdiff", - "ron", - "rust-ini", - "serde-untagged", - "serde_core", - "serde_json", - "toml", - "winnow", - "yaml-rust2", -] - [[package]] name = "const-oid" version = "0.9.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" -[[package]] -name = "const-random" -version = "0.1.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "87e00182fe74b066627d63b85fd550ac2998d4b0bd86bfed477a0ae4c7c71359" -dependencies = [ - "const-random-macro", -] - -[[package]] -name = "const-random-macro" -version = "0.1.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f9d839f2a20b0aee515dc581a6172f2321f96cab76c1a38a4c584a194955390e" -dependencies = [ - "getrandom 0.2.17", - "once_cell", - "tiny-keccak", -] - -[[package]] -name = "convert_case" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec182b0ca2f35d8fc196cf3404988fd8b8c739a4d270ff118a398feb0cbec1ca" -dependencies = [ - "unicode-segmentation", -] - [[package]] name = "convert_case" version = "0.10.0" @@ -665,12 +607,6 @@ dependencies = [ "cfg-if", ] -[[package]] -name = "crunchy" -version = "0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" - [[package]] name = "crypto-bigint" version = "0.5.5" @@ -872,7 +808,7 @@ version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" dependencies = [ - "convert_case 0.10.0", + "convert_case", "proc-macro2", "quote", "rustc_version", @@ -912,15 +848,6 @@ dependencies = [ "syn 2.0.117", ] -[[package]] -name = "dlv-list" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "442039f5147480ba31067cb00ada1adae6892028e40e45fc5de7b7df6dcc1b5f" -dependencies = [ - "const-random", -] - [[package]] name = "docker_credential" version = "1.3.3" @@ -996,7 +923,7 @@ dependencies = [ [[package]] name = "edgezero-core" version = "0.1.0" -source = "git+https://github.com/stackpop/edgezero?rev=170b74b#170b74bd2c9933b7d561f7ccdb67c53b239e9527" +source = "git+https://github.com/stackpop/edgezero?rev=2eeccc9748daba92b9adf6afe4df105e79269ae9#2eeccc9748daba92b9adf6afe4df105e79269ae9" dependencies = [ "anyhow", "async-compression", @@ -1024,7 +951,7 @@ dependencies = [ [[package]] name = "edgezero-macros" version = "0.1.0" -source = "git+https://github.com/stackpop/edgezero?rev=170b74b#170b74bd2c9933b7d561f7ccdb67c53b239e9527" +source = "git+https://github.com/stackpop/edgezero?rev=2eeccc9748daba92b9adf6afe4df105e79269ae9#2eeccc9748daba92b9adf6afe4df105e79269ae9" dependencies = [ "log", "proc-macro2", @@ -1113,17 +1040,6 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" -[[package]] -name = "erased-serde" -version = "0.4.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2add8a07dd6a8d93ff627029c51de145e12686fbc36ecb298ac22e74cf02dec" -dependencies = [ - "serde", - "serde_core", - "typeid", -] - [[package]] name = "errno" version = "0.3.14" @@ -1505,12 +1421,6 @@ version = "0.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" -[[package]] -name = "hashbrown" -version = "0.14.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" - [[package]] name = "hashbrown" version = "0.15.5" @@ -1531,15 +1441,6 @@ dependencies = [ "foldhash 0.2.0", ] -[[package]] -name = "hashlink" -version = "0.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1" -dependencies = [ - "hashbrown 0.15.5", -] - [[package]] name = "heck" version = "0.5.0" @@ -2075,17 +1976,6 @@ dependencies = [ "wasm-bindgen", ] -[[package]] -name = "json5" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96b0db21af676c1ce64250b5f40f3ce2cf27e4e47cb91ed91eb6fe9350b430c1" -dependencies = [ - "pest", - "pest_derive", - "serde", -] - [[package]] name = "lazy_static" version = "1.5.0" @@ -2441,16 +2331,6 @@ dependencies = [ "vcpkg", ] -[[package]] -name = "ordered-multimap" -version = "0.7.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "49203cdcae0030493bad186b28da2fa25645fa276a51b6fec8010d281e02ef79" -dependencies = [ - "dlv-list", - "hashbrown 0.14.5", -] - [[package]] name = "p256" version = "0.13.2" @@ -2519,61 +2399,12 @@ dependencies = [ "syn 2.0.117", ] -[[package]] -name = "pathdiff" -version = "0.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3" - [[package]] name = "percent-encoding" 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 = "phf" version = "0.11.3" @@ -3082,20 +2913,6 @@ dependencies = [ "windows-sys 0.52.0", ] -[[package]] -name = "ron" -version = "0.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4147b952f3f819eca0e99527022f7d6a8d05f111aeb0a62960c74eb283bec8fc" -dependencies = [ - "bitflags 2.11.1", - "once_cell", - "serde", - "serde_derive", - "typeid", - "unicode-ident", -] - [[package]] name = "rsa" version = "0.9.10" @@ -3116,16 +2933,6 @@ dependencies = [ "zeroize", ] -[[package]] -name = "rust-ini" -version = "0.21.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "796e8d2b6696392a43bea58116b667fb4c29727dc5abd27d6acf338bb4f688c7" -dependencies = [ - "cfg-if", - "ordered-multimap", -] - [[package]] name = "rustc-hash" version = "2.1.2" @@ -3376,18 +3183,6 @@ dependencies = [ "serde_derive", ] -[[package]] -name = "serde-untagged" -version = "0.1.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f9faf48a4a2d2693be24c6289dbe26552776eb7737074e6722891fadbe6c5058" -dependencies = [ - "erased-serde", - "serde", - "serde_core", - "typeid", -] - [[package]] name = "serde_core" version = "1.0.228" @@ -3849,15 +3644,6 @@ dependencies = [ "time-core", ] -[[package]] -name = "tiny-keccak" -version = "2.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2c9d3793400a45f954c52e73d068316d76b6f4e36977e3fcebb13a2721e80237" -dependencies = [ - "crunchy", -] - [[package]] name = "tinystr" version = "0.8.3" @@ -4122,7 +3908,6 @@ dependencies = [ "bytes", "chacha20poly1305", "chrono", - "config", "cookie", "derive_more 2.1.1", "ed25519-dalek", @@ -4180,24 +3965,12 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" -[[package]] -name = "typeid" -version = "1.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c" - [[package]] name = "typenum" version = "1.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de" -[[package]] -name = "ucd-trie" -version = "0.1.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" - [[package]] name = "ulid" version = "1.2.1" @@ -4729,9 +4502,6 @@ name = "winnow" version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0592e1c9d151f854e6fd382574c3a0855250e1d9b2f99d9281c6e6391af352f1" -dependencies = [ - "memchr", -] [[package]] name = "wit-bindgen" @@ -4855,17 +4625,6 @@ dependencies = [ "rustix", ] -[[package]] -name = "yaml-rust2" -version = "0.10.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2462ea039c445496d8793d052e13787f2b90e750b833afee748e601c17621ed9" -dependencies = [ - "arraydeque", - "encoding_rs", - "hashlink", -] - [[package]] name = "yoke" version = "0.8.2" diff --git a/crates/integration-tests/Cargo.toml b/crates/integration-tests/Cargo.toml index fe393d3b9..01fa25fc4 100644 --- a/crates/integration-tests/Cargo.toml +++ b/crates/integration-tests/Cargo.toml @@ -9,13 +9,17 @@ name = "integration" path = "tests/integration.rs" harness = true -[dev-dependencies] +# Library + `seed-viceroy-config` binary share the `app_config` seeding logic +# with the test harness, so both need these at build time (not just tests). +[dependencies] trusted-server-core = { path = "../trusted-server-core" } +serde_json = "1.0.149" + +[dev-dependencies] testcontainers = { version = "0.25", features = ["blocking"] } reqwest = { version = "0.12", features = ["blocking", "cookies", "json"] } scraper = "0.21" log = "0.4.29" -serde_json = "1.0.149" error-stack = "0.6" derive_more = { version = "2.0", features = ["display"] } env_logger = "0.11" diff --git a/crates/integration-tests/fixtures/configs/trusted-server-integration.toml b/crates/integration-tests/fixtures/configs/trusted-server-integration.toml new file mode 100644 index 000000000..73c50cd86 --- /dev/null +++ b/crates/integration-tests/fixtures/configs/trusted-server-integration.toml @@ -0,0 +1,134 @@ +# Trusted Server application config for integration tests. +# +# Mirrors `trusted-server.example.toml` with the integration-environment +# overrides that the WASM build and CI previously injected as +# `TRUSTED_SERVER__*` env vars (origin URL, proxy secret, EC passphrase, EC +# partners, certificate check). The harness loads this file, builds the +# `app_config` config-store payload via `build_config_payload`, and seeds it +# into Viceroy so the runtime can reconstruct Settings at request time. +# +# `__ORIGIN_PORT__` is substituted by the harness with the fixed origin port +# that Docker test containers are mapped to. + +[[handlers]] +path = "^/_ts/admin" +username = "admin" +password = "integration-test-admin-password-32b" + +[publisher] +domain = "example.com" +cookie_domain = ".example.com" +origin_url = "http://127.0.0.1:__ORIGIN_PORT__" +proxy_secret = "integration-test-proxy-secret" + +[ec] +passphrase = "integration-test-ec-secret-padded-32" +ec_store = "ec_identity_store" +pull_sync_concurrency = 3 + +[[ec.partners]] +name = "Integration Test Partner" +source_domain = "inttest.example.com" +bidstream_enabled = true +api_token = "integration-test-token-alpha-32-bytes-ok" + +[[ec.partners]] +name = "Integration Test Partner 2" +source_domain = "inttest2.example.com" +bidstream_enabled = true +api_token = "integration-test-token-bravo-32-bytes-ok" + +[request_signing] +enabled = false +config_store_id = "app_config" +secret_store_id = "secrets" + +[integrations.prebid] +enabled = false +server_url = "https://prebid.example.com/openrtb2/auction" +timeout_ms = 1000 +bidders = [] +debug = false +client_side_bidders = [] + +[integrations.nextjs] +enabled = false +rewrite_attributes = ["href", "link", "siteBaseUrl", "siteProductionDomain", "url"] +max_combined_payload_bytes = 10485760 + +[integrations.testlight] +enabled = false +endpoint = "https://testlight.example.com/openrtb2/auction" +timeout_ms = 1200 +rewrite_scripts = true + +[integrations.didomi] +enabled = false +sdk_origin = "https://sdk.example.com" +api_origin = "https://api.example.com" + +[integrations.sourcepoint] +enabled = false +rewrite_sdk = true +cdn_origin = "https://cdn.example.com" +cache_ttl_seconds = 3600 + +[integrations.permutive] +enabled = false +organization_id = "" +workspace_id = "" +project_id = "" +api_endpoint = "https://api.example.com" +secure_signals_endpoint = "https://secure-signals.example.com" + +[integrations.lockr] +enabled = false +app_id = "" +api_endpoint = "https://identity.example.com" +sdk_url = "https://identity.example.com/trusted-server.js" +cache_ttl_seconds = 3600 +rewrite_sdk = true + +[integrations.datadome] +enabled = false +sdk_origin = "https://sdk.example.com" +api_origin = "https://api.example.com" +cache_ttl_seconds = 3600 +rewrite_sdk = true + +[integrations.gpt] +enabled = false +script_url = "https://ads.example.com/gpt.js" +cache_ttl_seconds = 3600 +rewrite_script = true + +[proxy] +certificate_check = false + +[auction] +enabled = false +providers = [] +timeout_ms = 2000 +allowed_context_keys = [] + +[integrations.aps] +enabled = false +pub_id = "your-aps-publisher-id" +endpoint = "https://aps.example.com/e/dtb/bid" +timeout_ms = 1000 + +[integrations.google_tag_manager] +enabled = false +container_id = "GTM-EXAMPLE" +upstream_url = "https://tags.example.com" + +[integrations.adserver_mock] +enabled = false +endpoint = "https://adserver.example.com/mediate" +timeout_ms = 1000 + +[integrations.adserver_mock.context_query_params] +example_segments = "segments" + +[debug] +ja4_endpoint_enabled = false diff --git a/crates/integration-tests/src/bin/seed-viceroy-config.rs b/crates/integration-tests/src/bin/seed-viceroy-config.rs new file mode 100644 index 000000000..2cfc0c649 --- /dev/null +++ b/crates/integration-tests/src/bin/seed-viceroy-config.rs @@ -0,0 +1,64 @@ +//! Generates a Viceroy config with the `app_config` config store seeded from +//! the integration application config. +//! +//! Used by the Playwright browser runner (and CI) to produce the config the +//! WASM runtime loads Settings from. The Rust `cargo test` runtime renders the +//! same config in-process via +//! [`integration_tests::render_seeded_viceroy_config`]. +//! +//! Usage: +//! seed-viceroy-config --template --fixture --port --out + +use std::process::ExitCode; + +use integration_tests::render_seeded_viceroy_config; + +fn main() -> ExitCode { + match run() { + Ok(()) => ExitCode::SUCCESS, + Err(err) => { + eprintln!("seed-viceroy-config: {err}"); + ExitCode::FAILURE + } + } +} + +fn run() -> Result<(), String> { + let mut template = None; + let mut fixture = None; + let mut port = None; + let mut out = None; + + let mut args = std::env::args().skip(1); + while let Some(flag) = args.next() { + match flag.as_str() { + "--template" => template = Some(next_value(&mut args, "--template")?), + "--fixture" => fixture = Some(next_value(&mut args, "--fixture")?), + "--port" => port = Some(next_value(&mut args, "--port")?), + "--out" => out = Some(next_value(&mut args, "--out")?), + other => return Err(format!("unknown argument `{other}`")), + } + } + + let template = template.ok_or("missing --template")?; + let fixture = fixture.ok_or("missing --fixture")?; + let port: u16 = port + .ok_or("missing --port")? + .parse() + .map_err(|err| format!("invalid --port: {err}"))?; + let out = out.ok_or("missing --out")?; + + let template = std::fs::read_to_string(&template) + .map_err(|err| format!("failed to read template `{template}`: {err}"))?; + let fixture = std::fs::read_to_string(&fixture) + .map_err(|err| format!("failed to read fixture `{fixture}`: {err}"))?; + + let config = render_seeded_viceroy_config(&template, &fixture, port)?; + std::fs::write(&out, config).map_err(|err| format!("failed to write `{out}`: {err}"))?; + Ok(()) +} + +fn next_value(args: &mut impl Iterator, flag: &str) -> Result { + args.next() + .ok_or_else(|| format!("missing value for {flag}")) +} diff --git a/crates/integration-tests/src/lib.rs b/crates/integration-tests/src/lib.rs new file mode 100644 index 000000000..5b4454fae --- /dev/null +++ b/crates/integration-tests/src/lib.rs @@ -0,0 +1,67 @@ +//! Shared helpers for the Trusted Server integration tests. +//! +//! The runtime reconstructs `Settings` from the `app_config` config store at +//! request time, so every harness (the Rust `cargo test` runtime and the +//! Playwright browser runner) must seed that store into the Viceroy config +//! before booting the WASM. This module renders that seeded config from a +//! single source — the integration application config fixture — so the Rust +//! harness and the `seed-viceroy-config` binary stay in lockstep. + +use std::collections::BTreeMap; + +use trusted_server_core::config_payload::build_config_payload; +use trusted_server_core::settings::Settings; + +/// Placeholder in the integration config fixture, replaced with the live origin +/// port before the settings are parsed. +pub const ORIGIN_PORT_PLACEHOLDER: &str = "__ORIGIN_PORT__"; + +/// Renders a complete Viceroy config: the static `[local_server]` template plus +/// an inline `app_config` config store seeded from the integration application +/// config. +/// +/// `template` is the Viceroy `[local_server]` config (backends, KV/secret +/// stores); `fixture` is the integration `trusted-server.toml` with an +/// [`ORIGIN_PORT_PLACEHOLDER`] for the origin port. The runtime opens +/// `app_config` and reconstructs Settings from its flattened entries +/// ([`build_config_payload`]), so without this seeding every settings-dependent +/// request returns 503. +/// +/// # Errors +/// +/// Returns a human-readable message if the fixture cannot be parsed into +/// [`Settings`] or the config-store payload cannot be built. +pub fn render_seeded_viceroy_config( + template: &str, + fixture: &str, + origin_port: u16, +) -> Result { + let fixture = fixture.replace(ORIGIN_PORT_PLACEHOLDER, &origin_port.to_string()); + let settings = Settings::from_toml(&fixture) + .map_err(|err| format!("failed to parse integration config fixture: {err:?}"))?; + let payload = build_config_payload(&settings) + .map_err(|err| format!("failed to build app_config config-store payload: {err:?}"))?; + + let mut config = String::from(template); + config.push_str(&render_app_config_store(&payload.entries)); + Ok(config) +} + +/// Renders the seeded `app_config` config store as an inline-TOML section. +/// +/// Keys (flattened dotted settings paths) and values (including JSON-encoded +/// metadata) are emitted as TOML basic strings via `serde_json`, whose string +/// escaping is a subset of TOML's, so dots and quotes round-trip safely. +fn render_app_config_store(entries: &BTreeMap) -> String { + let mut section = String::from( + "\n[local_server.config_stores.app_config]\nformat = \"inline-toml\"\n\n\ + [local_server.config_stores.app_config.contents]\n", + ); + for (key, value) in entries { + let key = serde_json::to_string(key).expect("should encode config key as JSON string"); + let value = + serde_json::to_string(value).expect("should encode config value as JSON string"); + section.push_str(&format!("{key} = {value}\n")); + } + section +} diff --git a/crates/integration-tests/tests/common/runtime.rs b/crates/integration-tests/tests/common/runtime.rs index 3ab916d56..b2b5c4cea 100644 --- a/crates/integration-tests/tests/common/runtime.rs +++ b/crates/integration-tests/tests/common/runtime.rs @@ -119,8 +119,9 @@ pub fn wasm_binary_path() -> PathBuf { /// Get the fixed origin port used for Docker container port mapping. /// -/// This must match the port baked into the WASM binary via -/// `TRUSTED_SERVER__PUBLISHER__ORIGIN_URL` at build time. +/// This must match the `publisher.origin_url` port in the integration config +/// fixture, which the Fastly harness substitutes for `__ORIGIN_PORT__` before +/// seeding it into the `app_config` config store the runtime loads Settings from. pub fn origin_port() -> u16 { match std::env::var("INTEGRATION_ORIGIN_PORT") { Ok(value) => value diff --git a/crates/integration-tests/tests/environments/fastly.rs b/crates/integration-tests/tests/environments/fastly.rs index ec758432c..5987f1ba2 100644 --- a/crates/integration-tests/tests/environments/fastly.rs +++ b/crates/integration-tests/tests/environments/fastly.rs @@ -1,17 +1,19 @@ use crate::common::runtime::{ - RuntimeEnvironment, RuntimeProcess, RuntimeProcessHandle, TestError, TestResult, + RuntimeEnvironment, RuntimeProcess, RuntimeProcessHandle, TestError, TestResult, origin_port, }; -use error_stack::ResultExt as _; +use error_stack::{Report, ResultExt as _}; +use integration_tests::render_seeded_viceroy_config; use std::io::{BufRead as _, BufReader}; -use std::path::Path; +use std::path::{Path, PathBuf}; use std::process::{Child, Command, Stdio}; /// Fastly Compute runtime using Viceroy local simulator. /// -/// Spawns a `viceroy` child process with the WASM binary and the -/// Viceroy-specific `fastly.toml` config (KV stores, secrets). -/// The application config (origin URL, integrations) is baked into -/// the WASM binary at build time. +/// Spawns a `viceroy` child process with the WASM binary and a generated +/// Viceroy config: the static `[local_server]` template (backends, KV stores, +/// secret stores) plus a seeded `app_config` config store. The runtime +/// reconstructs Settings from `app_config` at request time, so the store must +/// carry the integration application config (see `trusted-server-integration.toml`). pub struct FastlyViceroy; impl RuntimeEnvironment for FastlyViceroy { @@ -22,7 +24,7 @@ impl RuntimeEnvironment for FastlyViceroy { fn spawn(&self, wasm_path: &Path) -> TestResult { let port = super::find_available_port()?; - let viceroy_config = self.viceroy_config_path(); + let viceroy_config = self.generate_viceroy_config(port)?; let mut child = Command::new("viceroy") .arg(wasm_path) @@ -47,8 +49,12 @@ impl RuntimeEnvironment for FastlyViceroy { }); } - // Wrap immediately so Drop::drop kills the process if readiness check fails - let handle = ViceroyHandle { child }; + // Wrap immediately so Drop::drop kills the process and removes the + // generated config if the readiness check fails. + let handle = ViceroyHandle { + child, + config_path: viceroy_config, + }; let base_url = format!("http://127.0.0.1:{port}"); // Fastly exposes a dedicated `/health` route, so root fallback only @@ -67,9 +73,43 @@ impl FastlyViceroy { /// /// This contains `[local_server]` configuration (backends, KV stores, /// secret stores) that Viceroy needs, separate from the application config. - fn viceroy_config_path(&self) -> std::path::PathBuf { - std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")) - .join("fixtures/configs/viceroy-template.toml") + fn viceroy_template_path(&self) -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("fixtures/configs/viceroy-template.toml") + } + + /// Path to the integration application config seeded into the `app_config` + /// config store. + fn app_config_fixture_path(&self) -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("fixtures/configs/trusted-server-integration.toml") + } + + /// Renders a complete Viceroy config for this run: the static + /// `[local_server]` template plus an inline `app_config` config store + /// seeded from the integration application config. + /// + /// The runtime opens `app_config` and reconstructs Settings from its + /// flattened entries, so without this seeding every settings-dependent + /// request returns 503. Written to a per-port temp file the + /// [`ViceroyHandle`] removes on drop. Shares + /// [`render_seeded_viceroy_config`] with the `seed-viceroy-config` binary + /// the browser runner uses. + fn generate_viceroy_config(&self, port: u16) -> TestResult { + let template = std::fs::read_to_string(self.viceroy_template_path()) + .change_context(TestError::RuntimeSpawn) + .attach("Failed to read viceroy template")?; + let fixture = std::fs::read_to_string(self.app_config_fixture_path()) + .change_context(TestError::RuntimeSpawn) + .attach("Failed to read integration config fixture")?; + + let config = render_seeded_viceroy_config(&template, &fixture, origin_port()) + .map_err(|message| Report::new(TestError::RuntimeSpawn).attach(message))?; + + let path = std::env::temp_dir().join(format!("ts-viceroy-{port}.toml")); + std::fs::write(&path, config) + .change_context(TestError::RuntimeSpawn) + .attach("Failed to write generated viceroy config")?; + Ok(path) } } @@ -79,6 +119,7 @@ impl FastlyViceroy { /// preventing orphaned Viceroy processes. struct ViceroyHandle { child: Child, + config_path: PathBuf, } impl RuntimeProcessHandle for ViceroyHandle {} @@ -87,5 +128,6 @@ impl Drop for ViceroyHandle { fn drop(&mut self) { let _ = self.child.kill(); let _ = self.child.wait(); + let _ = std::fs::remove_file(&self.config_path); } } diff --git a/crates/trusted-server-adapter-fastly/src/error.rs b/crates/trusted-server-adapter-fastly/src/error.rs index 560a2e181..12215f719 100644 --- a/crates/trusted-server-adapter-fastly/src/error.rs +++ b/crates/trusted-server-adapter-fastly/src/error.rs @@ -17,3 +17,22 @@ pub fn to_error_response(report: &Report) -> Response { Response::from_status(root_error.status_code()) .with_body_text_plain(&format!("{}\n", root_error.user_message())) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn config_store_unavailable_renders_503() { + let report = Report::new(TrustedServerError::ConfigStoreUnavailable { + store_name: "app_config".to_string(), + message: "not seeded".to_string(), + }); + let resp = to_error_response(&report); + assert_eq!( + resp.get_status(), + fastly::http::StatusCode::SERVICE_UNAVAILABLE, + "should render 503 for ConfigStoreUnavailable" + ); + } +} diff --git a/crates/trusted-server-core/src/error.rs b/crates/trusted-server-core/src/error.rs index 1b697f688..4981129fa 100644 --- a/crates/trusted-server-core/src/error.rs +++ b/crates/trusted-server-core/src/error.rs @@ -26,6 +26,11 @@ pub enum TrustedServerError { #[display("Configuration error: {message}")] Configuration { message: String }, + /// Config store could not be read (unseeded, transient backend, or a listed + /// key missing) — Settings cannot be loaded. Retryable / fix by seeding. + #[display("Config store unavailable: {store_name} - {message}")] + ConfigStoreUnavailable { store_name: String, message: String }, + /// Auction orchestration error. #[display("Auction error: {message}")] Auction { message: String }, @@ -123,6 +128,7 @@ impl IntoHttpResponse for TrustedServerError { Self::InvalidHeaderValue { .. } => StatusCode::BAD_REQUEST, Self::InvalidUtf8 { .. } => StatusCode::INTERNAL_SERVER_ERROR, Self::KvStore { .. } => StatusCode::SERVICE_UNAVAILABLE, + Self::ConfigStoreUnavailable { .. } => StatusCode::SERVICE_UNAVAILABLE, Self::Prebid { .. } => StatusCode::BAD_GATEWAY, Self::Integration { .. } => StatusCode::BAD_GATEWAY, Self::Proxy { .. } => StatusCode::BAD_GATEWAY, @@ -242,6 +248,15 @@ mod tests { assert_eq!(error.user_message(), "Invalid header value"); } + #[test] + fn config_store_unavailable_maps_to_503() { + let err = TrustedServerError::ConfigStoreUnavailable { + store_name: "app_config".to_string(), + message: "not seeded".to_string(), + }; + assert_eq!(err.status_code(), StatusCode::SERVICE_UNAVAILABLE); + } + #[test] fn status_code_maps_each_error_variant_to_expected_http_response() { // Compile-time guard: adding a TrustedServerError variant without @@ -264,6 +279,7 @@ mod tests { | TrustedServerError::EdgeCookie { .. } | TrustedServerError::PartnerNotFound { .. } | TrustedServerError::RequestTooLarge { .. } + | TrustedServerError::ConfigStoreUnavailable { .. } | TrustedServerError::InsecureDefault { .. } => (), }; @@ -341,6 +357,13 @@ mod tests { }, StatusCode::SERVICE_UNAVAILABLE, ), + ( + TrustedServerError::ConfigStoreUnavailable { + store_name: "app_config".to_string(), + message: "config store unavailable".to_string(), + }, + StatusCode::SERVICE_UNAVAILABLE, + ), ( TrustedServerError::Auction { message: "auction failed".to_string(), diff --git a/crates/trusted-server-core/src/settings_data.rs b/crates/trusted-server-core/src/settings_data.rs index efcfbcd45..92d6bdfb5 100644 --- a/crates/trusted-server-core/src/settings_data.rs +++ b/crates/trusted-server-core/src/settings_data.rs @@ -17,9 +17,12 @@ const DEFAULT_CONFIG_STORE_ID: &str = "app_config"; /// /// # Errors /// -/// Returns [`TrustedServerError::Configuration`] when metadata or any flattened -/// config entry is missing, cannot be read, fails hash verification, or fails -/// Trusted Server settings validation. +/// Returns [`TrustedServerError::ConfigStoreUnavailable`] (HTTP 503) when a +/// config-store entry cannot be read (store unseeded, transient backend, or a +/// listed key missing), and [`TrustedServerError::Configuration`] / +/// [`TrustedServerError::Settings`] (HTTP 500) when the read succeeds but +/// reconstruction fails (metadata unparseable, hash verification, or settings +/// validation). pub fn get_settings_from_services( services: &RuntimeServices, ) -> Result> { @@ -32,9 +35,12 @@ pub fn get_settings_from_services( /// /// # Errors /// -/// Returns [`TrustedServerError::Configuration`] when metadata or any flattened -/// config entry is missing, cannot be read, fails hash verification, or fails -/// Trusted Server settings validation. +/// Returns [`TrustedServerError::ConfigStoreUnavailable`] (HTTP 503) when a +/// config-store entry cannot be read (store unseeded, transient backend, or a +/// listed key missing), and [`TrustedServerError::Configuration`] / +/// [`TrustedServerError::Settings`] (HTTP 500) when the read succeeds but +/// reconstruction fails (metadata unparseable, hash verification, or settings +/// validation). pub fn get_settings_from_config_store( config_store: &dyn PlatformConfigStore, store_name: &StoreName, @@ -66,10 +72,11 @@ fn read_config_entry( ) -> Result> { config_store .get(store_name, key) - .change_context(TrustedServerError::Configuration { + .change_context(TrustedServerError::ConfigStoreUnavailable { + store_name: store_name.to_string(), message: format!( - "failed to read Trusted Server app config key `{key}` from config store `{store_name}`" - ), + "unavailable or not seeded (failed to read `{key}`) — run `ts config push`" + ), }) } @@ -77,6 +84,7 @@ fn read_config_entry( mod tests { use super::*; use crate::config_payload::build_config_payload; + use crate::error::IntoHttpResponse; use crate::platform::PlatformError; use crate::settings::Settings; use crate::test_support::tests::crate_test_settings_str; @@ -142,4 +150,68 @@ mod tests { "error should mention missing keys metadata" ); } + + #[test] + fn unseeded_store_is_config_store_unavailable_503() { + let store = MemoryConfigStore { + entries: BTreeMap::new(), + }; + let err = get_settings_from_config_store(&store, &StoreName::from("app_config")) + .expect_err("unseeded store must error"); + assert_eq!( + err.current_context().status_code(), + http::StatusCode::SERVICE_UNAVAILABLE, + "unseeded store read failure should map to 503" + ); + // The actionable hint must ride the error chain so it reaches the + // server log (the operator's channel); the public 503 body stays + // generic by design. + assert!( + format!("{err:?}").contains("ts config push"), + "error chain should carry the actionable `ts config push` hint for logs" + ); + } + + #[test] + fn malformed_hash_stays_500() { + let mut entries = build_config_payload( + &Settings::from_toml(&crate_test_settings_str()).expect("should parse"), + ) + .expect("should build payload") + .entries; + entries.insert(CONFIG_HASH_KEY.to_string(), "sha256:deadbeef".to_string()); + let store = MemoryConfigStore { entries }; + let err = get_settings_from_config_store(&store, &StoreName::from("app_config")) + .expect_err("hash mismatch must error"); + assert_eq!( + err.current_context().status_code(), + http::StatusCode::INTERNAL_SERVER_ERROR, + "reconstruct/verify failure should stay 500" + ); + } + + #[test] + fn missing_listed_key_is_503() { + // Metadata (`ts-config-keys` / `ts-config-hash`) reads succeed, but a key + // the metadata lists is absent — still a read failure → 503. + let mut entries = build_config_payload( + &Settings::from_toml(&crate_test_settings_str()).expect("should parse"), + ) + .expect("should build payload") + .entries; + let victim = entries + .keys() + .find(|key| !key.starts_with("ts-config-")) + .cloned() + .expect("payload should have at least one settings key"); + entries.remove(&victim); + let store = MemoryConfigStore { entries }; + let err = get_settings_from_config_store(&store, &StoreName::from("app_config")) + .expect_err("missing listed key must error"); + assert_eq!( + err.current_context().status_code(), + http::StatusCode::SERVICE_UNAVAILABLE, + "a listed key missing is a config-store read failure → 503" + ); + } } diff --git a/docs/superpowers/plans/2026-06-17-edgezero-269-repin.md b/docs/superpowers/plans/2026-06-17-edgezero-269-repin.md new file mode 100644 index 000000000..bfdff332b --- /dev/null +++ b/docs/superpowers/plans/2026-06-17-edgezero-269-repin.md @@ -0,0 +1,267 @@ +# EdgeZero #269 HTTP-Layer 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:** Land the HTTP-layer (runtime) half of adopting edgezero `stackpop/edgezero#269` in trusted-server — by **converging onto Christian's `feature/ts-cli-next`** (which already carries the repin, the `Body` fixes, and runtime Settings-from-config-store for Fastly), then closing the runtime gaps it leaves: seed-before-serve safety, secrets/KV runtime wiring, non-Fastly adapters, and the missing runtime-config-store spec. + +**Architecture:** trusted-server keeps its bespoke `platform/` layer (`RuntimeServices` + `PlatformConfigStore`/`SecretStore`/`KvStore`). #269's only forced code break is `Body::into_bytes() → Option` (18 sinks — Appendix A). Christian's branch already fixes those and wires `get_settings_from_services()` to rebuild `Settings` from the `app_config` config store via the shared `config_payload` flatten/hash contract. Our work is the **runtime-side hardening + spec**, not a parallel repin. + +**Tech Stack:** Rust 2024, cargo, `wasm32-wasip1` (Fastly via Viceroy), edgezero git dep (`2eeccc9`, #269 HEAD), `error-stack`. + +**Source spec:** [2026-06-16-edgezero-269-repin-breaking-api-finding.md](../specs/2026-06-16-edgezero-269-repin-breaking-api-finding.md) — esp. **§12** (convergence), §2 (sinks), §9 (decisions). + +--- + +## Strategy change (why this plan was rewritten) + +The prior version of this plan was a standalone minimal-repin off PR14. Investigation of `feature/ts-cli-next` (2026-06-18, spec §12) showed that branch is **not just CLI** — it already implements the end-to-end Fastly config-store migration: same #269 pin, the `Body` fixes, store ids, the `config_payload` contract, **and** runtime `Settings`-from-store load. So a separate Fastly repin is **redundant**. This plan now **builds on his branch** and focuses on the runtime gaps. The verified `Body`-sink enumeration is preserved as Appendix A (still the authoritative sink reference when his ad-hoc fixes merge up the stack). + +--- + +## Open decisions — resolve at Phase 0 before coding + +| # | Decision | Recommendation | +| --- | -------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------- | +| D1 | Build on `feature/ts-cli-next` vs keep the PR14-stack minimal-repin | **Build on his** (his is end-to-end for Fastly; ours duplicates it) | +| D2 | Whole-`Settings` → store (his) vs two-tier small `AppConfig` (our spec §6) | **Adopt his whole-`Settings`** (one source of truth; already implemented) | +| D3 | `Body` fix style | **His `ok_or_else` (graceful)** over `.expect()` (spec §2) | +| D4 | Empty/unseeded store behavior | **Decide explicitly** (Phase 2) — today it's a hard fail / outage | +| D5 | CLI-driven secret push (he punts) vs runtime secret writes (already exist via `management_api.rs`) | Keep runtime rotation; treat CLI secret-push as a later follow-up | +| D6 | Branch/merge topology — his branch is off `main`, the stack is PR14→PR20 | Phase 5 — confirm with team | + +Do **not** start Phase 1 until **D1–D3** are confirmed (they set the base branch +and code style). **D4–D6 are sequenced, not skipped:** D4 (empty-store response) +is resolved in Phase 2 Step 5, D5 (secret-write boundary) in Phase 3 Step 2, D6 +(branch topology) in Phase 5 Step 1. + +--- + +## Scope & non-goals + +**In scope:** converge onto his branch; verify the repin + `Body` fixes are complete against Appendix A; run the full gate (host, **wasm32-wasip1**, **`--all-targets`**, clippy, test) + integration-tests lockfile; harden runtime config-store loading (empty/malformed-store, seed-before-serve); confirm secrets/`ec_identity_store` KV runtime wiring; write the runtime-config-store spec; merge up the stack. + +**Out of scope (separate plans):** the CLI crate itself (`ts config`/`audit` — Christian); CLI-driven secret push; full edgezero `run_app`/`app!`/extractor adoption (he kept the bespoke layer, so do we); non-Fastly adapter _feature_ parity beyond making them build. + +--- + +## File structure (what we touch / extend, on his branch) + +| File | Role | Our action | +| ------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------- | --------------------------------------------- | +| `Cargo.toml` / `Cargo.lock` | edgezero pinned `2eeccc9` | verify; re-pin to `main` post-merge (Phase 5) | +| `crates/trusted-server-core/src/config_payload.rs` | flatten/hash contract (shared seam) | **read-only reference** — do not fork | +| `crates/trusted-server-core/src/settings_data.rs` | `get_settings_from_services` runtime load | **harden** empty/malformed behavior (Phase 2) | +| `crates/trusted-server-adapter-fastly/src/main.rs` | entry point: build services → load settings | **harden** the settings-error path (Phase 2) | +| `edgezero.toml` | store ids: `app_config` / `secrets` / `ec_identity_store` | verify; reference in the spec | +| `crates/trusted-server-core/src/{proxy,publisher,auction/endpoints,auction/formats,request_signing/endpoints}.rs`, `integrations/{prebid,testlight}.rs` | `Body` sinks | **verify** all 18 covered (Appendix A) | +| `crates/trusted-server-adapter-{cloudflare,spin}` | stubs, untouched by him | **make build** under #269 (Phase 3) | +| `docs/superpowers/specs/-runtime-config-store.md` | the missing spec | **create** (Phase 4) | + +--- + +## Phase 0: Convergence decision + adopt the base + +- [ ] **Step 1: Confirm D1–D3** with the team (record in the spec §9). If D1 = "build on his," proceed; if "keep PR14-stack," fall back to Appendix B (the minimal-repin tasks). + +- [ ] **Step 2: Create the HTTP-layer branch off his branch** + +```bash +git fetch origin +# Record the exact SHA — his branch is an unmerged WIP and may force-push/rebase. +git rev-parse origin/feature/ts-cli-next # note this; if he rebases, re-base from the new SHA + coordinate +git checkout -b feature/edgezero-269-http origin/feature/ts-cli-next +``` + +- [ ] **Step 3: Baseline build (inherit his state)** + +Run: `cargo build --workspace --all-targets 2>/tmp/ez_base.log; echo "exit=$?"` +Expected: **green** (his branch should already compile). If red, capture and triage before any new work. + +--- + +## Phase 1: Verify the inherited repin + `Body` fixes + +His `Body` fixes were ad-hoc (driven by his build), not enumerated. Verify completeness against Appendix A, and run the **full** gate (he is unlikely to have run wasm + `--all-targets` + clippy on every leg). + +- [ ] **Step 1: Enumerate the sinks (locate, don't "prove")** + +Run: `git grep -nE 'into_bytes\(\)' crates/trusted-server-core/src -- 'proxy.rs' 'publisher.rs' 'auction/endpoints.rs' 'auction/formats.rs' 'request_signing/endpoints.rs' 'integrations/prebid.rs' 'integrations/testlight.rs'` +Expect **18 sites** (8 prod + 10 test). Eyeball each has an `Option` handler +(`.ok_or_else`/`.expect`/`.unwrap_or_default`). **Note: grep cannot prove +correctness** — a fixed Shape-C `let b = …into_bytes().ok_or_else(…)?;` and a +broken bare `.into_bytes()` both contain `.into_bytes()`. This step is enumeration +only; the **authoritative completeness proof is Step 2's green `--all-targets` + +`cargo test`.** Appendix A line numbers are **PR14-base and do NOT apply** to this +`main`-based branch — trust the grep _count_ (18), not the numbers. + +- [ ] **Step 2: Full gate (the legs he likely skipped)** + +```bash +cargo build --workspace --all-targets +cargo build --package trusted-server-adapter-fastly --release --target wasm32-wasip1 +cargo test --workspace +cargo clippy --workspace --all-targets --all-features -- -D warnings +cargo fmt --all -- --check +``` + +Expected: all green. Any failure here is the real signal — fix before Phase 2. + +- [ ] **Step 3: integration-tests lockfile** + +`crates/integration-tests` is a separate workspace that path-deps `trusted-server-core`. +Run: `( cd crates/integration-tests && cargo build --workspace )` first (don't +`generate-lockfile` — that can re-resolve and _cause_ drift). Only if it fails on +shared-dep mismatch: `cargo update -p --precise ` (never +blanket). Repeat for `crates/openrtb-codegen` if it drifts. + +- [ ] **Step 4: Commit any gate fixups** + +```bash +git add crates Cargo.toml Cargo.lock && git commit -m "Complete Body sink coverage and pass full gate on #269" || echo "nothing to commit" +``` + +--- + +## Phase 2: Runtime config-store hardening (the core HTTP-layer deliverable) + +**Problem (verified against his `main.rs`):** `get_settings_from_services` → +`get_settings_from_config_store` reads `ts-config-keys` first; on an +**empty/unseeded store** `read_config_entry`'s `?` propagates a `Configuration` +error. His settings-error arm **does serve a response** — +`to_error_response(&e).send_to_client(); return;` (not a bare return, not an +opaque default; `fn main()` returns `()` and serves explicitly). So the issue is +**not** "no response" — it is that **every route returns a generic error** until +the store is seeded, and the error is **indistinguishable from a real config +bug**. Fresh deploy before `ts config push` = **total outage with an opaque 500**. +The gap our layer owns: make the unseeded case **actionable** (clear message) and +**correctly classified** (retryable 503, not 500). + +> **Call chain (read first):** `get_settings_from_services(&runtime_services)` → +> `get_settings_from_config_store(&dyn PlatformConfigStore, &StoreName)` → +> `read_config_entry` (per key) → `settings_from_config_entries` (hash verify). +> The in-memory `PlatformConfigStore` fake **already exists** as +> `MemoryConfigStore` in `settings_data.rs` tests (around line 84) — reuse it; do +> not write a new one. Confirm the exact constructor (`MemoryConfigStore { entries }` +> vs `::new(...)`) before writing the test below. + +- [ ] **Step 1: Write a failing test — empty store yields an actionable, typed error (not a generic read failure)** + +In `settings_data.rs` tests (reuse `MemoryConfigStore`): + +```rust +#[test] +fn empty_config_store_reports_unseeded_not_generic_failure() { + let store = MemoryConfigStore::new(BTreeMap::new()); // no ts-config-keys + let err = get_settings_from_config_store(&store, &StoreName::from("app_config")) + .expect_err("empty store should error"); + // assert it carries an actionable "config store not seeded — run `ts config push`" context + assert!(format!("{err:?}").contains("not seeded") || format!("{err:?}").contains("ts config push")); +} +``` + +- [ ] **Step 2: Run it — verify it fails** (`cargo test -p trusted-server-core empty_config_store -- --nocapture`). Expected: FAIL (current error message is generic "failed to read … key `ts-config-keys`"). + +- [ ] **Step 3: Implement — distinguish "unseeded" from "read error"** + +In `read_config_entry` / `get_settings_from_config_store`, when the **metadata** key (`ts-config-keys`) is absent, attach an actionable context (e.g. `TrustedServerError::Configuration` with `"config store `{store}`is not seeded — run`ts config push --adapter fastly`"`). Keep transport/read failures distinct. + +- [ ] **Step 4: Run the test — verify it passes.** + +- [ ] **Step 5: Decide + implement the adapter response (D4)** + +His settings-error arm already serves via `to_error_response(&e).send_to_client(); return;`. +Two options — **decide D4 here:** +(a) keep the arm, but have `to_error_response` map the new "unseeded" error context +to **503** (retryable) instead of 500; or +(b) special-case the unseeded error in `main.rs` before `to_error_response`: +`FastlyResponse::from_status(503).with_body_text_plain("config not provisioned — run `ts config push`").send_to_client(); return;` +(matches the existing `from_status(...).with_body_text_plain(...).send_to_client()` +idiom at `main.rs:119–121`). Add an adapter test asserting **503 + body** for the +unseeded case. This turns an opaque 500 into an observable, actionable signal — +and keeps real config bugs as 500. + +- [ ] **Step 6: Malformed-store test** — seed a `ts-config-hash` that doesn't match the entries; assert `settings_from_config_entries` errors on hash mismatch (his code already verifies; add the test if absent so the contract is locked). + +- [ ] **Step 7: Confirm secrets + KV runtime wiring** + - `secrets` store: request-signing reads signing keys via `PlatformSecretStore` (pre-existing `management_api.rs` provides write CRUD). Add/confirm a test that a missing signing secret degrades to a clear error, not a panic. + - `ec_identity_store` KV: `main.rs` starts `UnavailableKvStore` and EC routes lazily bind the configured store. Confirm a non-EC route still serves when EC KV is unavailable (existing behavior — add a regression test if missing). + +- [ ] **Step 8: Commit** + +```bash +git add crates && git commit -m "Harden runtime config-store load: actionable unseeded error and 503 response" +``` + +--- + +## Phase 3: Adapter + build-surface gaps + +- [ ] **Step 1: Make non-Fastly adapters build under #269** + +First confirm what "builds" means for these stubs — spec §1 notes +cloudflare/axum are **absent from the dependency graph** (not currently compiled). +If the crate has no real wasm entry, "builds" = `cargo check -p trusted-server-adapter-cloudflare` +on host; only use `--target wasm32-unknown-unknown` (install the target first) if +it has a genuine worker entry point. Same judgment for spin. +If they break on `Body`/edgezero churn, apply the Appendix A fix shapes. They are stubs — goal is **compiles**, not feature parity (out of scope). + +- [ ] **Step 2: Document the secret-write boundary (D5)** + +Confirm: runtime key-rotation secret writes work via `management_api.rs` (pre-existing); CLI-driven secret _push_ is deferred (Christian punts it). Capture this split in the spec so it is a recorded decision, not an accident. + +- [ ] **Step 3: Commit any adapter fixups.** + +--- + +## Phase 4: Runtime-config-store spec (the doc his CLI design references but never wrote) + +- [ ] **Step 1: Write `docs/superpowers/specs/-runtime-config-store.md`** covering: + - the load sequence (`build_runtime_services` → `get_settings_from_services` → `settings_from_config_entries`); + - the **shared `config_payload` contract** (escaping, sorted-key canonicalization, `sha256` over settings-only entries, `ts-config-*` reserved keys) — reference, do not duplicate; + - the **seed-before-serve** operational contract + the 503 unseeded behavior (Phase 2); + - empty / missing-key / malformed-hash / transport-error matrix; + - store-name resolution + `EDGEZERO__STORES__CONFIG__APP_CONFIG__NAME` override; + - secrets/KV runtime read paths + the secret-write boundary (D5); + - non-Fastly adapter status. + +- [ ] **Step 2: Docs gate** — `cd docs && npm run format` (prettier-clean), then commit. + +--- + +## Phase 5: Stack propagation + re-pin + +- [ ] **Step 1: Reconcile topology (D6).** His branch is off `main`; the migration stack is PR14→PR20. Confirm with the team whether the HTTP-layer branch merges via `main` (with his) or threads the stack. Do not push/merge without approval. + +- [ ] **Step 2: Re-pin to edgezero `main` after #269 merges** — one-line dep change in `Cargo.toml`, regenerate lock, re-run the Phase 1 gate. + +- [ ] **Step 3: Open the PR (approval-gated).** Base = whatever Step 1 resolves. Assign `@me`. Summary: HTTP-layer convergence + runtime hardening + spec. + +--- + +## Risks & watch points + +| Risk | Mitigation | +| ------------------------------------------------------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **Fresh deploy = outage** (unseeded store, no fallback) | Phase 2: actionable error + 503; document seed-before-serve; consider a provisioning gate in deploy | +| His `Body` fixes incomplete vs our 18 sinks | Phase 1 Step 1 cross-check against Appendix A | +| He likely didn't run wasm + `--all-targets` + clippy on every leg | Phase 1 Step 2 runs the full matrix | +| Pinned to an **open, force-pushable** #269 ref | Re-pin to `main` post-merge (Phase 5); rollback = revert the dep commit | +| **Building on a colleague's unmerged WIP branch** (`feature/ts-cli-next`) — it may rebase/force-push out from under us, vanishing our merge-base | Record its SHA at Phase 0 Step 2; if he rebases, re-base from the new SHA and coordinate before any merge; keep our additions as discrete commits so they re-cherry-pick cleanly | +| integration-tests lockfile drift | Phase 1 Step 3, targeted `--precise` only | +| Branch topology (his off `main`, stack off PR14) | Phase 5 Step 1, confirm with team | +| Whole-`Settings`-in-store enlarges blast radius of a bad push | hash verification (his) + malformed-store test (Phase 2 Step 6) | + +--- + +## Appendix A — verified `Body::into_bytes` sink reference (authoritative) + +From the compiler spike (spec §2/§10): **18 sink bindings, 8 production + 10 test-only**, all `into_bytes` (no `as_bytes` sink). The line numbers below are **PR14-base — they do NOT apply to the `main`-based `feature/ts-cli-next`**; use them only as a count/shape reference (8 prod + 10 test). On any branch, the compiler (`--all-targets`) is the source of truth. Use this to confirm Christian's ad-hoc fixes are complete and when merging up the stack. + +- **Production (8):** `proxy.rs:38`, `publisher.rs:46`, `auction/endpoints.rs:81`, `proxy.rs:1550`, `proxy.rs:1665`, `request_signing/endpoints.rs:103/246/365`. +- **Test-only (10):** `auction/formats.rs:444`, `prebid.rs:2067`, `testlight.rs:461`, `proxy.rs:2034/2795/2851`, `publisher.rs:748/1079/1562`, `request_signing/endpoints.rs:464`. +- **Not a sink:** `http_util.rs:456` (the `enforce_max_body_size(bytes: &[u8], …)` signature). +- **Fix style (D3):** production → `into_bytes().ok_or_else(|| )?`; compression/test → `unwrap_or_default()`; only `.expect("should …")` where a buffered body is truly invariant. + +## Appendix B — fallback: standalone minimal-repin (only if D1 = "keep PR14 stack") + +If the team rejects building on his branch, the original minimal-repin still applies: branch off PR14, repin to `2eeccc9`, fix the Appendix A sinks with the D3 style, reconcile the integration-tests lock, full gate, merge up PR14→PR20. (This duplicates his Fastly work and is **not** recommended — see spec §12.) diff --git a/docs/superpowers/plans/2026-06-18-edgezero-269-http-layer-runtime.md b/docs/superpowers/plans/2026-06-18-edgezero-269-http-layer-runtime.md new file mode 100644 index 000000000..a17abba1a --- /dev/null +++ b/docs/superpowers/plans/2026-06-18-edgezero-269-http-layer-runtime.md @@ -0,0 +1,312 @@ +# EdgeZero #269 HTTP-Layer Runtime 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:** Harden the runtime config-store load so an unseeded/unavailable `app_config` store returns an actionable **503** (not an opaque 500), confirm the non-Fastly adapters still build, and record the secret-write boundary — on top of the inherited #269 base. + +**Architecture:** trusted-server boots by rebuilding `Settings` from the `app_config` config store (`get_settings_from_services` → `get_settings_from_config_store` → `read_config_entry` per key → `settings_from_config_entries`). We classify failures by **call site**: a **config-store read failure** (unseeded, transient, or a missing listed key — all `PlatformConfigStore::get → Err`) maps to a new `TrustedServerError::ConfigStoreUnavailable` → **503**; a **reconstruct/verify failure** (`settings_from_config_entries`: hash mismatch, unparseable) stays `Configuration` → **500**. One new error variant, no platform-layer change (spec option Y). + +**Tech Stack:** Rust 2024, `error-stack` (`Report`), `derive_more::Display`, cargo, `wasm32-wasip1`. + +**Source spec:** [2026-06-18-edgezero-269-http-layer-runtime-design.md](../specs/2026-06-18-edgezero-269-http-layer-runtime-design.md) — §3.3 behavior matrix, §4.1 mechanism, §4.4 the `get→Option` follow-up (out of scope here). + +**Branch:** `feature/edgezero-269-http` (off `ts-cli-next` `14a91cc1`, edgezero `2eeccc9`), inherited base verified green. + +--- + +## Scope & non-goals + +**In scope:** the new `ConfigStoreUnavailable` 503 variant + read-failure classification (§4.1); core + adapter tests; malformed-hash stays-500 test; non-Fastly build check (§4.2); secret-write boundary note (§4.3). + +**Out of scope:** `PlatformConfigStore::get → Result>` convergence (spec §4.4 — pre-existing trait, store-convergence follow-up); the PR14→PR20 stack; CLI changes; edgezero extractor/`run_app` adoption. + +--- + +## File structure + +| File | Change | +| -------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `crates/trusted-server-core/src/error.rs` | Add `ConfigStoreUnavailable { message: String }` variant + `status_code()` arm → `SERVICE_UNAVAILABLE` | +| `crates/trusted-server-core/src/settings_data.rs` | `read_config_entry` `change_context` → `ConfigStoreUnavailable` (actionable message); `get_settings_from_config_store` metadata reads inherit it; **reconstruct path unchanged**; add tests | +| `crates/trusted-server-adapter-fastly/src/` (test) | Adapter test: read-failure error → **503** to client via `to_error_response` | + +`settings_from_config_entries` and `config_payload.rs` are **not** touched (reconstruct/verify stays 500; shared seam read-only). + +--- + +## Task 1: Add the `ConfigStoreUnavailable` error variant (→ 503) + +**Files:** Modify `crates/trusted-server-core/src/error.rs` + +- [ ] **Step 1: Write the failing test** (in `error.rs` `#[cfg(test)]`) + +```rust +#[test] +fn config_store_unavailable_maps_to_503() { + let err = TrustedServerError::ConfigStoreUnavailable { + message: "config store unavailable or not seeded".to_string(), + }; + assert_eq!(err.status_code(), StatusCode::SERVICE_UNAVAILABLE); +} +``` + +- [ ] **Step 2: Run it — verify it fails** + +Run: `cargo test -p trusted-server-core config_store_unavailable_maps_to_503` +Expected: FAIL to compile — variant doesn't exist yet. + +- [ ] **Step 3: Add the variant + mapping** + +In the `TrustedServerError` enum, beside `Configuration { message: String }` (mirror its `#[display(...)]` style): + +```rust +/// Config store could not be read (unseeded, transient backend, or a +/// listed key missing) — the service cannot load Settings. Retryable / fix +/// by seeding the store. +#[display("Config store unavailable: {message}")] +ConfigStoreUnavailable { message: String }, +``` + +In `status_code()`, beside the `KvStore` 503 arm (`error.rs:125`): + +```rust +Self::ConfigStoreUnavailable { .. } => StatusCode::SERVICE_UNAVAILABLE, +``` + +**Also (required — or Step 4 won't compile):** `error.rs` has an existing +exhaustiveness-guard test `status_code_maps_each_error_variant_to_expected_http_response` +(~lines 246-390) whose `let _guard: fn(&TrustedServerError) = |error| match error { … }` +lists **every** variant with no wildcard. Add the new variant to that guard arm +(and a case to its `cases` array) in this step, e.g.: + +```rust +TrustedServerError::ConfigStoreUnavailable { .. } => {} +``` + +Skipping this turns Step 4 into a non-exhaustive-match **compile error**, not a pass. + +- [ ] **Step 4: Run it — verify it passes** + +Run: `cargo test -p trusted-server-core config_store_unavailable_maps_to_503` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add crates/trusted-server-core/src/error.rs +git commit -m "Add ConfigStoreUnavailable error variant mapping to 503" +``` + +--- + +## Task 2: Classify config-store read failures as `ConfigStoreUnavailable` + +**Files:** Modify `crates/trusted-server-core/src/settings_data.rs` + +Background (confirmed): `read_config_entry` wraps `config_store.get(...)` with +`change_context(TrustedServerError::Configuration { … })` today (→ 500). The +in-memory test fake is `MemoryConfigStore { entries: BTreeMap }` +(**struct literal, no `::new`**); its `get` returns `Err(PlatformError::ConfigStore)` +for a missing key — so an empty map models the unseeded store. + +- [ ] **Step 1: Write the failing tests** (in `settings_data.rs` `#[cfg(test)]`, reuse `MemoryConfigStore`) + +**Imports (required — `status_code()` is a trait method):** the test module is +`use super::*`, which does **not** bring in the status trait. Add to the test +module: `use crate::error::IntoHttpResponse;` (precedent: `proxy.rs` and +`request_signing/endpoints.rs` test modules do the same). `http::StatusCode` works +as a bare path (`http` is a direct dep) — no `use` needed for it. + +```rust +#[test] +fn unseeded_store_is_config_store_unavailable_503() { + let store = MemoryConfigStore { entries: BTreeMap::new() }; // no ts-config-keys + let err = get_settings_from_config_store(&store, &StoreName::from("app_config")) + .expect_err("unseeded store must error"); + assert_eq!( + err.current_context().status_code(), + http::StatusCode::SERVICE_UNAVAILABLE, + "unseeded/unavailable config store should be 503" + ); +} + +#[test] +fn malformed_hash_stays_500() { + // Build a valid payload, then corrupt the hash entry so reconstruct fails. + let mut payload = build_config_payload( + &Settings::from_toml(&crate_test_settings_str()).expect("should parse"), + ) + .expect("should build payload") + .entries; + payload.insert(CONFIG_HASH_KEY.to_string(), "sha256:deadbeef".to_string()); + let store = MemoryConfigStore { entries: payload }; + let err = get_settings_from_config_store(&store, &StoreName::from("app_config")) + .expect_err("hash mismatch must error"); + assert_eq!( + err.current_context().status_code(), + http::StatusCode::INTERNAL_SERVER_ERROR, + "corrupt (loaded-but-invalid) config should stay 500" + ); +} +``` + +- [ ] **Step 2: Run — verify the unseeded test fails, malformed passes** + +Run: `cargo test -p trusted-server-core -- unseeded_store_is_config_store_unavailable malformed_hash_stays_500` +Expected: `unseeded_…` FAILS (currently 500); `malformed_hash_stays_500` PASSES already (reconstruct path unchanged). If `malformed` fails, the corruption isn't reaching `settings_from_config_entries` — re-check the fixture before touching code. + +- [ ] **Step 3: Implement — flip only the read path** + +In `read_config_entry`, change the `change_context` target from +`TrustedServerError::Configuration` to the new variant with an actionable message: + +```rust +fn read_config_entry( + config_store: &dyn PlatformConfigStore, + store_name: &StoreName, + key: &str, +) -> Result> { + config_store + .get(store_name, key) + .change_context(TrustedServerError::ConfigStoreUnavailable { + message: format!( + "config store `{store_name}` unavailable or not seeded \ + (failed to read `{key}`) — run `ts config push`" + ), + }) +} +``` + +Do **not** change the `serde_json::from_str(&keys_raw)` parse (`Configuration`/500 +— that's reconstruct of metadata, genuine corruption) or `settings_from_config_entries`. + +- [ ] **Step 4: Run — both tests pass + full core suite** + +Run: `cargo test -p trusted-server-core` +Expected: the two new tests PASS; the existing `settings_data` round-trip/load tests still PASS (seeded path unchanged). + +- [ ] **Step 5: Commit** + +```bash +git add crates/trusted-server-core/src/settings_data.rs +git commit -m "Classify config-store read failures as ConfigStoreUnavailable (503)" +``` + +--- + +## Task 3: Adapter end-to-end — read failure reaches the client as 503 + +**Files:** Add a test in `crates/trusted-server-adapter-fastly/src/` (follow the existing test module style — see `route_tests.rs` for how adapter tests assert status; `to_error_response` lives in `crate::error`). + +- [ ] **Step 1: Write the failing test** + +Assert that the adapter's error→response path maps a `ConfigStoreUnavailable` +error to a 503 response. Minimal, no live store: + +```rust +#[test] +fn config_store_unavailable_error_renders_503() { + use trusted_server_core::error::TrustedServerError; + let err = error_stack::Report::new(TrustedServerError::ConfigStoreUnavailable { + message: "unseeded".to_string(), + }); + let resp = crate::error::to_error_response(&err); + assert_eq!(resp.get_status(), fastly::http::StatusCode::SERVICE_UNAVAILABLE); +} +``` + +(Confirm `to_error_response`'s exact signature/return type — adjust the call and +the status accessor to match `route_tests.rs` conventions. If `to_error_response` +takes the error by value or a different ref, follow the existing call sites.) + +- [ ] **Step 2: Run — verify it fails** (compile or assertion), then make it pass + +Run: `cargo test -p trusted-server-adapter-fastly config_store_unavailable_error_renders_503` +If it fails only because `to_error_response` already maps via `status_code()` (Task 1), it should pass once the call signature is correct — this test **locks** that the variant→503 mapping isn't bypassed by the adapter. + +- [ ] **Step 3: Commit** + +```bash +git add crates/trusted-server-adapter-fastly/src +git commit -m "Lock adapter 503 response for ConfigStoreUnavailable" +``` + +--- + +## Task 4: Confirm non-Fastly adapters still build + +**Files:** none (verification) + +- [ ] **Step 1: Determine what "builds" means per stub** + +`trusted-server-adapter-{cloudflare,spin}` are stubs (cloudflare/axum absent from +the dependency graph). Run host checks: +`cargo check -p trusted-server-adapter-cloudflare` and `-p trusted-server-adapter-spin`. +Only attempt `--target wasm32-unknown-unknown` if the crate has a real worker +entry (install the target first). + +- [ ] **Step 2: If anything breaks under #269**, apply the §4.1 / finding fix shapes (likely none — our change is additive to core). Expected: green with no edits. + +- [ ] **Step 3: Commit any fixups** (likely none). + +--- + +## Task 5: Record the secret-write boundary (§4.3) + +**Files:** Modify a doc comment near the signing/secret store wiring (no behavior change) + +- [ ] **Step 1:** Add a brief doc comment where signing secrets are read (or at + `management_api.rs`'s secret-write entry) noting: runtime key-rotation writes go + through `FastlyPlatformSecretStore` CRUD (pre-existing); CLI-driven secret push + is deferred (spec §4.3). One comment so the split is discoverable in code, not + only the spec. + +- [ ] **Step 2: Commit** + +```bash +git add crates/trusted-server-adapter-fastly/src +git commit -m "Document runtime-vs-CLI secret-write boundary" || echo "nothing to commit" +``` + +> Skip this task if the team prefers the boundary lives only in the spec. + +--- + +## Task 6: Full gate + +**Files:** none (verification) + +- [ ] **Step 1: Run the gate** + +```bash +cargo build --workspace --all-targets +cargo build -p trusted-server-adapter-fastly --release --target wasm32-wasip1 +cargo test --workspace +cargo clippy --workspace --all-targets --all-features -- -D warnings +cargo fmt --all -- --check +``` + +Expected: all green (the §6 baseline + the new tests). + +- [ ] **Step 2: integration-tests lockfile** (separate workspace, path-deps core) + +Run: `( cd crates/integration-tests && cargo build --workspace )`. Only on +shared-dep mismatch: `cargo update -p --precise ` (never +blanket). + +- [ ] **Step 3: Commit any fmt fixups** + +```bash +git add crates Cargo.toml Cargo.lock && git commit -m "Gate fixups" || echo "nothing to commit" +``` + +--- + +## Risks & notes + +| Risk / note | Handling | +| --------------------------------------------------------------- | -------------------------------------------------------------------------------------------------- | +| `to_error_response` signature differs from the Task 3 sketch | Confirm against `route_tests.rs` call sites; the test is illustrative | +| The unseeded message also fires on transient backend errors | By design (spec §3.3/§3.4 option Y) — 503 covers both; message names both paths | +| `get→Option` would let us split unseeded vs transient precisely | **Out of scope** (spec §4.4) — tracked store-convergence follow-up; this plan does not block on it | +| `MemoryConfigStore` constructor | Struct literal `MemoryConfigStore { entries }` — **no `::new`** (confirmed) | diff --git a/docs/superpowers/specs/2026-06-16-edgezero-269-repin-breaking-api-finding.md b/docs/superpowers/specs/2026-06-16-edgezero-269-repin-breaking-api-finding.md new file mode 100644 index 000000000..c9c554128 --- /dev/null +++ b/docs/superpowers/specs/2026-06-16-edgezero-269-repin-breaking-api-finding.md @@ -0,0 +1,681 @@ +# Finding: edgezero #269 repin — breaking-API surface for trusted-server + +- **Date:** 2026-06-16 (build-verified 2026-06-17, §10) +- **Author:** Prakash (HTTP port); Christian owns the CLI port (see §7) +- **Upstream:** [stackpop/edgezero#269](https://github.com/stackpop/edgezero/pull/269) + "EdgeZero CLI Extensions" — head `feature/extensible-cli`, base `main`, + **OPEN / unmerged** as of 2026-06-16. Sibling adaptation precedent: + [stackpop/mocktioneer#110](https://github.com/stackpop/mocktioneer/pull/110) + (design + plan only, docs-only). +- **Companion doc:** extends + [2026-03-19-edgezero-migration-design.md](./2026-03-19-edgezero-migration-design.md) + (the original Fastly→EdgeZero migration, PRs 1–17; PR13 merged, branch now on + PR19 canary cutover). +- **Method:** diffed the exact pin `170b74b` (current) against + `feature/extensible-cli` HEAD (`git diff 170b74b..HEAD`, 257 commits, + +28,969 / −5,754 across 143 files), then cross-referenced every changed + public symbol against trusted-server's actual consumption sites. All + signatures below are quoted verbatim from the two refs; call sites are + `file:line` in this repo. + +--- + +## 0. TL;DR + +1. **trusted-server currently pins edgezero at `170b74b`** (March 2026, "unified + key-value store abstraction #165"). `feature/extensible-cli` is **257 commits + ahead** of that exact commit — and `170b74b` is a clean ancestor, so a repin + to post-#269 swallows the **entire** delta, not just #269's own commits. + +2. **Almost none of #269's headline breaks reach trusted-server.** The original + migration deliberately wrapped edgezero behind trusted-server's own + `platform/` trait layer (`RuntimeServices`, `PlatformConfigStore`, + `PlatformSecretStore`, `PlatformKvStore`, `PlatformHttpClient`). As a result + trusted-server uses **none** of: `run_app`, the `app!` macro, + `RequestContext`, edgezero extractors, typed `AppConfig`, or `[stores.*]` + manifest tables. Every one of those is where #269's breakage lives. + +3. **The actual code-level break is a single method:** + `edgezero_core::body::Body::into_bytes()` now returns `Option` instead + of panicking. (`as_bytes()` changed the same way but has **no** trusted-server + call site — §2.) **Compiler-enumerated** (not rg-guessed): **18 sink bindings + — 8 production + 10 test-only** (§2/§10). Mechanical fix. + +4. **`KvError` going `#[non_exhaustive]` + two new variants does _not_ break us** + — trusted-server only _constructs_ `KvError::Unavailable` and never + exhaustively matches the enum. + +5. **Strategic question for the HTTP port:** #269 matures edgezero's _own_ + first-class multi-store registry, async `ConfigStore`/`SecretStore`, + `Config`/`Secrets`/`Kv` extractors, and typed `AppConfig`. These now overlap + heavily with trusted-server's bespoke `platform/` layer. The HTTP port can be + a **minimal repin** (keep the bespoke layer) or a **convergence** onto + edgezero's surface. See §6. + +6. **The stack already walks edgezero forward** — pins are _not_ frozen at + `170b74b`: PR1–13 = `170b74b`, PR14–18 = `38198f9`, PR19–20 = `ce6bcf7`. The + #269 repin is the next step of a bump the team already does. PR14 is where + trusted-server _starts_ consuming edgezero's high-level surface + (`RequestContext`/`EdgeError`/middleware/router) — yet a real build (§10) + proves even that base breaks on **nothing but `Body`**. See §11. + +7. **Plan (agreed):** do the upgrade on a **dedicated branch off PR14** (not on + main, not in-place on any reviewed PR), then **merge up** the stack; **re-pin + to edgezero `main` after #269 merges**. Full-adaptation roadmap (store + convergence + typed config + entry-point) is a _separate, optional_ track — + **not** forced by the repin (§11). + +8. **Superseded for Fastly by `feature/ts-cli-next` (§12).** Christian's "CLI" + branch already implements the end-to-end Fastly config-store migration — same + #269 pin, the `Body` fixes (graceful `ok_or_else`, not `.expect()`), the store + ids, the `config_payload` flatten/hash contract, **and runtime + Settings-from-store load** (`get_settings_from_services`). So our minimal-repin + (#771) is largely redundant for Fastly. **Revised: build on his branch**; our + real HTTP-layer deliverable is the **runtime-config-store spec** his CLI doc + references but never wrote. See §12. + +--- + +## 1. What trusted-server actually consumes from edgezero + +Verified by `rg` across `crates/`. The dependency is a **thin, low-level slice** +of `edgezero-core` plus one type from `edgezero-adapter-fastly`. + +| edgezero symbol | trusted-server usage | Reaches #269 break? | +| ---------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------- | +| `edgezero_core::body::Body` (alias `EdgeBody`) | pervasive — bodies on every request/response, integrations, publisher, auction | **YES** (`into_bytes` → `Option`; `as_bytes` changed too but has **no** TS sink) | +| `edgezero_core::http::{Request, Response, request_builder, response_builder, HeaderValue, …}` | request/response construction in `platform.rs`, `proxy.rs`, `http_util.rs`, tests | No (stable; alias names unchanged) | +| `edgezero_core::key_value_store::{KvStore (as `PlatformKvStore`), KvError, KvHandle, KvPage, NoopKvStore}` | KV trait impls, EC identity graph, `UnavailableKvStore` stub | No (trait sigs unchanged; `KvError` change is inert for us — §3.2) | +| `edgezero_adapter_fastly::key_value_store::FastlyKvStore` | `platform.rs:13` — only symbol used from the fastly adapter | No (`open()` sig unchanged) | +| `edgezero-adapter-axum`, `edgezero-adapter-cloudflare` (workspace deps) | **declared in root `[workspace.dependencies]` only; no member crate references them — `cargo tree -i edgezero-adapter-axum` / `-cloudflare` return "did not match any packages"** | No (absent from the dependency graph — not compiled at all; see §9 Q4 → drop) | +| `edgezero-adapter-spin` | **not a dependency** — `trusted-server-adapter-spin` is an in-repo stub | No (edgezero's Spin SDK6/wasip2 churn never reaches us) | + +**Not used at all** (and therefore immune to #269): `run_app`, `app!`, +`RequestContext`, `FromRequest`/extractors, `EdgeError`, `IntoResponse`, +`ProxyClient`/edgezero `proxy`, typed `AppConfig`, manifest `[stores.*]` / +`[adapters.*]` tables. trusted-server's manifest is `trusted-server.toml` (a +bespoke `Settings` struct in `settings.rs`), **not** an edgezero `edgezero.toml`. +(Baseline as of the current pin / pre-`ts-cli-next`. Christian's branch adds an +`edgezero.toml` and deletes `trusted-server.toml` — but still reads config through +the **bespoke `PlatformConfigStore`**, not edgezero's first-class store/extractor, +so this "uses none of …" list stays true even there. See §12.) + +--- + +## 2. The one break that reaches trusted-server: `Body` → `Option` + +`crates/edgezero-core/src/body.rs`. The `Body` enum shape is **unchanged** — +`Body::Once(Bytes)` / `Body::Stream(LocalBoxStream<…>)` — so trusted-server's +pattern matches in `platform.rs` survive. Two accessor return types changed: + +| Item | BASE (`170b74b`) | HEAD (`feature/extensible-cli`) | Break | +| ------------------ | -------------------------------------------------------------------- | ---------------------------------------------------------------------------- | ----------- | +| `Body::as_bytes` | `pub fn as_bytes(&self) -> &[u8]` (panics on `Stream`) `body.rs:48` | `pub fn as_bytes(&self) -> Option<&[u8]>` (`None` on `Stream`) `body.rs:24` | return type | +| `Body::into_bytes` | `pub fn into_bytes(self) -> Bytes` (panics on `Stream`) `body.rs:55` | `pub fn into_bytes(self) -> Option` (`None` on `Stream`) `body.rs:62` | return type | + +Everything else on `Body` is intact: `empty()`, `from_bytes()`, `from_stream()`, +`into_stream()`, `is_stream()`, `text()`, `json()`, `to_json()`, and `stream()` +(the earlier subagent claim that `stream()` was removed is **wrong** — it moved +in source order only). `From` impls unchanged. + +**Behavior** (not just signatures) verified for the accessor neighbours: `Body::to_json` +is byte-identical BASE↔HEAD and matches on the `Once`/`Stream` enum directly — +it never calls `as_bytes`, so the `Option`-return change cannot leak into JSON +deserialization. `text()`/`json()`/`to_json()` are unused in trusted-server +regardless. + +### Affected call sites — compiler-enumerated (authoritative) + +> **Source of truth = the compiler, not `rg`.** An earlier hand-built `rg` list +> was wrong (missed production sinks `proxy.rs:38` and `auction/endpoints.rs:81`; +> mis-tagged tests as production). The list below is the exhaustive set from +> `cargo build --workspace --all-targets` on the repinned spike (§10): **27 +> compiler errors collapsing to 18 distinct `into_bytes()` sink bindings.** All +> are `Body` (`EdgeBody`); **no `as_bytes` site exists** in trusted-server. + +Line numbers are **PR14-base**; they shift per branch as the stack rewrites these +files — re-derive from the compiler on whatever branch you repin (the §8 gate does +this). One binding often produces several errors (`.len()`, `.to_vec()`, +`from_slice(&…)`, `from_utf8(&…)` on the now-`Option`). + +**Production (8) — fail plain `cargo build` (lib + bin):** + +| Binding site (PR14) | Shape | +| ------------------------------------ | ------------------------------------------------------------------- | +| `proxy.rs:38` (`body_as_reader`) | `Cursor::new(body.into_bytes())` | +| `publisher.rs:46` (`body_as_reader`) | `Cursor::new(body.into_bytes())` | +| `auction/endpoints.rs:81` | `let b = body.into_bytes(); b.len(); from_slice(&b)` | +| `proxy.rs:1550` | `let b = req.into_body().into_bytes(); enforce(&b); from_utf8(&b)` | +| `proxy.rs:1665` | same shape (rebuild path) | +| `request_signing/endpoints.rs:103` | `let b = req.into_body().into_bytes(); enforce(&b); from_slice(&b)` | +| `request_signing/endpoints.rs:246` | same (rotate; also `b.is_empty()`) | +| `request_signing/endpoints.rs:365` | same (deactivate) | + +**Test-only (10) — fail only `cargo test` / `--all-targets`, invisible to plain +`cargo build`:** + +`auction/formats.rs:444`, `integrations/prebid.rs:2067`, +`integrations/testlight.rs:461`, `proxy.rs:2034`, `proxy.rs:2795`, +`proxy.rs:2851`, `publisher.rs:748`, `publisher.rs:1079`, `publisher.rs:1562`, +`request_signing/endpoints.rs:464`. + +**NOT a sink:** `http_util.rs:456` appears in errors as the _expected_ side — it's +the `enforce_max_body_size(bytes: &[u8], …)` signature. No edit; `&Bytes` derefs +to `&[u8]` once the caller unwraps. + +**False positives — leave untouched** (receiver is `str`/`String`/`FromUtf8Error`, +not `Body`; confirmed by source): +`http_util.rs:286,320` (`str::as_bytes`), `request_signing/endpoints.rs:23` +(`String::into_bytes`), `request_signing/endpoints.rs:452` (test, `str::as_bytes`), +`sourcepoint.rs:571` / `datadome.rs:323` (`rewrite_script_content() -> String`), +`sourcepoint.rs:822` (`FromUtf8Error::into_bytes`). + +### Fix — style (updated per `feature/ts-cli-next`) + +> **Revised guidance.** Christian's branch already fixed these sinks and chose +> **`into_bytes().ok_or_else(|| )?`** (graceful error, no panic) for +> production request/response handlers, `unwrap_or_default()` for +> compression/test paths, and reserved `.expect()` for genuinely-unreachable +> spots. That is **better than a blanket `.expect()`** — a streaming/empty body +> must not panic the worker. **Adopt his approach:** propagate an error at +> production handler sinks; only `.expect("should …")` where a buffered body is +> truly invariant (and never `unwrap_or_default()` where an empty body would +> silently corrupt behavior). Align with his exact per-sink choices when we +> converge (§12). + +For a production handler sink, prefer (error variant illustrative — match the +existing one at each call site, not necessarily `BadRequest`): + +```rust +let bytes = req.into_body().into_bytes().ok_or_else(|| { + Report::new(TrustedServerError::BadRequest { message: "request body should be buffered".into() }) +})?; +``` + +The three mechanical shapes below still apply (substitute `ok_or_else(…)?` for +`.expect(…)` at production sinks): + +```rust +// Shape A — value consumed directly (e.g. proxy.rs:38, publisher.rs:46) +let body = resp.into_body().into_bytes() + .expect("should have a buffered body"); + +// Shape B — chained .to_vec() (e.g. prebid.rs:2067, proxy.rs:2034) +String::from_utf8( + resp.into_body().into_bytes() + .expect("should have a buffered body") + .to_vec(), +) + +// Shape C — bound, then borrowed into &[u8] / &Bytes (e.g. proxy.rs:1550, +// auction/endpoints.rs:81, request_signing/endpoints.rs:*) +let b = req.into_body().into_bytes() + .expect("should have a buffered request body"); +enforce_max_body_size(&b, …)?; // &Bytes → &[u8] +serde_json::from_slice(&b)?; // borrow the unwrapped Bytes +``` + +No signature changes propagate to callers (the locals are consumed in place). + +--- + +## 3. Low-level surface that changed upstream but is INERT for trusted-server + +These changed across the 257-commit delta but do **not** break our build, +verified against actual usage. Documented so the repin reviewer doesn't chase +ghosts. + +### 3.1 `KvStore` trait — reordered, not re-signed + +`key_value_store.rs`. All methods keep identical signatures +(`get_bytes`/`put_bytes`/`put_bytes_with_ttl`/`delete`/`list_keys_page`/`exists`, +all `async`, same params/returns). Only source ordering changed (clippy +`arbitrary_source_item_ordering`). trusted-server's `UnavailableKvStore` / +`NoopKvStore` impls and `KvIdentityGraph` calls are unaffected. + +### 3.2 `KvError` — `#[non_exhaustive]` + new variants (inert here) + +| | BASE | HEAD | +| -------- | ------------------------------------ | --------------------------------------------------------------------------- | +| attr | (none) | `#[non_exhaustive]` `key_value_store.rs:302` | +| variants | `NotFound { key }`, `Unavailable`, … | adds `LimitExceeded { message }` `:311`, `Unsupported { operation }` `:328` | + +Inert for us: trusted-server only **constructs** `KvError::Unavailable` (a unit +variant — still constructible downstream under enum-level `#[non_exhaustive]`) in +`platform/kv.rs`, and never writes an exhaustive `match` on `KvError`. No catch-arm +needed. + +### 3.3 `KvPage`, `KvHandle`, `NoopKvStore`, `FastlyKvStore::open`, http builders + +All present and signature-stable: + +- `KvPage` — same fields (`keys`, `cursor`/etc.), alphabetized only. +- `KvHandle` `key_value_store.rs:354`, `NoopKvStore` `:818` — exist. +- `edgezero_core::http::request_builder()` / `response_builder()` — same + signatures; `RequestBuilder`/`ResponseBuilder` are still exported alias names + (now aliasing `HttpRequestBuilder`/`HttpResponseBuilder` internally, transparent + to us); `Request`/`Response`/`Method`/`StatusCode`/`HeaderValue`/… aliases + unchanged. +- `edgezero_adapter_fastly::key_value_store::FastlyKvStore::open(name: &str) -> +Result` — unchanged. + +--- + +## 4. Full #269 breaking-API catalog (does NOT reach trusted-server today) + +Recorded for completeness — these are the framework-level breaks that bite +_consumers who use edgezero's high-level surface_ (e.g. mocktioneer). They matter +to us only **if** the HTTP port chooses convergence (§6) or when Christian's CLI +port (§7) adopts typed config. Grouped by subsystem. + +### 4.1 Adapter entrypoints — `run_app` dropped the manifest arg + +| Adapter | BASE | HEAD | +| ---------- | -------------------------------------------------------------------- | -------------------------------------------------- | +| axum | `pub fn run_app(manifest_src: &str) -> anyhow::Result<()>` | `pub fn run_app() -> anyhow::Result<()>` | +| cloudflare | `run_app(manifest_src: &str, req, env, ctx)` | `run_app(req, env, ctx)` | +| fastly | `run_app(manifest_src: &str, req)` | `run_app(req)` | + +Manifest/store config now flows from `A::stores()` (macro-baked) + `EDGEZERO__*` +env vars instead of an `include_str!` manifest string. **Not used by us** (we have +a manual `fn main()` event loop, not `run_app`). + +### 4.2 `Hooks::stores()` + `StoresMetadata` + +New `fn stores() -> StoresMetadata` on the `Hooks` trait (default impl returns +empty). The `app!` macro auto-emits it from `[stores.*]`. **Not used by us** (no +`app!`, no `Hooks` impl). + +### 4.3 Manifest `[stores.*]` hard rewrite + +Old per-adapter shape is now a **hard load error**: + +```toml +# BASE (now rejected) +[stores.kv] +name = "MY_KV" +[stores.kv.adapters.cloudflare] +name = "CF_BINDING" + +# HEAD (portable; names move to env) +[stores.kv] +ids = ["default"] +default = "default" +``` + +`[adapters..stores.*]` and unknown `[adapters..*]` subtables now +fail `manifest.validate()`. `Manifest::kv_store_name()` removed → runtime +`EnvConfig::store_name(kind, id)` (`EDGEZERO__STORES______NAME`). +**Not used by us** (no edgezero manifest). + +### 4.4 Multi-store registry + async `ConfigStore`/`SecretStore` + +New `store_registry.rs`: `StoreRegistry` with `default()`/`named(id)`, aliases +`KvRegistry`/`ConfigRegistry`/`SecretRegistry`, and `BoundSecretStore` (binds +platform store name per logical id). New async read traits +`ConfigStore::get(&self, key) -> Result, ConfigStoreError>` and +`SecretStore::get_bytes(&self, store_name, key) -> Result, …>`. +**Overlaps directly with our `PlatformConfigStore`/`PlatformSecretStore`** — see +§6. Not consumed today. + +### 4.5 `RequestContext` store accessors + +`kv_handle()` removed; replaced by `kv_store(id)`/`kv_store_default()` (+ config +& secret variants) returning `Option`. **Not used by us** (no +`RequestContext`). + +### 4.6 Extractor overhaul + +`Kv(KvHandle)` → `Kv(KvRegistry)` with `.default()`/`.named(id)`; new +`Config`/`Secrets` extractors. **Not used by us.** + +### 4.7 `#[derive(AppConfig)]` + `#[secret]` (entirely new) + +New derive macro (`edgezero-macros/src/app_config.rs`) emitting `AppConfigMeta` +with `SECRET_FIELDS`. `#[secret]` / `#[secret(store_ref)]` only on scalar +`String` fields; rejects `Option`, `Cow`, non-scalars, `serde(rename)`, +container `rename_all`, duplicate/`=`/unknown-arg forms (compile-fail UI tests). +Pairs with CLI `config validate`/`config push`. **This is Christian's CLI-port +territory** (§7); net-new adoption, not a break. + +### 4.8 `EdgeError` / `IntoResponse` / `ProxyResponse` + +`EdgeError` now `#[non_exhaustive]`, gains `NotImplemented`, `source()`→`inner()`. +`IntoResponse::into_response` now returns `Result`. +`ProxyResponse::into_response` returns `Result`. **None used by us** (we use +`error_stack::Report` and never touch `EdgeError`/edgezero +`IntoResponse`/edgezero `proxy`). + +### 4.9 CLI surface (new) — inventory only + +New `edgezero-cli` commands: `auth` (login/logout/status), `provision`, +`config validate`, `config push`, `demo` (replaces `dev`). Generated `-cli` +crate per app. Typed entrypoints a consumer crate calls: +`run_config_validate_typed::()`, `run_config_push_typed::()`, plus +`run_{auth,build,deploy,provision,serve}`. **Christian's port** (§7). + +### 4.10 Spin adapter — SDK 6.0 / wasip2 + +edgezero's Spin adapter moved to `spin-sdk ~6.0`, `wasm32-wasip1`→`wasip2`, +`#[http_component]`→`#[http_service]`, `IncomingRequest`→`Request`, async stores. +**Does not reach us** — trusted-server does not depend on `edgezero-adapter-spin`; +our `trusted-server-adapter-spin` is an in-repo stub. + +--- + +## 5. Net repin work for the HTTP port (minimal path) + +If the HTTP port is a **straight repin** (keep the bespoke `platform/` layer): + +1. Bump the four `edgezero-*` git deps in root `Cargo.toml` from `rev = "170b74b"` + to the #269 branch (then to `main` post-merge), regenerate root `Cargo.lock`. +2. **Reconcile `crates/integration-tests/Cargo.lock`** (it has its own lock; so + does `crates/openrtb-codegen/`). CI enforces that shared direct deps match + between the root and integration-tests lockfiles, and the 257-commit edgezero + delta will drag shared transitive deps (bytes/http/serde/…). Fix with targeted + `cargo update -p --precise ` in the integration-tests workspace — + **never** a blanket `cargo update`. +3. Fix the **18 `Body::into_bytes()` sink bindings** (§2) — 8 production + 10 + test — with explicit `.expect("should …")` / `None` handling. +4. Run the full gate (§8): host + Fastly `wasm32-wasip1`, **`--all-targets`**, + clippy `-D warnings`, `cargo test --workspace`, `cargo fmt --check`. + +**Status:** compilation is now **verified** (host, lib + tests — §10): the forced +code delta is the `Body` sinks and nothing else. **Still unverified:** +`wasm32-wasip1`, clippy, full test pass, and lockfile reconciliation (step 2). +Source-API diffing cannot see transitive breaks (dep bumps, MSRV, feature +unification, `spin-sdk ~6.0` lock entries); the §8 matrix is the proof, not this +document. "Does not reach us" (§4.10) is true at _source_ level but does not by +itself prove the lock graph resolves or that wasm builds. + +### 5.1 Risks & assumptions + +| Risk / assumption | Mitigation | +| ------------------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Pinning to an **OPEN, unmerged, force-pushable** ref (`feature/extensible-cli`) | Pin to the branch only if we must move pre-merge; re-pin to edgezero `main` after #269 merges (mirrors mocktioneer #110). | +| **Transitive build breakage** unseen by source diffing (lock re-resolution, MSRV, features, spin-sdk 6) | Verification gate above — actually build on a scratch branch before sign-off. | +| Branch rebased / dep destabilizes after we pin | **Rollback = single-commit revert.** `170b74b` stays recoverable; the repin is one `Cargo.toml`/`Cargo.lock` commit — `git revert` it to return to the known-good pin. | +| Assumption: all 18 `Body` sinks are buffered (`Once`) bodies | True today (compiler-confirmed receivers); if a future sink is genuinely streaming, use `into_stream()` / branch on `None` instead of `.expect()`. | +| **integration-tests lockfile drift** — CI fails if shared direct deps diverge between root and `crates/integration-tests/Cargo.lock` | Reconcile with targeted `cargo update -p --precise` (§5 step 2); never blanket-update. | +| **Test-only sinks slip through** a `cargo build`-only check | Gate runs `--all-targets` + `cargo test` (§8) — 10 of 18 sinks are test-only (§10). | + +--- + +## 6. Strategic divergence (decision for the HTTP port) + +The original migration built a trusted-server-owned abstraction: +`RuntimeServices { config_store, secret_store, kv_store, backend, http_client, +geo, client_info }` with `PlatformConfigStore`/`PlatformSecretStore`(full CRUD)/ +`PlatformKvStore`/`PlatformHttpClient` traits. #269 ships edgezero's _own_ +first-class equivalents: async `ConfigStore`/`SecretStore` read traits, the +multi-store `StoreRegistry`, `Config`/`Secrets`/`Kv` extractors, env-var store +binding, and typed `AppConfig`. + +So two parallel abstractions now exist for the same job. The original design doc +already flagged this risk (PR2: "these must not coexist as parallel abstractions +… file an EdgeZero issue to generalize `ProxyClient` into `HttpClient`"). #269 is +edgezero answering that — on the store/config axis. + +| Option | What it means | Cost | Pull | +| ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | -------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **A. Minimal repin** | Keep `platform/` layer; only fix `Body`. | 18 sinks | Fastest; preserves CRUD writes (edgezero stores are read-only) and the `select()` fan-out we depend on. | +| **B. Converge stores** | Map `PlatformConfigStore`/`SecretStore` reads onto edgezero's `ConfigStore`/`SecretStore` + `StoreRegistry`; keep our write-CRUD as an extension. | medium | Aligns with framework direction; less bespoke code. But edgezero read traits don't cover our management writes (key rotation), so the layer can't fully dissolve. | +| **C. Full adoption** | Also take `run_app`/`app!`/`RequestContext`/extractors/typed `AppConfig`. | large | Matches mocktioneer; big rewrite of our manual dispatch + `Settings`. | + +**Recommendation:** ship **A** as the repin (unblocks everything, tiny diff), +then evaluate **B** as a separate follow-up once #269 lands on `main`. **C** is a +roadmap question, not a repin question — and it's where the HTTP/CLI split +actually pays off: typed `AppConfig` (C/§4.7) is Christian's CLI surface, and our +read-store convergence (B) is the HTTP surface. The shared contract between the +two of you is **the typed config struct + its `[stores.config]` declaration** — +agree its shape and store id before either port starts. + +--- + +## 7. HTTP-port vs CLI-port split + +| | HTTP port (Prakash) | CLI port (Christian) | +| ----------------- | ------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------- | +| #269 axis | runtime / stores / adapters (§4.1–4.6, 4.8, 4.10) | CLI + typed config (§4.7, 4.9) | +| repin-forced work | `Body` fix (§2) | none (trusted-server has no edgezero CLI today) | +| net-new adoption | optional store convergence (§6 B) | `-cli` crate, `auth`/`provision`/`config validate`/`config push`, `#[derive(AppConfig)]`, CI `config validate --strict` gate | +| shared seam | **reads** the typed config from the bound store | **defines/validates/pushes** the typed config | + +Note: because trusted-server uses a bespoke `Settings` + `trusted-server.toml` +(not edgezero config), Christian's CLI port is largely a **net-new adoption** +(mirroring mocktioneer #110 §3.5–3.9), not a break-fix. Sequence the typed-config +struct contract first; both ports depend on it. + +--- + +## 8. Verification commands + +```bash +# reproduce the upstream diff base (full clone; 170b74b is not in a shallow fetch) +cd /tmp && rm -rf ez && git clone https://github.com/stackpop/edgezero ez && cd ez +git fetch origin feature/extensible-cli +git diff 170b74b..origin/feature/extensible-cli -- crates/edgezero-core/src/body.rs + +# after repin, in this repo — the full gate: +cargo build --workspace --all-targets # CRITICAL: --all-targets, else 10 test-only sinks hide +cargo build --package trusted-server-adapter-fastly --target wasm32-wasip1 +cargo test --workspace +cargo clippy --workspace --all-targets --all-features -- -D warnings +cargo fmt --all -- --check + +# integration-tests lockfile gate (separate workspace lock): +( cd crates/integration-tests && cargo build --workspace ) # must resolve against root deps +``` + +Expected pre-fix failures: type errors at the 18 `Body` sinks (§2) — `Option` +has no `.len()`/`.to_vec()`, can't pass where `Bytes`/`&[u8]` expected. **8 surface +under plain `cargo build`; the other 10 only under `--all-targets`/`cargo test`.** + +Compilation green is **verified** (§10). The remaining legs — wasm, clippy, full +test, lockfile reconciliation — are the proof of "done"; transitive lock/MSRV/ +feature breaks surface only here, not in source-API diffing. **Re-run this gate on +every branch as the pin advances** (§11), since the sink set and line numbers +shift per layer. + +--- + +## 9. Decisions & open questions + +**Decided (this review cycle):** + +- **Repin target:** pin the upgrade branch to `feature/extensible-cli` (or its + HEAD sha) to start now; **re-pin to edgezero `main` after #269 merges.** +- **Where:** dedicated branch **off PR14** — _not_ main, _not_ in-place on any + reviewed PR — then **merge up** the stack (merge, not rebase). See §11. +- **Scope:** the _forced_ repin work is minimal (the 18 `Body` sinks, §2/§10). The + full A+B+C adaptation (store convergence onto edgezero's `ConfigStore`/ + `SecretStore`/`StoreRegistry`; two-tier typed `AppConfig`; entry-point + convergence) is a **separate, optional roadmap** — see §6 and the companion + full-adaptation design — and is **not** required by the repin. +- **Platform layer:** _hybrid_ — converge stores onto edgezero (+ a thin + write-CRUD extension for rotation), keep `PlatformHttpClient`/`PlatformBackend`/ + `Geo`/`ClientInfo` (edgezero gaps). Roadmap, not repin. +- **`edgezero-adapter-axum`/`cloudflare`:** drop — absent from the dependency + graph (`cargo tree -i` matches no package, §1); not compiled. + +**Still open:** + +1. **Convergence with `feature/ts-cli-next` (§12).** Christian's branch already + implements the end-to-end Fastly config-store migration (repin + Body fix + + runtime Settings-from-store), so our minimal-repin (#771) is largely subsumed + for Fastly. Decide: rebase our HTTP work onto his config system vs keep the + PR14-stack repin. **Recommend: build on his.** +2. **Body-fix style conflict** — his `ok_or_else` (graceful) vs our former + `.expect()`. Resolved in §2 (adopt his); confirm per-sink alignment on merge. +3. **Secret-write conflict** — he punts secret-store writes (key rotation) until + edgezero exposes write primitives; our original migration design kept + write-CRUD in TS. Decide which holds. +4. **Shared config contract — now CONCRETE, not "to agree":** store ids + `app_config` / `secrets` / `ec_identity_store` (his `edgezero.toml`) and the + `config_payload` flatten/hash rules (his core module). The remaining gap is + the **runtime-config-store spec** his doc references but never wrote — that is + our HTTP-layer deliverable (§12). +5. wasm32-wasip1 + clippy + test legs of the verification gate (§10 covered host + build only). + +--- + +## 10. Verified build result (spike) + +The §5 "prediction, not a result" hedge is **discharged for compilation** (host). +A throwaway branch `spike/edgezero-269-upgrade` was cut **off PR14** (base pin +`38198f9`), repinned to #269 HEAD (`2eeccc9`), and built twice: + +``` +cargo build --workspace → exit 101, 15 errors (lib + bin only) +cargo build --workspace --all-targets → exit 101, 27 errors (adds tests) +``` + +**Every error is downstream of `Body::into_bytes` → `Option`, all in +`trusted-server-core`. Zero errors from `RequestContext`, `EdgeError`, middleware, +router, or any other #269 churn — even though PR14 imports all of those.** This +empirically confirms the central thesis: the repin's forced code change is the +`Body` break and nothing else. + +The two runs differ by design and this gap is the lesson: + +- **Plain `cargo build`** compiles lib + bin → the **8 production** sinks (§2). +- **`--all-targets`** also compiles tests → **+10 test-only** sinks. These are + **invisible to plain `cargo build`** and only fail under `cargo test` / + `--all-targets`. A repin that greens `cargo build` but skips `--all-targets` + would ship a red test suite. **The gate (§8) must include `--all-targets`.** + +27 raw errors collapse to **18 distinct `into_bytes()` bindings** (one binding → +several errors via `.len()`/`.to_vec()`/`from_slice(&…)`/`from_utf8(&…)`). Full +enumeration with the production/test split is §2 — that list is now the +compiler's, superseding the earlier rg attempt (which missed `proxy.rs:38` and +`auction/endpoints.rs:81`). + +**Not yet run** (remaining gate legs): `wasm32-wasip1` build, `cargo clippy -D +warnings`, `cargo test --workspace`, and the integration-tests lockfile +reconciliation (§5.1). Compilation-green ≠ gate-green. + +> Evidence branch kept (repin only; trial code edits reverted) so the failing +> build is reproducible. No real branch was modified. + +--- + +## 11. Impact on the in-flight stacked migration branches + +The stack is `PR1 → … → PR20`, partially linear / partially diverged +(PR15/PR16/PR19 are not clean descendants of their predecessor — merges +happened). Pins climb in steps: + +| Branches | edgezero pin | date | +| ----------- | ------------ | ------ | +| PR1–13 | `170b74b` | Mar 18 | +| PR14–18 | `38198f9` | Apr 9 | +| PR19–20 | `ce6bcf7` | May 21 | +| (#269 HEAD) | `2eeccc9` | Jun 12 | + +**Key facts:** + +- `Body::into_bytes`→`Option` landed in `7ec2ad1` ("strict clippy #257", **Jun + 12**) — _after every current stack pin_ (latest is May 21). So **no existing + branch has absorbed the `Body` break**; whichever branch first bumps to a + rev ≥ Jun-12 eats all 18 sinks (§2). +- **PR14 is the inflection**: it introduces `run_app`/`RequestContext`/ + `EdgeError`/`middleware`/`router` consumption (PR13 has none). That is the + high-level surface #269 churned — _but the §10 build proves it does not break_. +- main's "only `Body`" story is therefore true **for the whole stack**, not just + main. The earlier worry that PR14+ would drag in context/error/router breakage + is **disproven by build**. + +**Why not repin at the bottom (main / `upgrade-edgezero-http-layer`):** that base +predates the consuming code, so it can't exercise PR14+ at all; the real build +signal only exists from PR14 up. + +**Why a dedicated branch off PR14, not in-place on PR14:** PR14 is still under +review; folding a version bump into it conflates two review concerns and forces +rework when review feedback rewrites the migrated code. A branch _on top of_ PR14 +gets the same build signal without disturbing any open review. + +**Propagation:** land the repin + `Body` fix once on the dedicated branch, then +**merge up** PR14→15→16→17→18→19→20 (merge, not rebase, per team preference). +Conflicts will cluster in the files every layer rewrites — `publisher.rs`, +`proxy.rs`, `request_signing/endpoints.rs`, `auction/endpoints.rs`. Run the §10 +gate (host + wasm + clippy + test) **per branch as the pin advances**, not once. + +--- + +## 12. Convergence with `feature/ts-cli-next` (Christian's branch) + +Inspected 2026-06-18. The "CLI" branch is **not just CLI** — it is an +**end-to-end config-store migration for the Fastly adapter**, off `main`, pinned +to the **same #269 HEAD** (`2eeccc9`) we used. It already carries the repin, the +`Body` fixes, the CLI crate, _and_ runtime Settings-loading from the config store. +This materially changes our plan. + +### 12.1 What it already implements (overlaps our work) + +| Area | His branch | Effect on us | +| --------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------ | +| #269 repin | deps pinned `2eeccc9` | our repin (#771) duplicated for Fastly | +| `Body` sink fixes | `into_bytes().ok_or_else(…)?` (prod), `unwrap_or_default()` (compress/test) | supersedes our `.expect()` style (§2) | +| Store ids | `edgezero.toml`: config `app_config`, secrets `secrets`, kv `ec_identity_store` | the shared seam, concrete | +| Config contract | `trusted-server-core/src/config_payload.rs` — flatten all of `Settings` → entries + `sha256` (`ts-config-hash`/`ts-config-keys`), reversible | shared core module; CLI pushes, runtime reads | +| **Runtime load** | adapter `main.rs`: `build_runtime_services()` → `get_settings_from_services()` reads `app_config`, rebuilds `Settings` | **this is the HTTP-layer pattern, already wired for Fastly** | +| `trusted-server.toml` | **deleted**; replaced by `trusted-server.example.toml` | config now lives in the store, seeded via `ts config push` | + +### 12.2 The HTTP-layer pattern, demonstrated + +`crates/trusted-server-adapter-fastly/src/main.rs` (his branch): + +```rust +let runtime_services = build_runtime_services(&req, kv_store); // config store available first +let settings = match get_settings_from_services(&runtime_services) { … }; // load Settings FROM store +``` + +`get_settings_from_services` → resolves the store name via +`env_config.store_name("config", DEFAULT_CONFIG_STORE_ID)` (`= "app_config"`) → +reads `ts-config-keys` / `ts-config-hash` / each entry → +`settings_from_config_entries` (verifies hash) → `Settings`. **That entry-point +sequence is exactly what "our HTTP layer" was meant to build.** + +**Crucial: he reads through the bespoke `PlatformConfigStore`, not edgezero's +store surface.** `services.config_store()` returns `&dyn PlatformConfigStore` +(impl `FastlyPlatformConfigStore`); he uses **none** of edgezero's #269 +`ConfigStore`/`StoreRegistry`/`Config` extractor/`RequestContext` (grep-confirmed: +zero in his `main.rs`/`settings_data.rs`). So he converged the config **source** +(toml → store) while **keeping `RuntimeServices` + the `platform/` layer** — i.e. +he took our §6 **hybrid** path, not full edgezero adoption. This also keeps §1's +"uses none of …" list accurate on his branch, and validates that our HTTP layer +should bind the store via `PlatformConfigStore`, not edgezero's extractor. + +### 12.3 Runtime contract (new, important) + +A **missing key is a hard error** — there is **no `trusted-server.toml` +fallback** anymore. The settings-error arm in `main.rs` **does serve a response** +(`to_error_response(&e).send_to_client(); return;`) — so an unseeded store yields +a **generic 500 on every route** (not a silent no-response), and that 500 is +**indistinguishable from a real config bug**. Net: the worker **cannot serve real +routes until the store is seeded** (`ts config push`). This is a new +deploy-ordering requirement (seed-before-serve) and an operational risk — the HTTP +layer should make the unseeded case **actionable** (clear message) and **correctly +classified** (retryable 503, not 500). See the plan's Phase 2. + +### 12.4 Conflicts to resolve (see §9) + +1. **Body-fix style** — adopt his `ok_or_else` (done in §2); align per-sink. +2. **Secret writes** — he punts key-rotation secret writes until edgezero adds + write primitives; our original migration design kept write-CRUD in TS. Decide. +3. **Base branch** — his off `main`; ours off the PR14 stack. His already carries + repin+Body, so for Fastly our minimal-repin is redundant. +4. **Whole-`Settings` vs two-tier** — he flattens _all_ of `Settings` into the + store (not our two-tier small `AppConfig`). One source of truth; bigger blast. + +### 12.5 Revised recommendation + +- **Build on his branch, don't run a parallel repin.** Our #771 minimal-repin is + superseded for Fastly; keep it only as the verified breaking-API reference. +- **Our HTTP-layer deliverable = the "runtime-config-store spec" his CLI doc + references but never wrote** — document `get_settings_from_services`, the + flatten/reconstruct rules (shared `config_payload`), the seed-before-serve + contract, empty/malformed-store behavior, and non-Fastly adapter wiring. +- **Keep our compiler-verified `Body` enumeration (§2/§10)** as the authoritative + sink reference when merging his ad-hoc fixes up the stack. diff --git a/docs/superpowers/specs/2026-06-18-edgezero-269-http-layer-runtime-design.md b/docs/superpowers/specs/2026-06-18-edgezero-269-http-layer-runtime-design.md new file mode 100644 index 000000000..eca7b329a --- /dev/null +++ b/docs/superpowers/specs/2026-06-18-edgezero-269-http-layer-runtime-design.md @@ -0,0 +1,307 @@ +# Design: EdgeZero #269 HTTP-Layer Runtime + +- **Date:** 2026-06-18 +- **Author:** Prakash (HTTP-layer / runtime). CLI side: Christian (`feature/ts-cli-next`). +- **Status:** design — base build verified green (see §6). +- **Base:** edgezero `stackpop/edgezero#269` (`feature/extensible-cli`, HEAD `2eeccc9`), + adopted on `main` via `feature/ts-cli-next`. Our work branches off it + (`feature/edgezero-269-http`). +- **Companion docs:** references + [2026-06-16-edgezero-269-repin-breaking-api-finding.md](./2026-06-16-edgezero-269-repin-breaking-api-finding.md) + for the breaking-API / `Body`-sink detail (not duplicated here). Subsumes the + plan's Phase-4 "runtime-config-store spec" — this is that, widened to all + runtime surfaces. + +--- + +## 1. Scope & base assumptions + +This spec governs the **runtime (HTTP-layer) half** of running trusted-server on +edgezero #269. It is the source of truth for how the Fastly adapter boots, loads +configuration, and serves requests under #269. + +**Convergence model (decided):** `main` is the #269 convergence point. +`ts-cli-next` (off `main`) lands first, establishing #269 on `main`; our +HTTP-layer work branches off it and also targets `main`. **The PR14→PR20 +migration stack is explicitly out of scope** — it reconciles to #269 on its own +schedule and is not a dependency of, or dependent on, this work. + +**Inherited from `ts-cli-next`** (already done, verified green — §6): + +- the #269 dependency repin (`2eeccc9`); +- the `Body::into_bytes() → Option` fixes across `trusted-server-core`; +- the **Fastly adapter migration** to the #269 API (the dual-path entry point was + removed — §2.2); +- runtime `Settings`-from-config-store load (`get_settings_from_services`). + +**What we own (add on top):** runtime hardening of the config-store load path +(§4), the non-Fastly adapter build state (§4.2), the secret-write boundary +decision (§4.3), and this spec. + +**Architecture invariant:** trusted-server keeps its **bespoke `platform/` +layer** (`RuntimeServices` + `PlatformConfigStore`/`PlatformSecretStore`/ +`PlatformKvStore`). We do **not** adopt edgezero's first-class +`ConfigStore`/`StoreRegistry`/extractor/`RequestContext`. The #269 convergence is +of the config **source** (TOML → config store), not the abstraction. + +--- + +## 2. Surface inventory + +Every surface #269 touches, with current state and owner. "Inherited" = done on +`ts-cli-next`; "ours" = HTTP-layer work in this spec. + +| Surface | State under #269 | Owner | +| -------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------- | +| `Body::into_bytes()` → `Option` (`trusted-server-core`) | Fixed: `request_body_bytes` helper + graceful `ok_or_else` (prod) / `unwrap_or_default` (test); `body_as_reader` returns `Result`. 18 sinks (finding §2). | inherited | +| **Adapter entry flow** | Dual-path (`into_core_request` + router `oneshot` + middleware) **removed**; converts via `compat::from_fastly_request` → `route_request` (§2.2). | inherited | +| **Config-store load** | `Settings` rebuilt at boot from the `app_config` config store via the `config_payload` flatten/hash contract (§3). `trusted-server.toml` deleted. | inherited; **hardening = ours** (§4.1) | +| `secrets` store | Read via `PlatformSecretStore`; write CRUD (key rotation) via pre-existing `adapter-fastly/src/management_api.rs`. | inherited; **boundary doc = ours** (§4.3) | +| `ec_identity_store` KV | Adapter boots `UnavailableKvStore`; EC routes lazily bind the configured store at dispatch. | inherited; **regression test = ours** | +| **`fastly` 0.11 / 0.12 coexistence** | edgezero #269 pulls `fastly 0.12.1`; trusted-server core+adapter stay on `fastly 0.11.13`. Bridged at `compat::from_fastly_request` (core, 0.11). Both versions resolve in the tree (§2.3). | inherited (constraint) | +| integration-tests lockfile | Separate workspace, path-deps core; shared deps must match root. | verify (§4) | +| CI gates | fmt / clippy `-D warnings` / `cargo test --workspace` / wasm32-wasip1 — all green on base (§6). | verify per change | +| JS (`crates/js`) | Untouched by #269. | n/a | + +### 2.1 Store ids (`edgezero.toml`) + +| Kind | id | Runtime use | +| ------- | ------------------- | ----------------------------- | +| config | `app_config` | `Settings` source (§3) | +| secrets | `secrets` | signing keys; rotation writes | +| kv | `ec_identity_store` | EC identity graph | + +### 2.2 Adapter entry flow (why the dual-path is gone) + +PR14 introduced a dual-path entry: `edgezero_adapter_fastly::into_core_request` +plus an edgezero router `oneshot` and a middleware chain keyed on +`FastlyRequestContext`. Under #269 those symbols are **fastly-0.12-bound**, but +the adapter builds the request with **fastly-0.11** — an unbridgeable version +mismatch. The migrated adapter (inherited) abandons that path: it converts the +Fastly request via `trusted_server_core::compat::from_fastly_request` +(fastly-0.11, in core) and routes through `route_request`, deleting `app.rs` and +`middleware.rs`. **This spec does not revive the dual-path.** + +### 2.3 The `fastly` version split (load-bearing constraint) + +The 0.11/0.12 coexistence is **deliberate and required**, not a smell: +edgezero #269 internally uses `fastly 0.12`; trusted-server stays on `fastly +0.11`. The only safe bridge is `compat::from_fastly_request` (a core, 0.11 +function that produces the platform-neutral `http` request). **Do not** call +edgezero APIs that take/return a fastly-0.12 `Request`/`Response` directly from +adapter code built on 0.11 — that reintroduces the PR14 dead end. All +adapter↔core hand-off goes through `compat` and the bespoke `platform/` types. + +--- + +## 3. Runtime config-store load (core design) + +### 3.1 Load sequence + +At adapter boot (`crates/trusted-server-adapter-fastly/src/main.rs`): + +``` +build_runtime_services(&req, kv_store) // config store available first + → get_settings_from_services(&services) // settings_data.rs + → get_settings_from_config_store(services.config_store(), &store_name) + → read_config_entry(CONFIG_KEYS_KEY) // "ts-config-keys" + → read_config_entry(CONFIG_HASH_KEY) // "ts-config-hash" + → read_config_entry() + → settings_from_config_entries(entries) // hash verify + reconstruct +``` + +`store_name` resolves via `EnvConfig::store_name("config", DEFAULT_CONFIG_STORE_ID)` +where `DEFAULT_CONFIG_STORE_ID = "app_config"`, overridable by +`EDGEZERO__STORES__CONFIG__APP_CONFIG__NAME`. + +**Store dependency ordering (resolves the apparent §2 tension):** the **config +store is required at boot** — if `app_config` is unseeded, settings load fails and +_no_ route serves (§3.3). The **`ec_identity_store` KV is optional/lazy** — the +adapter boots `UnavailableKvStore` and EC routes bind it at dispatch, so EC-KV +being unavailable degrades only EC routes while everything else serves. The §2 +"non-EC routes still serve" resilience therefore holds **only after** config load +has succeeded. + +### 3.2 The `config_payload` contract (shared seam — reference only) + +`trusted-server-core/src/config_payload.rs` is the **single bidirectional +contract**: the CLI flattens `Settings` → config-store entries; the runtime +reconstructs from the same module. Do **not** fork it. Properties (per the CLI +design): escaped dotted keys (`\` → `\\`, `.` → `\.`); leaf values as canonical +JSON; `ts-config-keys` (sorted key array) + `ts-config-hash` (`sha256` over +settings-only entries) metadata; `ts-config-*` reserved. + +### 3.3 Behavior matrix (the contract this spec locks) + +**The boundary is "couldn't load the config" vs "loaded it but it's corrupt"** — +because `PlatformConfigStore::get` collapses key-absent and transport failure into +the same `PlatformError::ConfigStore` (`platform.rs:50-66`), the runtime cannot +cheaply tell "unseeded" from "transient backend" today (see §4.4 for the long-term +fix). So we classify by **where** the failure occurs, not by trying to subdivide a +read error: + +| Situation | Current (inherited) | Target (ours) | +| ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| **Config-store read failure** — `read_config_entry` returns `Err` for any reason: store unseeded (`ts-config-keys` absent), transient backend hiccup, or a listed key missing | every case → generic **500**; indistinguishable from a real bug | **503** (`SERVICE_UNAVAILABLE`) via one new `TrustedServerError` variant, actionable message `"config store unavailable or not seeded — run \`ts config push\`"`. 503 is correct: unseeded → seed it; transient → retry. | +| **Reconstruct / verify failure** — config read OK but `settings_from_config_entries` fails (hash mismatch, unparseable value) | `Configuration`/`Settings` → 500 | **500** (genuine corruption / bug) — unchanged | +| Seeded + valid | `Settings` loads | unchanged | + +The 503/500 split is exactly the read-vs-reconstruct boundary in +`get_settings_from_config_store` → `read_config_entry` (read; → 503) vs +`settings_from_config_entries` (reconstruct; → 500). No `PlatformError` change +needed. + +### 3.4 Seed-before-serve (operational contract) + +Because `Settings` lives only in the store (no `trusted-server.toml` fallback), +**the service cannot serve real routes until `ts config push` seeds +`app_config`.** The runtime makes the unseeded state **observable** (503 status + +an actionable message in the **server logs** — §3.3), but observability is not +availability — the store **must** be seeded for the service to function. + +**This bites existing production, not just fresh installs.** `trusted-server.toml` +is deleted, so the moment the #269 wasm goes live on a service whose `app_config` +store is empty, **every route 503s** — an instant outage, not an edge case. + +**Cutover migration (one-time, ordered):** + +1. Export the currently-live `Settings` to a config payload (the CLI's + `config push` flattens `Settings` via `config_payload` — §3.2). +2. **Seed `app_config` first** (`ts config push --adapter fastly`), verify the + store holds `ts-config-keys`/`ts-config-hash` + entries. +3. **Then** deploy the #269 wasm. + +Never deploy the wasm before the store is seeded. This ordering is the contract; +§5 carries it as the top risk + the rollback. + +**503 covers two cases; the actionable text goes to LOGS, not the client body.** +Under option Y (§3.3) a 503 means "couldn't load config": either **unseeded** +(operator must `ts config push`) or a **transient backend** error (genuinely +retryable). The actionable message — `config store \`{store}\` unavailable or not +seeded (failed to read \`{key}\`) — run \`ts config push\`` — is carried on the +error (`ConfigStoreUnavailable`'s `Display`) and surfaced in the **server log** +(`main.rs` logs the full error chain). The **client 503 body stays generic** +(`user_message()`'s catch-all, "An internal error occurred") **by design** — per +the security rule, don't leak internal tooling/paths to public clients; detail +lives server-side. So: operator-actionable in logs, safe-generic to the client. + +--- + +## 4. HTTP-layer work we add + +### 4.1 Config-store load hardening (the core deliverable) + +Implement §3.3's target column, TDD-first, in `settings_data.rs` + `error.rs`. +Reuse the existing `MemoryConfigStore` test fake. Test at **both layers** — +neither alone proves the contract: + +- **core** (`settings_data.rs` / `error.rs`): a config-store **read failure** → + the new variant, and the variant's `status_code()` == **503**; a + malformed-hash (reconstruct failure) → stays **500**. +- **adapter** (`trusted-server-adapter-fastly`): the read-failure error actually + reaches the client as **503** via `to_error_response` (end-to-end check that + the variant→status mapping isn't bypassed). + +**Mechanism — one new variant, no platform-layer change (option Y).** A +`change_context`/`attach` does **not** alter `status_code()`, and +`PlatformConfigStore::get` cannot distinguish key-absent from transport error +(both `PlatformError::ConfigStore` — §4.4). So classify by **call site**: + +1. Add `TrustedServerError::ConfigStoreUnavailable` (or similar), mapped to + `StatusCode::SERVICE_UNAVAILABLE` in `error.rs` — precedent: the `KvStore` arm + already maps to 503 (`error.rs:125`). +2. In `get_settings_from_config_store` / `read_config_entry`, `change_context` + **read failures** (the `config_store.get(...)` path — `ts-config-keys`, + `ts-config-hash`, each entry) to `ConfigStoreUnavailable` (→ 503) with the + actionable message. +3. Leave `settings_from_config_entries` failures (hash mismatch, unparseable) + as `Configuration`/`Settings` (→ 500). That is the only change — the + read-vs-reconstruct boundary does the classification. + +No new `PlatformError` variant, no change to `PlatformConfigStore` or any of its +impls. (Detailed steps live in the implementation plan, not here.) + +**Do NOT add a `user_message()` arm for this variant.** The actionable text must +reach **logs only**; the public client body stays generic via `user_message()`'s +catch-all (§3.4, security). The `ConfigStoreUnavailable` `Display` (carrying the +"run `ts config push`" message) flows to the log via `main.rs`'s error-chain dump +— that is the operator's channel. Adding a `user_message()` arm would leak +internal tooling into public 503 bodies. + +### 4.4 Long-term: `PlatformConfigStore::get → Result>` (tracked follow-up — NOT this work) + +The reason §4.1 can't cleanly separate "unseeded" from "transient backend" is a +**pre-existing trait-shape smell**: `PlatformConfigStore::get` (defined in PR2 / +#545, on `main` — not `ts-cli-next`) returns `Result` and folds +key-absent into `Err(PlatformError::ConfigStore)`, same as a transport failure. + +The durable fix is to make absence a **value**, not an error — +`get(...) -> Result, Report>` (`Ok(None)` = absent, +`Err` = real failure). This is **exactly edgezero #269's own `ConfigStore::get` +shape** (`Result, ConfigStoreError>`), so it is also the +**store-convergence** direction (finding §6 B). With it, unseeded (`Ok(None)` on +`ts-config-keys`) becomes distinguishable from transient (`Err`) for free, and +§4.1's option Y sharpens into the precise option X **without rework**. + +**Out of scope here** (it's a pre-existing trait `ts-cli-next` only consumes, and +touches every `PlatformConfigStore` impl). Track it on the store-convergence +follow-up; optionally surface it as a non-blocking comment on the `ts-cli-next` +PR (its config-load path is the consumer that exposes the limitation). + +### 4.2 Non-Fastly adapters + +`trusted-server-adapter-{cloudflare,spin}` are stubs (cloudflare/axum are absent +from the dependency graph). Goal: they **compile** under #269 — not feature +parity. Confirm what "builds" means per stub (`cargo check -p …` on host vs a +real wasm target) before acting. + +### 4.3 Secret-write boundary (decision to record) + +Runtime key-rotation secret **writes** already work via the pre-existing +`crates/trusted-server-adapter-fastly/src/management_api.rs` +(`FastlyPlatformSecretStore` CRUD). The CLI's `config push` +**does not** push secrets (deferred by the CLI design until edgezero exposes +secret-store write primitives). Net: **runtime rotation stays; CLI-driven secret +push is out of scope.** Recorded so the split is intentional. + +--- + +## 5. Risks + +| Risk | Mitigation | +| ----------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **Cutover outage on existing prod** — `trusted-server.toml` deleted; deploying the #269 wasm before `app_config` is seeded 503s every route instantly | §3.4 cutover migration: **seed the store first, then deploy the wasm** (ordered); §3.3 makes the unseeded state observable | +| **No rollback path once `trusted-server.toml` is gone** | Rollback = **redeploy the pre-#269 wasm** (still reads `trusted-server.toml`); keep that build artifact available through cutover; the repin is also a single-commit revert | +| Unseeded / fresh install can't serve | §3.3 actionable 503 + §3.4 seed-before-serve | +| **Two `fastly` versions in the wasm binary** (0.11 + 0.12 coexist, §2.3) | Builds green today (§6); watch binary size / duplicate-symbol bloat; the 0.12 bump is deferred (§7), not a fix | +| **`fastly` 0.11/0.12 coexistence** misused — calling 0.12 edgezero APIs from 0.11 adapter code | §2.3: all hand-off via `compat` + `platform/`; never revive the dual-path | +| **`ts-cli-next` is unmerged WIP** (force-pushable, off `main`) | record its SHA when branching; re-base from new SHA + coordinate if it moves; keep our additions as discrete commits | +| Whole-`Settings`-in-store enlarges blast radius of a bad push | `ts-config-hash` verification + malformed-store test (§3.3) | +| `config_payload` forked / drifts | treat as read-only shared seam (§3.2); both sides import one module | +| integration-tests lockfile drift after repin reaches main | targeted `cargo update -p --precise`; never blanket | + +--- + +## 6. Verification (base, this branch) + +`feature/edgezero-269-http` off `ts-cli-next` (`14a91cc1`), pinned `2eeccc9`, +verified **green** on 2026-06-18: + +- `cargo build --workspace --all-targets` — 0 errors +- `cargo build -p trusted-server-adapter-fastly --release --target wasm32-wasip1` — ok +- `cargo test --workspace` — 1372 + 38 + 21 + 2 pass, 0 fail +- `cargo clippy --workspace --all-targets --all-features -- -D warnings` — exit 0 +- `cargo fmt --all -- --check` — clean + +The hardening (§4.1) must keep all of the above green and add its own tests. + +--- + +## 7. Out of scope + +- The PR14→PR20 migration stack and its reconciliation to #269 (separate effort). +- edgezero `run_app`/`app!`/extractor/`RequestContext` adoption (we keep the + bespoke `platform/` layer). +- CLI-driven secret push; the CLI crate itself (`ts config`/`audit`). +- Bumping trusted-server to `fastly 0.12` (the 0.11/0.12 bridge via `compat` is + the chosen design). diff --git a/scripts/integration-tests-browser.sh b/scripts/integration-tests-browser.sh index 9db45f34a..2d3bb8187 100755 --- a/scripts/integration-tests-browser.sh +++ b/scripts/integration-tests-browser.sh @@ -54,10 +54,28 @@ cd "$REPO_ROOT/$BROWSER_DIR" npm ci npx playwright install chromium +# --- Seed the app_config config store into the Viceroy config --- +# The runtime reconstructs Settings from the `app_config` config store at +# request time; without it every settings-dependent route returns 503 and +# Viceroy never becomes ready. Generate a seeded config from the integration +# application config (shares the logic with the Rust test harness). +echo "==> Seeding app_config config store for Viceroy..." +HOST_TARGET="$(rustc -vV | sed -n 's/^host: //p')" +SEEDED_VICEROY_CONFIG="$REPO_ROOT/target/integration/viceroy-seeded.toml" +mkdir -p "$(dirname "$SEEDED_VICEROY_CONFIG")" +cargo run --quiet \ + --manifest-path "$REPO_ROOT/crates/integration-tests/Cargo.toml" \ + --target "$HOST_TARGET" \ + --bin seed-viceroy-config -- \ + --template "$REPO_ROOT/crates/integration-tests/fixtures/configs/viceroy-template.toml" \ + --fixture "$REPO_ROOT/crates/integration-tests/fixtures/configs/trusted-server-integration.toml" \ + --port "$ORIGIN_PORT" \ + --out "$SEEDED_VICEROY_CONFIG" + # --- Export env vars for global-setup.ts --- export WASM_BINARY_PATH="$REPO_ROOT/target/wasm32-wasip1/release/trusted-server-adapter-fastly.wasm" export INTEGRATION_ORIGIN_PORT="$ORIGIN_PORT" -export VICEROY_CONFIG_PATH="$REPO_ROOT/crates/integration-tests/fixtures/configs/viceroy-template.toml" +export VICEROY_CONFIG_PATH="$SEEDED_VICEROY_CONFIG" # Cleanup trap: stop any leftover containers on failure stop_matching_containers() {