From b082c3c49ca67379caec46c490a8a13fc2cd4055 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Wed, 1 Jul 2026 19:50:02 -0700 Subject: [PATCH 01/74] Add design spec for pluggable introspection routes --- .../2026-07-01-introspection-routes-design.md | 346 ++++++++++++++++++ 1 file changed, 346 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-01-introspection-routes-design.md diff --git a/docs/superpowers/specs/2026-07-01-introspection-routes-design.md b/docs/superpowers/specs/2026-07-01-introspection-routes-design.md new file mode 100644 index 00000000..d61ef63f --- /dev/null +++ b/docs/superpowers/specs/2026-07-01-introspection-routes-design.md @@ -0,0 +1,346 @@ +# Design: Pluggable Introspection Routes (manifest / config / routes) + +**Date:** 2026-07-01 +**Status:** Approved — ready for implementation planning +**Scope:** `edgezero-core`, `edgezero-macros`, `examples/app-demo`, `edgezero-cli` templates + +## Summary + +Provide three reusable, framework-supplied HTTP handlers that let any EdgeZero +app expose its own metadata at runtime: + +| Handler path | Emits | +| --------------------------------------- | ------------------------------------------------- | +| `edgezero_core::introspection::manifest` | The full manifest as JSON (baked at compile time) | +| `edgezero_core::introspection::config` | The default config-store envelope `.data` (secret-safe) | +| `edgezero_core::introspection::routes` | `[{ "method", "path" }]` from the live route index | + +These are ordinary handlers. Apps wire them like any other route via +`[[triggers.http]]` in `edgezero.toml`, choosing their own paths. There is **no** +special manifest section and **no** dedicated builder API. `app-demo` and every +generated app ship with the three routes pre-wired under a per-app namespace +`/_/{manifest,config,routes}` (e.g. `/_app-demo/manifest`), but those +are plain trigger rows a developer can edit or delete. + +This design also **removes** the existing built-in route-listing machinery +(`enable_route_listing`, `enable_route_listing_at`, `DEFAULT_ROUTE_LISTING_PATH`, +`/__edgezero/routes`, `RouteListingEntry`, `build_listing_response`) in favor of +the new bindable `routes` handler. + +## Motivation + +Today there is no runtime way to inspect what an app *is*: + +- The **manifest** is compile-time only. `Manifest` derives `Deserialize` + + `Validate` but not `Serialize`, and the portable-store rewrite removed the + `run_app(include_str!("edgezero.toml"), …)` shape, so a running adapter binary + no longer carries the manifest. +- The **app config** is reachable at runtime through the config store, but only + via the typed `AppConfig` extractor, which resolves secrets and requires the + app's concrete config type. +- The only built-in introspection is an opt-in route listing at + `/__edgezero/routes`, wired through a bespoke builder method + (`enable_route_listing`) rather than the normal routing path. + +We want a single, consistent, "bind it yourself" mechanism for all three. + +## Key Decisions (resolved during design) + +1. **Manifest output** — bake the full manifest as JSON. `Manifest` gains + `Serialize`; the `app!` macro serializes the parsed manifest at expansion time + and hands the JSON string to the router. +2. **Config output** — emit the raw config-store `BlobEnvelope.data`. This is + generic (core needs no knowledge of the app's typed config `C`) and + secret-safe: secret fields appear as unresolved key-name references, never + resolved values (resolution only happens inside the typed `AppConfig` + extractor). +3. **Wiring** — plain `[[triggers.http]]` bindings referencing stable core + handler paths. No `[introspection]` manifest section; no builder methods. +4. **Paths** — per-app namespace `/_/{manifest,config,routes}` + (single underscore). These are just the default paths written into the + templates; the developer controls them. +5. **Injection, not a global** — the app-specific data (manifest JSON + route + index) is injected into the request at the shared router dispatch chokepoint + in core. No process-global state; no per-adapter changes. +6. **Remove route listing** — delete the entire `enable_route_listing` machinery + and `/__edgezero/routes`. + +## Architecture + +### Data flow + +``` +compile time runtime (per request) +------------ --------------------- +edgezero.toml + │ app!() macro parses Manifest + │ serde_json::to_string(&manifest) + ▼ +build_router() + builder.with_manifest_json("{…}") RouterService::oneshot(req) + │ └─ RouterInner::dispatch(req) + ▼ │ req.extensions_mut().insert( +RouterInner { manifest_json, │ IntrospectionData { + route_index, … } │ manifest_json, routes }) + ▼ + handler reads ctx.introspection() + manifest → returns baked JSON + routes → projects route index + config → reads default config store +``` + +- **manifest**: parsed at compile time, re-serialized to JSON by the macro, + baked as a string literal into `build_router()`, stored on `RouterInner`, + injected into each request, returned verbatim. No runtime TOML dependency. +- **routes**: derived at request time from the live route index already held by + `RouterInner` (the actually-registered routes, not a manifest projection). +- **config**: read at request time from the default config store; independent of + the manifest JSON. + +### Component 1 — `Manifest: Serialize` (`edgezero-core/src/manifest.rs`) + +Add `Serialize` to the derive list on `Manifest` and every nested struct that +must appear in the output (`ManifestApp`, `ManifestTriggers`, +`ManifestHttpTrigger`, `ManifestEnvironment`, `ManifestBinding`, +`ManifestAdapter` and its sub-structs, `ManifestLogging*`, `ManifestStores`, +`StoreDeclaration`, etc.). + +- Internal-only fields already carry `#[serde(skip)]` (`root`, + `logging_resolved`) and stay out of the output. +- Secret **values** are never stored in the manifest — only binding + declarations (name / env / description) — so serialized output is secret-safe. +- Verify round-trip is not required; this is a one-way (serialize-for-output) + addition. Existing `Deserialize`/`Validate` behavior is unchanged. + +### Component 2 — Router injection (`edgezero-core/src/router.rs`) + +New public struct carrying the per-request introspection payload: + +```rust +#[derive(Clone)] +pub struct IntrospectionData { + pub manifest_json: Option>, + pub routes: Arc<[RouteInfo]>, +} +``` + +Changes: + +- `RouterInner` gains `manifest_json: Option>`. +- `RouterBuilder` gains `manifest_json: Option>` plus a setter: + ```rust + pub fn with_manifest_json>>(mut self, json: S) -> Self { … } + ``` + `build()` threads it into `RouterService::new(...)` / `RouterInner`. +- `RouterInner::dispatch(mut req)` inserts the extension **before** middleware and + routing: + ```rust + req.extensions_mut().insert(IntrospectionData { + manifest_json: self.manifest_json.clone(), + routes: Arc::clone(&self.route_index), + }); + ``` + `route_index` is already an `Arc<[RouteInfo]>`, so the clone is cheap. + +### Component 3 — `RequestContext` accessor (`edgezero-core/src/context.rs`) + +```rust +#[inline] +pub fn introspection(&self) -> Option<&IntrospectionData> { + self.request.extensions().get::() +} +``` + +Mirrors the existing extension-backed accessors (`config_store*`, `kv_store*`). + +### Component 4 — `edgezero_core::introspection` module (new file) + +Three handlers written with `#[action]`, plus a small JSON shape for `routes`. + +```rust +/// GET — full manifest as JSON. +#[action] +pub async fn manifest(ctx: RequestContext) -> Result { + let json = ctx + .introspection() + .and_then(|d| d.manifest_json.clone()) + .ok_or_else(|| EdgeError::internal("manifest introspection data not available"))?; + // application/json, body = json verbatim +} + +/// GET — [{ "method", "path" }] for every registered route. +#[action] +pub async fn routes(ctx: RequestContext) -> Result { + let routes = ctx.introspection().map(|d| &d.routes) /* → Vec */; + // application/json +} + +/// GET — the default config-store envelope `data` (secret-safe). +#[action] +pub async fn config(ctx: RequestContext) -> Result { + let binding = ctx.config_store_default_binding() + .ok_or_else(|| EdgeError::not_found("no default config store registered"))?; + // read raw blob at binding.default_key via binding.handle + // parse BlobEnvelope, emit envelope.data as application/json +} +``` + +Notes: + +- `RouteEntryView { method: String, path: String }` replaces the removed + `RouteListingEntry`. +- `config` reads the raw blob string from the config-store handle (the same read + `extract_from_handle` performs) and parses `BlobEnvelope`; it does **not** run + secret resolution or typed deserialization. +- Error mapping: absent manifest → `500` internal (should not happen once wired); + missing config store or missing blob → `404`. +- The handlers must be reachable by the `app!` macro's `parse_handler_path`, + which already resolves arbitrary `a::b::c` paths (it resolves + `app_demo_core::handlers::root` today), so `edgezero_core::introspection::…` + resolves the same way. + +### Component 5 — `app!` macro (`edgezero-macros/src/app.rs`) + +- After parsing the manifest, serialize it: `serde_json::to_string(&manifest)`. + On serialization error, emit a `compile_error!`. +- Emit one added line in the generated `build_router()`: + ```rust + pub fn build_router() -> edgezero_core::router::RouterService { + let mut builder = edgezero_core::router::RouterService::builder(); + builder = builder.with_manifest_json(#manifest_json_lit); + #(#middleware_tokens)* + #(#route_tokens)* + builder.build() + } + ``` +- No route wiring for introspection (routes come from `[[triggers.http]]`). +- `edgezero-macros` needs `serde_json` as a (build-time) dependency; `Manifest` + must be `Serialize` (Component 1). + +### Component 6 — Removals + +Delete from `edgezero-core/src/router.rs`: + +- `pub const DEFAULT_ROUTE_LISTING_PATH` +- `RouterBuilder::enable_route_listing`, `RouterBuilder::enable_route_listing_at` +- `RouterBuilder.route_listing_path` field and the listing branch inside `build()` +- `build_listing_response` +- `RouteListingEntry` +- All associated unit tests (`route_listing_*`) + +Grep the workspace for any other references (docs, examples, adapter code) and +remove/update them so nothing depends on `/__edgezero/routes`. + +### Component 7 — Templates (default bindings) + +Add three trigger rows, wired to the core handlers, under `/_/…`. + +`examples/app-demo/edgezero.toml`: + +```toml +[[triggers.http]] +id = "manifest" +path = "/_app-demo/manifest" +methods = ["GET"] +handler = "edgezero_core::introspection::manifest" +description = "App manifest as JSON" + +[[triggers.http]] +id = "config" +path = "/_app-demo/config" +methods = ["GET"] +handler = "edgezero_core::introspection::config" +description = "Effective app config (secret-safe)" + +[[triggers.http]] +id = "routes" +path = "/_app-demo/routes" +methods = ["GET"] +handler = "edgezero_core::introspection::routes" +description = "Registered route table" +``` + +`crates/edgezero-cli/src/templates/root/edgezero.toml.hbs`: the same three rows, +using `path = "/_{{name}}/manifest"` etc. and the same `edgezero_core::introspection::*` +handlers. (`{{name}}` is the sanitized app name already used elsewhere in the +template.) + +No template handler code is generated — the handlers live in core. + +## Interfaces (summary) + +| Unit | Public surface | Depends on | +| ----------------------- | ---------------------------------------------------------- | ----------------------------------- | +| `IntrospectionData` | `{ manifest_json: Option>, routes: Arc<[RouteInfo]> }` | `RouteInfo` | +| `RouterBuilder` | `with_manifest_json(impl Into>)` | — | +| `RequestContext` | `introspection() -> Option<&IntrospectionData>` | request extensions | +| `introspection::manifest` | `#[action]` GET → JSON | `ctx.introspection()` | +| `introspection::routes` | `#[action]` GET → JSON | `ctx.introspection()` | +| `introspection::config` | `#[action]` GET → JSON | default config store, `BlobEnvelope`| + +## Error Handling + +- **manifest** absent from `IntrospectionData`: `500 internal` (indicates a + wiring bug; always present once the macro sets it). +- **config**: no default config store → `404 not found`; no blob at + `default_key` → `404`; malformed envelope → `500 internal`. +- **routes**: `IntrospectionData` absent → empty list is acceptable, or `500`; + chosen behavior: return an empty array rather than error, since routes are + always injected by dispatch. + +## Testing Strategy + +Colocated `#[cfg(test)]`, `futures::executor::block_on` (no Tokio), no network. + +- **router.rs**: dispatch test asserting an `IntrospectionData` extension is + present in the request seen by a handler, with the expected route index and + `manifest_json`. Remove old `route_listing_*` tests. +- **introspection module**: + - `manifest` returns the injected JSON with `application/json`. + - `routes` returns the projected `[{method, path}]`. + - `config` returns `BlobEnvelope.data` from a stub config store; `404` when no + store is registered; `404` when the blob is missing. +- **macro (`edgezero-macros`)**: trybuild/expansion assertion that + `with_manifest_json(...)` is emitted with valid JSON for a sample manifest. +- **app-demo**: extend router/handler tests to hit `/_app-demo/manifest`, + `/_app-demo/config`, `/_app-demo/routes` and assert shapes. + +## CI Gates (unchanged) + +1. `cargo fmt --all -- --check` +2. `cargo clippy --workspace --all-targets --all-features -- -D warnings` +3. `cargo test --workspace --all-targets` +4. `cargo check --workspace --all-targets --features "fastly cloudflare spin"` +5. `cargo check -p edgezero-adapter-spin --target wasm32-wasip2 --features spin` + +## Constraints & Non-Goals + +- **WASM-first**: no Tokio, no runtime-specific deps added. `Arc`, `serde_json`, + and `Once*`-free injection are all WASM-safe. `serde_json` is added only to + `edgezero-macros` (a proc-macro crate that runs at build time). +- **No auth/gating in this iteration**: endpoints are exposed wherever the app + binds them. Because they are plain triggers, a developer who does not want them + simply omits the rows. Config output is already secret-safe. Access control + (e.g. dev-only, header-gated) is a possible follow-up, out of scope here. +- **Single-app assumption**: `manifest_json` is per-`RouterService`, so multiple + distinct apps in one process each carry their own — no shared/global state and + no cross-app leakage. +- **No `[introspection]` manifest section** and **no builder-based enable API** — + explicitly rejected in favor of plain `[[triggers.http]]` bindings. + +## File-Change Checklist (for planning) + +- [ ] `crates/edgezero-core/src/manifest.rs` — add `Serialize` derives. +- [ ] `crates/edgezero-core/src/router.rs` — `IntrospectionData`, + `with_manifest_json`, dispatch injection; remove route-listing machinery + + tests. +- [ ] `crates/edgezero-core/src/context.rs` — `introspection()` accessor. +- [ ] `crates/edgezero-core/src/introspection.rs` — new module, three handlers. +- [ ] `crates/edgezero-core/src/lib.rs` — export `introspection`. +- [ ] `crates/edgezero-macros/src/app.rs` — serialize manifest, emit + `with_manifest_json`; add `serde_json` dep. +- [ ] `examples/app-demo/edgezero.toml` — three trigger rows. +- [ ] `crates/edgezero-cli/src/templates/root/edgezero.toml.hbs` — three trigger + rows using `{{name}}`. +- [ ] Workspace grep — purge remaining `/__edgezero/routes` / + `enable_route_listing` references (docs, examples, adapters). From 2e04b2617fc878c61e2a0a0af6f6d38f7047a6fd Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Wed, 1 Jul 2026 20:05:11 -0700 Subject: [PATCH 02/74] Add implementation plan for introspection routes --- .../plans/2026-07-02-introspection-routes.md | 831 ++++++++++++++++++ 1 file changed, 831 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-02-introspection-routes.md diff --git a/docs/superpowers/plans/2026-07-02-introspection-routes.md b/docs/superpowers/plans/2026-07-02-introspection-routes.md new file mode 100644 index 00000000..16322813 --- /dev/null +++ b/docs/superpowers/plans/2026-07-02-introspection-routes.md @@ -0,0 +1,831 @@ +# Pluggable Introspection Routes Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add three reusable core `#[action]` handlers — `edgezero_core::introspection::{manifest, config, routes}` — that any app binds via `[[triggers.http]]`, default-mounted at `/_/{manifest,config,routes}`. + +**Architecture:** The `app!` macro serializes the parsed manifest to JSON at expansion time and hands it to `RouterService::builder().with_manifest_json(...)`. `RouterInner::dispatch` injects an `IntrospectionData { manifest_json, routes }` extension into each request; the three core handlers read it (config reads the default config store instead). The legacy `enable_route_listing` machinery and `/__edgezero/routes` are removed. + +**Tech Stack:** Rust 1.95 (edition 2021), `serde`/`serde_json`, `matchit` routing, `#[action]`/`app!` proc-macros, `futures::executor::block_on` for tests. WASM-first: no Tokio, no runtime-specific deps in core. + +## Global Constraints + +- Rust 1.95.0, edition 2021, resolver 2. License Apache-2.0. +- WASM compatibility first: no Tokio, no `std::time::Instant`, `async-trait` without `Send` bounds. `serde_json` may be added only to the proc-macro crate `edgezero-macros` (runs at build time). +- Colocate tests with implementation (`#[cfg(test)]` in the same file). Async tests use `futures::executor::block_on`, never Tokio. No network / no platform credentials in tests. +- Route params use matchit brace syntax `{id}` / `{*rest}`; never `:id`. +- Import HTTP aliases from `edgezero_core` re-exports, never the `http` crate directly. +- Minimal changes: touch as little as possible; no unrelated refactors or docstrings on untouched code. +- No `Co-Authored-By` trailers, "Generated with" footers, or AI bylines in commits or PR bodies. +- Every PR must pass all five CI gates (see Task 8). + +## Spec Errata / Implementation Assumptions + +These correct or refine the design spec (`docs/superpowers/specs/2026-07-01-introspection-routes-design.md`) after a close read of the code. **They override the spec where they conflict.** + +1. **Secret redaction in manifest output.** `ManifestBinding` (manifest.rs:287) has a `value: Option` field, and `ManifestEnvironment` (manifest.rs:276) uses that same type for BOTH `variables` and `secrets`. Blindly deriving `Serialize` would emit secret-shaped `value`s. The `secrets` list MUST be serialized with `value` omitted. Implemented via a `#[serde(serialize_with = ...)]` redactor on `ManifestEnvironment::secrets`. +2. **`[app]` version/kind.** `ManifestApp` (manifest.rs:217) models only `entry`/`middleware`/`name`, but app-demo's `edgezero.toml` sets `version` and `kind`; they are silently dropped on deserialize today. Add optional `version`/`kind` fields so the manifest JSON reflects the real file. +3. **`#[action]` inside core needs a self-alias.** The `#[action]` macro emits absolute `::edgezero_core::…` paths (action.rs:87). Core uses `#[action]` only in doc comments today, never compiled. Add `extern crate self as edgezero_core;` to `crates/edgezero-core/src/lib.rs` so those paths resolve within the core crate. +4. **Config handler error mapping.** Mirror `extract_from_handle` (extractor.rs:766): map `ConfigStoreError` via `EdgeError::from` (preserving 503/400/500 distinctions), parse `BlobEnvelope`, and call `envelope.verify()` before returning `.data`. Do NOT collapse backend errors to 500. +5. **Injection timing.** `dispatch` inserts the extension after route match / before the handler runs. Tests must assert visibility from a handler and from middleware, not that it changes 404/405 outcomes. +6. **Docs.** The only live public reference to route listing is `docs/guide/routing.md:118`. Update it. Do NOT touch unrelated `.__edgezero_chunks` documentation. +7. **App-demo tests** exercise routes through `build_router().oneshot(request)`, not only direct handler calls. + +--- + +## File Structure + +| File | Responsibility | Task | +| --- | --- | --- | +| `crates/edgezero-core/src/manifest.rs` | Add `Serialize` (+ secret redaction, version/kind) | 1 | +| `crates/edgezero-core/src/router.rs` | `IntrospectionData`, `with_manifest_json`, dispatch injection | 2 | +| `crates/edgezero-core/src/context.rs` | `introspection()` accessor | 2 | +| `crates/edgezero-core/src/introspection.rs` (new) | Three `#[action]` handlers | 3 | +| `crates/edgezero-core/src/lib.rs` | `extern crate self`, `pub mod introspection` | 3 | +| `crates/edgezero-macros/src/app.rs` | Serialize manifest, emit `with_manifest_json` | 4 | +| `crates/edgezero-macros/Cargo.toml` | Add `serde_json` dep | 4 | +| `crates/edgezero-core/src/router.rs` | Remove route-listing machinery + tests | 5 | +| `examples/app-demo/edgezero.toml` | Three trigger rows + router-level tests | 6 | +| `crates/edgezero-cli/src/templates/root/edgezero.toml.hbs` | Three trigger rows | 6 | +| `docs/guide/routing.md` | Replace route-listing docs | 7 | + +--- + +### Task 1: Manifest serialization with secret redaction + +**Files:** +- Modify: `crates/edgezero-core/src/manifest.rs` (structs at :86, :217, :276, :287, and nested adapter/logging/stores structs) +- Test: same file, `#[cfg(test)]` + +**Interfaces:** +- Produces: `Manifest: Serialize` and all nested types serializable; `ManifestApp` gains `version: Option`, `kind: Option`; `ManifestEnvironment::secrets` serialized with `value` omitted. + +- [ ] **Step 1: Write the failing test** + +Add to the `#[cfg(test)]` module in `manifest.rs`: + +```rust +#[test] +fn serializes_manifest_and_redacts_secret_values() { + let toml = r#" +[app] +name = "t" +version = "0.1.0" +kind = "http" + +[[triggers.http]] +id = "root" +path = "/" +methods = ["GET"] +handler = "t::handlers::root" + +[[environment.variables]] +name = "LOG_LEVEL" +value = "info" + +[[environment.secrets]] +name = "API_TOKEN" +value = "super-secret-value" +"#; + let manifest: Manifest = toml::from_str(toml).unwrap(); + let json = serde_json::to_value(&manifest).unwrap(); + + // [app] version/kind round-trip + assert_eq!(json["app"]["version"], "0.1.0"); + assert_eq!(json["app"]["kind"], "http"); + // variables keep their value + assert_eq!(json["environment"]["variables"][0]["value"], "info"); + // secrets NEVER expose value + let secret = &json["environment"]["secrets"][0]; + assert_eq!(secret["name"], "API_TOKEN"); + assert!(secret.get("value").is_none(), "secret value must be redacted"); +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cargo test -p edgezero-core serializes_manifest_and_redacts_secret_values` +Expected: FAIL to compile — `Manifest` does not implement `Serialize`; `ManifestApp` has no `version`/`kind`. + +- [ ] **Step 3: Add `version`/`kind` to `ManifestApp`** + +In `ManifestApp` (manifest.rs:217), add after `name`: + +```rust + #[serde(default, skip_serializing_if = "Option::is_none")] + #[validate(length(min = 1_u64))] + pub version: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[validate(length(min = 1_u64))] + pub kind: Option, +``` + +- [ ] **Step 4: Add the secret redactor** + +Add near `ManifestEnvironment` (manifest.rs:276): + +```rust +/// Serialize a `[[environment.secrets]]` list without exposing `value`. +/// Secret bindings share `ManifestBinding` with variables, whose `value` +/// is safe to emit; secret values must never appear in manifest output. +fn serialize_secrets(secrets: &[ManifestBinding], serializer: S) -> Result +where + S: serde::Serializer, +{ + use serde::ser::SerializeSeq; + + #[derive(Serialize)] + struct RedactedBinding<'a> { + #[serde(skip_serializing_if = "Vec::is_empty")] + adapters: &'a [String], + #[serde(skip_serializing_if = "Option::is_none")] + description: &'a Option, + #[serde(skip_serializing_if = "Option::is_none")] + env: &'a Option, + name: &'a str, + // `value` intentionally omitted. + } + + let mut seq = serializer.serialize_seq(Some(secrets.len()))?; + for binding in secrets { + seq.serialize_element(&RedactedBinding { + adapters: &binding.adapters, + description: &binding.description, + env: &binding.env, + name: &binding.name, + })?; + } + seq.end() +} +``` + +- [ ] **Step 5: Add `Serialize` derives + wire the redactor** + +Add `Serialize` to the `#[derive(...)]` on: `Manifest` (:86), `ManifestApp` (:217), `ManifestTriggers` (:230), `ManifestHttpTrigger` (:238), `ManifestEnvironment` (:276), `ManifestBinding` (:287), `ManifestAdapter` (:344), `ManifestAdapterDeployed` (:368 area), `ManifestAdapterBuild`, `ManifestAdapterCommands`, `ManifestAdapterDefinition`, `ManifestLogging`, `ManifestLoggingConfig`, `ManifestStores`, `StoreDeclaration`, plus the `HttpMethod` enum used in triggers. Keep existing `Deserialize`/`Validate`. + +On `ManifestEnvironment::secrets`, add: + +```rust + #[serde(default, serialize_with = "serialize_secrets")] + #[validate(nested)] + pub secrets: Vec, +``` + +Add `#[serde(skip_serializing_if = "...")]` to keep output clean where fields are optional/empty (e.g. `Option::is_none`, `Vec::is_empty`, `BTreeMap::is_empty`). The internal `root` and `logging_resolved` fields already carry `#[serde(skip)]` — leave them. + +- [ ] **Step 6: Run tests** + +Run: `cargo test -p edgezero-core serializes_manifest_and_redacts_secret_values` +Expected: PASS. +Then: `cargo test -p edgezero-core manifest` — Expected: all existing manifest tests still PASS. + +- [ ] **Step 7: Commit** + +```bash +git add crates/edgezero-core/src/manifest.rs +git commit -m "Make Manifest serializable with secret-value redaction" +``` + +--- + +### Task 2: Router injection + RequestContext accessor + +**Files:** +- Modify: `crates/edgezero-core/src/router.rs` (`RouterBuilder` :80, `build()` :121, `RouterService::new` :343, `RouterInner` :260, `dispatch`) +- Modify: `crates/edgezero-core/src/context.rs` (accessor near the other extension accessors) +- Test: both files, `#[cfg(test)]` + +**Interfaces:** +- Consumes: `RouteInfo` (router.rs:40), existing `RouterInner.route_index: Arc<[RouteInfo]>`. +- Produces: + - `pub struct IntrospectionData { pub manifest_json: Option>, pub routes: Arc<[RouteInfo]> }` (`Clone`). + - `RouterBuilder::with_manifest_json(impl Into>) -> Self`. + - `RequestContext::introspection(&self) -> Option<&IntrospectionData>`. + +- [ ] **Step 1: Write the failing test (router injection)** + +Add to `router.rs` tests: + +```rust +#[test] +fn dispatch_injects_introspection_data() { + use crate::context::RequestContext; + use std::sync::{Arc, Mutex}; + + let seen: Arc>> = Arc::new(Mutex::new(None)); + let seen_h = Arc::clone(&seen); + + let handler = move |ctx: RequestContext| { + let seen_h = Arc::clone(&seen_h); + async move { + let d = ctx.introspection().expect("introspection data present"); + *seen_h.lock().unwrap() = + Some((d.manifest_json.is_some(), d.routes.len())); + Ok::<_, EdgeError>("ok") + } + }; + + let router = RouterService::builder() + .with_manifest_json("{\"app\":{\"name\":\"t\"}}") + .get("/", handler) + .build(); + + let request = crate::http::request_builder() + .method(Method::GET) + .uri("/") + .body(Body::empty()) + .unwrap(); + let _ = block_on(router.oneshot(request)).unwrap(); + + let (had_manifest, route_count) = seen.lock().unwrap().expect("handler ran"); + assert!(had_manifest, "manifest_json should be injected"); + assert_eq!(route_count, 1); +} +``` + +(Use whatever request-builder/`block_on` imports the existing router tests use; match them.) + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cargo test -p edgezero-core dispatch_injects_introspection_data` +Expected: FAIL to compile — `with_manifest_json` and `RequestContext::introspection` do not exist. + +- [ ] **Step 3: Add `IntrospectionData` + builder field/setter** + +In `router.rs`, define near `RouteInfo`: + +```rust +/// Per-request introspection payload injected by [`RouterInner::dispatch`]. +#[derive(Clone)] +pub struct IntrospectionData { + /// The app manifest serialized to JSON at compile time by `app!`. + pub manifest_json: Option>, + /// Every registered route, in registration order. + pub routes: Arc<[RouteInfo]>, +} +``` + +Add to `RouterBuilder` (struct at :80): `manifest_json: Option>,` (its `#[derive(Default)]` covers it). Add the setter: + +```rust + #[must_use] + pub fn with_manifest_json>>(mut self, json: S) -> Self { + self.manifest_json = Some(json.into()); + self + } +``` + +- [ ] **Step 4: Thread it through `build()` → `RouterInner`** + +`RouterInner` (:260) already needs `route_index`. Add `manifest_json: Option>`. Update `RouterService::new` (:343) to accept and store it, and `build()` (:121) to pass `self.manifest_json`. In `dispatch`, before running middleware/handler, insert the extension: + +```rust + async fn dispatch(&self, mut request: Request) -> Result { + request.extensions_mut().insert(IntrospectionData { + manifest_json: self.manifest_json.clone(), + routes: Arc::clone(&self.route_index), + }); + // ... existing match/middleware/handler logic unchanged ... + } +``` + +(If `dispatch` currently takes `request` by value already, just add `mut`. Match the existing signature.) + +- [ ] **Step 5: Add the `RequestContext` accessor** + +In `context.rs`, near `config_store_default_binding`: + +```rust + /// The per-request [`IntrospectionData`] injected by the router, if any. + #[must_use] + #[inline] + pub fn introspection(&self) -> Option<&crate::router::IntrospectionData> { + self.request.extensions().get::() + } +``` + +- [ ] **Step 6: Run tests** + +Run: `cargo test -p edgezero-core dispatch_injects_introspection_data` +Expected: PASS. +Then: `cargo test -p edgezero-core router` — Expected: PASS (existing route-listing tests still pass; they are removed in Task 5). + +- [ ] **Step 7: Commit** + +```bash +git add crates/edgezero-core/src/router.rs crates/edgezero-core/src/context.rs +git commit -m "Inject IntrospectionData at router dispatch chokepoint" +``` + +--- + +### Task 3: Introspection handler module + +**Files:** +- Create: `crates/edgezero-core/src/introspection.rs` +- Modify: `crates/edgezero-core/src/lib.rs` (add `extern crate self as edgezero_core;` and `pub mod introspection;`) +- Test: `introspection.rs`, `#[cfg(test)]` + +**Interfaces:** +- Consumes: `RequestContext::introspection()`, `IntrospectionData` (Task 2); `config_store_default_binding()` (context.rs:63); `BlobEnvelope` (blob_envelope.rs:17); `EdgeError` constructors (error.rs). +- Produces: `pub async fn manifest/config/routes` (each `#[action]`), bindable as `edgezero_core::introspection::{manifest,config,routes}`. + +- [ ] **Step 1: Add the self-alias and module declaration** + +In `crates/edgezero-core/src/lib.rs`, add at the very top of the crate (before the `pub mod` list, after any inner attributes): + +```rust +extern crate self as edgezero_core; +``` + +And add to the module list (keep alphabetical): `pub mod introspection;` + +- [ ] **Step 2: Write the failing tests** + +Create `crates/edgezero-core/src/introspection.rs`: + +```rust +//! Framework-supplied introspection handlers. Bind via `[[triggers.http]]`: +//! `handler = "edgezero_core::introspection::manifest"` etc. + +use crate::blob_envelope::BlobEnvelope; +use crate::body::Body; +use crate::context::RequestContext; +use crate::error::EdgeError; +use crate::http::{response_builder, StatusCode}; +use crate::response::Response; +use edgezero_core::action; +use serde::Serialize; + +#[derive(Serialize)] +struct RouteView { + method: String, + path: String, +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::http::{request_builder, Method}; + use crate::router::RouterService; + use futures::executor::block_on; + + #[test] + fn manifest_returns_injected_json() { + let router = RouterService::builder() + .with_manifest_json("{\"app\":{\"name\":\"t\"}}") + .get("/m", manifest) + .build(); + let req = request_builder().method(Method::GET).uri("/m").body(Body::empty()).unwrap(); + let resp = block_on(router.oneshot(req)).unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + assert_eq!( + resp.headers().get("content-type").unwrap(), + "application/json" + ); + } + + #[test] + fn routes_lists_registered_routes() { + let router = RouterService::builder().get("/r", routes).build(); + let req = request_builder().method(Method::GET).uri("/r").body(Body::empty()).unwrap(); + let resp = block_on(router.oneshot(req)).unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + } + + #[test] + fn config_without_store_is_not_found() { + let router = RouterService::builder().get("/c", config).build(); + let req = request_builder().method(Method::GET).uri("/c").body(Body::empty()).unwrap(); + let resp = block_on(router.oneshot(req)).unwrap(); + assert_eq!(resp.status(), StatusCode::NOT_FOUND); + } +} +``` + +- [ ] **Step 3: Run tests to verify they fail** + +Run: `cargo test -p edgezero-core introspection` +Expected: FAIL to compile — `manifest`/`config`/`routes` not defined. + +- [ ] **Step 4: Implement the three handlers** + +Add to `introspection.rs` (above the tests): + +```rust +fn json_response(status: StatusCode, body: Body) -> Result { + response_builder() + .status(status) + .header("content-type", "application/json") + .body(body) + .map_err(EdgeError::internal) +} + +/// GET — the app manifest as JSON (baked at compile time by `app!`). +#[action] +pub async fn manifest(ctx: RequestContext) -> Result { + let json = ctx + .introspection() + .and_then(|d| d.manifest_json.clone()) + .ok_or_else(|| EdgeError::internal(anyhow::anyhow!("manifest introspection data missing")))?; + json_response(StatusCode::OK, Body::text(json.to_string())) +} + +/// GET — `[{ "method", "path" }]` for every registered route. +#[action] +pub async fn routes(ctx: RequestContext) -> Result { + let views: Vec = ctx + .introspection() + .map(|d| { + d.routes + .iter() + .map(|r| RouteView { + method: r.method().as_str().to_owned(), + path: r.path().to_owned(), + }) + .collect() + }) + .unwrap_or_default(); + let body = Body::json(&views).map_err(EdgeError::internal)?; + json_response(StatusCode::OK, body) +} + +/// GET — the default config-store envelope `data` (secret-safe: secret +/// fields remain unresolved key-name references). +#[action] +pub async fn config(ctx: RequestContext) -> Result { + let binding = ctx + .config_store_default_binding() + .ok_or_else(|| EdgeError::not_found("no default config store registered"))?; + // ConfigStoreError → EdgeError preserves 503/400/500 (see extractor.rs). + let raw = binding + .handle + .get(&binding.default_key) + .await + .map_err(EdgeError::from)? + .ok_or_else(|| EdgeError::not_found("no config blob in default store"))?; + let envelope: BlobEnvelope = serde_json::from_str(&raw) + .map_err(|err| EdgeError::internal(anyhow::anyhow!("envelope parse failed: {err}")))?; + envelope + .verify() + .map_err(|err| EdgeError::internal(anyhow::anyhow!("envelope verification failed: {err}")))?; + let body = Body::json(&envelope.into_data()).map_err(EdgeError::internal)?; + json_response(StatusCode::OK, body) +} +``` + +Notes: confirm `ConfigStoreBinding` field names are `handle` and `default_key` (context.rs uses `binding.handle`/`binding.default_key`). Confirm `Body::json` exists (body.rs:114) and `RouteInfo::method()/path()` (router.rs:48/62). If `anyhow` is not already a core dep for this pattern, mirror what `extractor.rs` uses (`anyhow::anyhow!`). + +- [ ] **Step 5: Run tests** + +Run: `cargo test -p edgezero-core introspection` +Expected: PASS (all three). + +- [ ] **Step 6: Commit** + +```bash +git add crates/edgezero-core/src/introspection.rs crates/edgezero-core/src/lib.rs +git commit -m "Add edgezero_core::introspection handlers (manifest/config/routes)" +``` + +--- + +### Task 4: `app!` macro injects the manifest JSON + +**Files:** +- Modify: `crates/edgezero-macros/src/app.rs` (`build_router` emission around :170-176) +- Modify: `crates/edgezero-macros/Cargo.toml` (add `serde_json`) +- Test: `crates/edgezero-macros` unit test or `examples/app-demo` (verified end-to-end in Task 6) + +**Interfaces:** +- Consumes: parsed `Manifest` (now `Serialize`, Task 1); `RouterBuilder::with_manifest_json` (Task 2). +- Produces: generated `build_router()` calls `builder.with_manifest_json("")`. + +- [ ] **Step 1: Add `serde_json` to the macro crate** + +In `crates/edgezero-macros/Cargo.toml` under `[dependencies]`, add the workspace dep: + +```toml +serde_json = { workspace = true } +``` + +- [ ] **Step 2: Serialize the manifest and emit the setter** + +In `app.rs`, after the manifest is parsed (near `app_name` at :126), add: + +```rust + let manifest_json = match serde_json::to_string(&manifest) { + Ok(json) => json, + Err(err) => { + return syn::Error::new( + Span::call_site(), + format!("failed to serialize manifest to JSON: {err}"), + ) + .to_compile_error() + .into(); + } + }; + let manifest_json_lit = LitStr::new(&manifest_json, Span::call_site()); +``` + +Then in the emitted `build_router()` (the `quote! { ... pub fn build_router() ... }` block around :170), insert the setter as the first builder mutation: + +```rust + pub fn build_router() -> edgezero_core::router::RouterService { + let mut builder = edgezero_core::router::RouterService::builder(); + builder = builder.with_manifest_json(#manifest_json_lit); + #(#middleware_tokens)* + #(#route_tokens)* + builder.build() + } +``` + +- [ ] **Step 3: Verify the macro crate compiles** + +Run: `cargo build -p edgezero-macros` +Expected: builds cleanly. + +- [ ] **Step 4: Verify a consumer still builds** + +Run: `cargo build -p edgezero-core` then `cargo check -p app-demo-core --manifest-path examples/app-demo/Cargo.toml` (or `cd examples/app-demo && cargo check -p app-demo-core`). +Expected: builds; the generated `build_router` now sets manifest JSON. + +- [ ] **Step 5: Commit** + +```bash +git add crates/edgezero-macros/src/app.rs crates/edgezero-macros/Cargo.toml +git commit -m "app! macro: bake manifest JSON into build_router via with_manifest_json" +``` + +--- + +### Task 5: Remove legacy route-listing machinery + +**Files:** +- Modify: `crates/edgezero-core/src/router.rs` (remove `DEFAULT_ROUTE_LISTING_PATH`, `enable_route_listing`, `enable_route_listing_at`, `route_listing_path` field, listing branch in `build()`, `build_listing_response`, `RouteListingEntry`, and all `route_listing_*` tests at :621-716) +- Test: `router.rs` (removal of obsolete tests) + +**Interfaces:** +- Produces: no public route-listing API remains; `/__edgezero/routes` is gone. + +- [ ] **Step 1: Delete the machinery** + +Remove from `router.rs`: +- `pub const DEFAULT_ROUTE_LISTING_PATH` (:21) +- `RouterBuilder.route_listing_path` field (:83) +- `RouterBuilder::enable_route_listing` (:174) and `enable_route_listing_at` (:182) +- The `if let Some(path) = listing_path { ... }` block inside `build()` (the listing-handler insertion) and the `let listing_path = self.route_listing_path.clone();` line (:122) +- `build_listing_response` (:376) +- `RouteListingEntry` struct (:71 area) +- Tests: `route_listing_duplicate_path_panics`, `route_listing_outputs_all_routes`, `route_listing_rejects_empty_path`, `route_listing_rejects_missing_slash`, `route_listing_response_handles_builder_failure`, `route_listing_response_handles_json_failure` (:621-716) + +- [ ] **Step 2: Grep for stragglers** + +Run: +```bash +grep -rn "enable_route_listing\|DEFAULT_ROUTE_LISTING_PATH\|RouteListingEntry\|__edgezero/routes\|build_listing_response" crates/ examples/ +``` +Expected: no matches in non-doc source. (The `docs/guide/routing.md` reference is handled in Task 7.) + +- [ ] **Step 3: Verify compile + tests** + +Run: `cargo test -p edgezero-core router` +Expected: PASS; no references to removed items. + +- [ ] **Step 4: Commit** + +```bash +git add crates/edgezero-core/src/router.rs +git commit -m "Remove legacy route-listing machinery and /__edgezero/routes" +``` + +--- + +### Task 6: Wire default triggers in app-demo + generated template + +**Files:** +- Modify: `examples/app-demo/edgezero.toml` +- Modify: `crates/edgezero-cli/src/templates/root/edgezero.toml.hbs` +- Test: `examples/app-demo/crates/app-demo-core/src/lib.rs` or the crate's existing router test module (through `build_router().oneshot()`) + +**Interfaces:** +- Consumes: `edgezero_core::introspection::{manifest,config,routes}` (Task 3); manifest JSON injection (Task 4). + +- [ ] **Step 1: Add three triggers to app-demo** + +Append to `examples/app-demo/edgezero.toml` in the `[[triggers.http]]` section: + +```toml +[[triggers.http]] +id = "manifest" +path = "/_app-demo/manifest" +methods = ["GET"] +handler = "edgezero_core::introspection::manifest" +adapters = ["axum", "cloudflare", "fastly", "spin"] +description = "App manifest as JSON" + +[[triggers.http]] +id = "config" +path = "/_app-demo/config" +methods = ["GET"] +handler = "edgezero_core::introspection::config" +adapters = ["axum", "cloudflare", "fastly", "spin"] +description = "Effective app config (secret-safe)" + +[[triggers.http]] +id = "routes" +path = "/_app-demo/routes" +methods = ["GET"] +handler = "edgezero_core::introspection::routes" +adapters = ["axum", "cloudflare", "fastly", "spin"] +description = "Registered route table" +``` + +- [ ] **Step 2: Write the failing router-level test** + +In app-demo-core's test module (colocated with `build_router`/`App`), add: + +```rust +#[test] +fn introspection_routes_are_wired() { + use edgezero_core::body::Body; + use edgezero_core::http::{request_builder, Method, StatusCode}; + use futures::executor::block_on; + + let router = crate::build_router(); + for path in ["/_app-demo/manifest", "/_app-demo/routes"] { + let req = request_builder().method(Method::GET).uri(path).body(Body::empty()).unwrap(); + let resp = block_on(router.oneshot(req)).unwrap(); + assert_eq!(resp.status(), StatusCode::OK, "{path} should be 200"); + assert_eq!(resp.headers().get("content-type").unwrap(), "application/json"); + } + // /_app-demo/config is 404 without a populated config store, but must be routed + // (i.e. not a routing 404 with empty body). Assert it is reachable: + let req = request_builder().method(Method::GET).uri("/_app-demo/config").body(Body::empty()).unwrap(); + let resp = block_on(router.oneshot(req)).unwrap(); + assert!(matches!(resp.status(), StatusCode::OK | StatusCode::NOT_FOUND)); +} +``` + +(Match the app-demo crate's existing test imports/module location; `build_router` is generated by `app!`.) + +- [ ] **Step 3: Run test to verify it fails, then passes** + +Run: `cargo test -p app-demo-core introspection_routes_are_wired` +Expected: initially FAILS if triggers not yet parsed/handler path unresolved; after Step 1 + Tasks 3-4, PASS. + +- [ ] **Step 4: Add the same rows to the generated-app template** + +In `crates/edgezero-cli/src/templates/root/edgezero.toml.hbs`, append three trigger blocks mirroring app-demo but templated: + +```hbs +[[triggers.http]] +id = "manifest" +path = "/_{{name}}/manifest" +methods = ["GET"] +handler = "edgezero_core::introspection::manifest" +adapters = [{{{adapter_list}}}] +description = "App manifest as JSON" + +[[triggers.http]] +id = "config" +path = "/_{{name}}/config" +methods = ["GET"] +handler = "edgezero_core::introspection::config" +adapters = [{{{adapter_list}}}] +description = "Effective app config (secret-safe)" + +[[triggers.http]] +id = "routes" +path = "/_{{name}}/routes" +methods = ["GET"] +handler = "edgezero_core::introspection::routes" +adapters = [{{{adapter_list}}}] +description = "Registered route table" +``` + +(Use the same `{{{adapter_list}}}` placeholder the template already uses for other triggers — verify its exact name in the `.hbs` file.) + +- [ ] **Step 5: Verify generator tests** + +Run: `cargo test -p edgezero-cli` +Expected: PASS (scaffold/generator tests still green with the added triggers). + +- [ ] **Step 6: Commit** + +```bash +git add examples/app-demo/edgezero.toml examples/app-demo/crates/app-demo-core crates/edgezero-cli/src/templates/root/edgezero.toml.hbs +git commit -m "Wire default introspection triggers into app-demo and generated apps" +``` + +--- + +### Task 7: Update docs + +**Files:** +- Modify: `docs/guide/routing.md` (around :118, the route-listing reference) + +**Interfaces:** none (documentation only). + +- [ ] **Step 1: Locate the reference** + +Run: `grep -n "route listing\|__edgezero/routes\|enable_route_listing" docs/guide/routing.md` +Expected: a reference around line 118. Do NOT touch any `.__edgezero_chunks` docs (unrelated). + +- [ ] **Step 2: Replace with introspection-route docs** + +Rewrite that section to describe the three bindable handlers instead of `enable_route_listing`. Content to convey: +- Core provides `edgezero_core::introspection::{manifest, config, routes}`. +- Bind them in `[[triggers.http]]` like any handler; app-demo and generated apps mount them under `/_/{manifest,config,routes}` by default. +- `manifest` → full manifest JSON (secret values redacted); `config` → effective app config from the default config store (secret-safe); `routes` → registered route table. +- Remove any mention of `/__edgezero/routes` / `enable_route_listing`. + +Example block to include: + +```toml +[[triggers.http]] +id = "manifest" +path = "/_my-app/manifest" +methods = ["GET"] +handler = "edgezero_core::introspection::manifest" +``` + +- [ ] **Step 3: Verify no stale references remain** + +Run: `grep -rn "enable_route_listing\|__edgezero/routes" docs/` +Expected: no matches (excluding `.__edgezero_chunks` which is a different token — verify the grep does not match it; if it does, refine to `__edgezero/routes`). + +- [ ] **Step 4: Commit** + +```bash +git add docs/guide/routing.md +git commit -m "Docs: replace route-listing with introspection routes" +``` + +--- + +### Task 8: Full verification (CI gates + app-demo smoke) + +**Files:** none (verification only). + +- [ ] **Step 1: Format** + +Run: `cargo fmt --all -- --check` +Expected: clean (no diff). + +- [ ] **Step 2: Clippy** + +Run: `cargo clippy --workspace --all-targets --all-features -- -D warnings` +Expected: no warnings. + +- [ ] **Step 3: Workspace tests** + +Run: `cargo test --workspace --all-targets` +Expected: all pass, including the new manifest/router/introspection/app-demo tests. + +- [ ] **Step 4: Feature compilation** + +Run: `cargo check --workspace --all-targets --features "fastly cloudflare spin"` +Expected: builds. + +- [ ] **Step 5: Spin wasm target** + +Run: `cargo check -p edgezero-adapter-spin --target wasm32-wasip2 --features spin` +Expected: builds. + +- [ ] **Step 6: app-demo dev-server smoke (manual/optional)** + +Run: `cd examples/app-demo && cargo run -p app-demo-adapter-axum` then in another shell: +```bash +curl -s localhost:8787/_app-demo/manifest | head -c 200 +curl -s localhost:8787/_app-demo/routes +curl -s -o /dev/null -w "%{http_code}\n" localhost:8787/_app-demo/config +``` +Expected: manifest JSON (no secret `value`), a routes array, and a status code for `/config` (200 if a config blob is present, 404 otherwise). + +- [ ] **Step 7: Mark PR ready** + +Update PR #300 checklist and mark it ready for review: +```bash +gh pr ready 300 +``` + +--- + +## Self-Review + +**Spec coverage:** +- Manifest→JSON (baked, Serialize): Task 1 + Task 4. ✓ (errata: secret redaction, version/kind) +- Config→envelope data (secret-safe, verify): Task 3. ✓ +- Routes→live index: Task 3. ✓ +- Router-chokepoint injection (no global/no adapter changes): Task 2. ✓ +- `RequestContext::introspection()`: Task 2. ✓ +- `#[action]` self-alias: Task 3 Step 1. ✓ +- Remove `enable_route_listing`/`/__edgezero/routes`: Task 5. ✓ +- Templates + app-demo default triggers under `/_/…`: Task 6. ✓ +- Docs update: Task 7. ✓ +- CI gates: Task 8. ✓ + +**Placeholder scan:** No TBD/TODO; every code step shows real code. Two verification notes ("confirm field names", "verify `{{{adapter_list}}}` name") are guardrails against drift, not missing content. + +**Type consistency:** `IntrospectionData { manifest_json: Option>, routes: Arc<[RouteInfo]> }`, `with_manifest_json(impl Into>)`, and `introspection() -> Option<&IntrospectionData>` are used identically across Tasks 2/3/6. Handler names `manifest`/`config`/`routes` match the trigger `handler = "edgezero_core::introspection::…"` strings in Task 6. From 98d225af2876c1dd655c1eb00b17f0c71a625e20 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Wed, 1 Jul 2026 20:16:49 -0700 Subject: [PATCH 03/74] Revise introspection plan per review: enum serialization, imports, config/app-demo test coverage, workspace commands --- .../plans/2026-07-02-introspection-routes.md | 370 ++++++++++++++++-- 1 file changed, 331 insertions(+), 39 deletions(-) diff --git a/docs/superpowers/plans/2026-07-02-introspection-routes.md b/docs/superpowers/plans/2026-07-02-introspection-routes.md index 16322813..6073f166 100644 --- a/docs/superpowers/plans/2026-07-02-introspection-routes.md +++ b/docs/superpowers/plans/2026-07-02-introspection-routes.md @@ -99,12 +99,40 @@ value = "super-secret-value" let secret = &json["environment"]["secrets"][0]; assert_eq!(secret["name"], "API_TOKEN"); assert!(secret.get("value").is_none(), "secret value must be redacted"); + // Enums serialize to their wire strings, not Rust variant names. + assert_eq!(json["triggers"]["http"][0]["methods"][0], "GET"); +} + +#[test] +fn serializes_enums_with_wire_casing() { + let toml = r#" +[app] +name = "t" + +[[triggers.http]] +id = "r" +path = "/" +methods = ["POST"] +handler = "t::h::r" +body-mode = "buffered" + +[logging.axum] +level = "info" +"#; + let manifest: Manifest = toml::from_str(toml).unwrap(); + let json = serde_json::to_value(&manifest).unwrap(); + assert_eq!(json["triggers"]["http"][0]["methods"][0], "POST"); + assert_eq!(json["triggers"]["http"][0]["body_mode"], "buffered"); + assert_eq!(json["logging"]["axum"]["level"], "info"); } ``` +(Adjust the `[logging.axum]`/`body-mode` key spellings to match the manifest's +actual serde field renames — verify against manifest.rs before running.) + - [ ] **Step 2: Run test to verify it fails** -Run: `cargo test -p edgezero-core serializes_manifest_and_redacts_secret_values` +Run: `cargo test -p edgezero-core serializes_manifest_and_redacts_secret_values serializes_enums_with_wire_casing` Expected: FAIL to compile — `Manifest` does not implement `Serialize`; `ManifestApp` has no `version`/`kind`. - [ ] **Step 3: Add `version`/`kind` to `ManifestApp`** @@ -122,7 +150,10 @@ In `ManifestApp` (manifest.rs:217), add after `name`: - [ ] **Step 4: Add the secret redactor** -Add near `ManifestEnvironment` (manifest.rs:276): +Add near `ManifestEnvironment` (manifest.rs:276). Use an **owned** redacted +struct so serde's `skip_serializing_if` fn signatures match (a `&[String]` field +would make `Vec::is_empty` fail to type-check; an `&Option<_>` field would make +`Option::is_none` fail). Cloning is cheap and only happens at serialize time: ```rust /// Serialize a `[[environment.secrets]]` list without exposing `value`. @@ -135,33 +166,88 @@ where use serde::ser::SerializeSeq; #[derive(Serialize)] - struct RedactedBinding<'a> { + struct RedactedBinding { #[serde(skip_serializing_if = "Vec::is_empty")] - adapters: &'a [String], + adapters: Vec, #[serde(skip_serializing_if = "Option::is_none")] - description: &'a Option, + description: Option, #[serde(skip_serializing_if = "Option::is_none")] - env: &'a Option, - name: &'a str, + env: Option, + name: String, // `value` intentionally omitted. } let mut seq = serializer.serialize_seq(Some(secrets.len()))?; for binding in secrets { seq.serialize_element(&RedactedBinding { - adapters: &binding.adapters, - description: &binding.description, - env: &binding.env, - name: &binding.name, + adapters: binding.adapters.clone(), + description: binding.description.clone(), + env: binding.env.clone(), + name: binding.name.clone(), })?; } seq.end() } ``` -- [ ] **Step 5: Add `Serialize` derives + wire the redactor** +- [ ] **Step 5a: Add manual `Serialize` impls for the enums** + +`HttpMethod` (:581), `BodyMode` (:639), and `LogLevel` (:669) have hand-written +`Deserialize` impls that accept wire strings (`"GET"`, `"buffered"`, `"info"`). +A derived `Serialize` would emit variant names (`Get`/`Buffered`/`Info`) — +**wrong**. Add manual impls that mirror deserialization. Do NOT add `Serialize` +to their derive lists. `Serialize` has no defaulted methods, so no +`#[expect(clippy::missing_trait_methods)]` is needed (unlike the `Deserialize` +impls). Add after each enum's existing impl block: + +```rust +impl serde::Serialize for HttpMethod { + #[inline] + fn serialize(&self, serializer: S) -> Result { + serializer.serialize_str(self.as_str()) + } +} + +impl serde::Serialize for BodyMode { + #[inline] + fn serialize(&self, serializer: S) -> Result { + serializer.serialize_str(match self { + Self::Buffered => "buffered", + Self::Stream => "stream", + }) + } +} + +impl serde::Serialize for LogLevel { + #[inline] + fn serialize(&self, serializer: S) -> Result { + serializer.serialize_str(self.as_str()) + } +} +``` -Add `Serialize` to the `#[derive(...)]` on: `Manifest` (:86), `ManifestApp` (:217), `ManifestTriggers` (:230), `ManifestHttpTrigger` (:238), `ManifestEnvironment` (:276), `ManifestBinding` (:287), `ManifestAdapter` (:344), `ManifestAdapterDeployed` (:368 area), `ManifestAdapterBuild`, `ManifestAdapterCommands`, `ManifestAdapterDefinition`, `ManifestLogging`, `ManifestLoggingConfig`, `ManifestStores`, `StoreDeclaration`, plus the `HttpMethod` enum used in triggers. Keep existing `Deserialize`/`Validate`. +- [ ] **Step 5: Add `Serialize` derives to the structs + wire the redactor** + +Add `Serialize` to the `#[derive(...)]` on these **structs** (verify each line +against the file — they are the Deserialize-deriving manifest structs reachable +from `Manifest` output on `main`): `Manifest` (:86), `ManifestApp` (:217), +`ManifestTriggers` (:230), `ManifestHttpTrigger` (:238), `ManifestEnvironment` +(:276), `ManifestBinding` (:287), `ManifestAdapter` (:344), +`ManifestAdapterDefinition` (:368), `ManifestAdapterBuild` (:405), +`ManifestAdapterCommands` (:418), `ManifestStores` (:460), `StoreDeclaration` +(:482), `ManifestLogging` (:519), `ManifestLoggingConfig` (:527). Keep existing +`Deserialize`/`Validate`. + +Do **not** add `Serialize` to the enums (Step 5a handles those manually), and do +**not** add it to the internal resolved/non-serde structs at :330, :338, :539 +(they are reachable only via `#[serde(skip)]` fields — `root`, +`logging_resolved`). `toml::Value` fields (e.g. any `#[serde(flatten)]` legacy +map, if present) already implement `Serialize`. + +> **Note (branch drift):** the earlier design exploration ran against the +> `feature/provision-local-impl` checkout, which has an extra +> `ManifestAdapterDeployed` struct and an adapter `deployed` field. Those do +> **not** exist on `main` (this worktree's base) — do not reference them. On `ManifestEnvironment::secrets`, add: @@ -175,7 +261,7 @@ Add `#[serde(skip_serializing_if = "...")]` to keep output clean where fields ar - [ ] **Step 6: Run tests** -Run: `cargo test -p edgezero-core serializes_manifest_and_redacts_secret_values` +Run: `cargo test -p edgezero-core serializes_manifest_and_redacts_secret_values serializes_enums_with_wire_casing` Expected: PASS. Then: `cargo test -p edgezero-core manifest` — Expected: all existing manifest tests still PASS. @@ -245,9 +331,45 @@ fn dispatch_injects_introspection_data() { (Use whatever request-builder/`block_on` imports the existing router tests use; match them.) +Also add a middleware-visibility test (errata #5 requires proving both handler +and middleware see the injected data, since injection happens before the +middleware chain runs): + +```rust +#[test] +fn middleware_sees_introspection_data() { + use crate::context::RequestContext; + use crate::middleware::{Middleware, Next}; + use std::sync::{Arc, Mutex}; + + struct Probe(Arc>); + #[async_trait::async_trait(?Send)] + impl Middleware for Probe { + async fn handle(&self, ctx: RequestContext, next: Next) -> Result { + *self.0.lock().unwrap() = ctx.introspection().is_some(); + next.run(ctx).await + } + } + + let saw = Arc::new(Mutex::new(false)); + let router = RouterService::builder() + .with_manifest_json("{}") + .middleware(Probe(Arc::clone(&saw))) + .get("/", |_ctx: RequestContext| async { Ok::<_, EdgeError>("ok") }) + .build(); + let request = crate::http::request_builder() + .method(Method::GET).uri("/").body(Body::empty()).unwrap(); + let _ = block_on(router.oneshot(request)).unwrap(); + assert!(*saw.lock().unwrap(), "middleware should see introspection data"); +} +``` + +(Match the exact `Middleware`/`Next` import paths and `async_trait` usage the +existing middleware tests in this crate use.) + - [ ] **Step 2: Run test to verify it fails** -Run: `cargo test -p edgezero-core dispatch_injects_introspection_data` +Run: `cargo test -p edgezero-core dispatch_injects_introspection_data middleware_sees_introspection_data` Expected: FAIL to compile — `with_manifest_json` and `RequestContext::introspection` do not exist. - [ ] **Step 3: Add `IntrospectionData` + builder field/setter** @@ -306,7 +428,7 @@ In `context.rs`, near `config_store_default_binding`: - [ ] **Step 6: Run tests** -Run: `cargo test -p edgezero-core dispatch_injects_introspection_data` +Run: `cargo test -p edgezero-core dispatch_injects_introspection_data middleware_sees_introspection_data` Expected: PASS. Then: `cargo test -p edgezero-core router` — Expected: PASS (existing route-listing tests still pass; they are removed in Task 5). @@ -352,8 +474,9 @@ use crate::blob_envelope::BlobEnvelope; use crate::body::Body; use crate::context::RequestContext; use crate::error::EdgeError; -use crate::http::{response_builder, StatusCode}; -use crate::response::Response; +// NOTE: `Response` is an HTTP alias exported from `crate::http`, NOT +// `crate::response` (response.rs itself imports it from crate::http). +use crate::http::{response_builder, Response, StatusCode}; use edgezero_core::action; use serde::Serialize; @@ -366,9 +489,66 @@ struct RouteView { #[cfg(test)] mod tests { use super::*; + use crate::config_store::{ConfigStore, ConfigStoreError, ConfigStoreHandle}; + use crate::context::RequestContext; use crate::http::{request_builder, Method}; + use crate::params::PathParams; use crate::router::RouterService; + use crate::store_registry::{ConfigRegistry, ConfigStoreBinding, StoreRegistry}; + use async_trait::async_trait; use futures::executor::block_on; + use std::collections::BTreeMap; + use std::sync::Arc; + + // A config store returning a fixed result for `get`, used to drive the + // config handler's status-code mapping. Mirrors the pattern in + // extractor.rs::config_extractor_resolves_from_registry. + struct StubStore(Result, ConfigStoreError>); + #[async_trait(?Send)] + impl ConfigStore for StubStore { + async fn get(&self, _key: &str) -> Result, ConfigStoreError> { + match &self.0 { + Ok(v) => Ok(v.clone()), + Err(ConfigStoreError::Unavailable { .. }) => { + Err(ConfigStoreError::unavailable("down")) + } + Err(ConfigStoreError::InvalidKey { .. }) => { + Err(ConfigStoreError::invalid_key("bad")) + } + Err(_) => Err(ConfigStoreError::internal(anyhow::anyhow!("boom"))), + } + } + } + + // Build a request carrying a default ConfigRegistry backed by `store`, + // run it through the `config` handler, and return the response. + fn run_config(store: StubStore) -> crate::http::Response { + let registry: ConfigRegistry = StoreRegistry::new( + [( + "default".to_owned(), + ConfigStoreBinding { + handle: ConfigStoreHandle::new(Arc::new(store)), + default_key: "default".to_owned(), + }, + )] + .into_iter() + .collect::>(), + "default".to_owned(), + ); + let mut request = request_builder() + .method(Method::GET) + .uri("/c") + .body(Body::empty()) + .unwrap(); + request.extensions_mut().insert(registry); + let ctx = RequestContext::new(request, PathParams::default()); + block_on(super::config(ctx)).unwrap_or_else(|e| e.into_response().unwrap()) + } + + fn valid_envelope_json(data: serde_json::Value) -> String { + // Build a real envelope so sha/version are correct. + serde_json::to_string(&BlobEnvelope::new(data, "2026-01-01T00:00:00Z".to_owned())).unwrap() + } #[test] fn manifest_returns_injected_json() { @@ -379,10 +559,7 @@ mod tests { let req = request_builder().method(Method::GET).uri("/m").body(Body::empty()).unwrap(); let resp = block_on(router.oneshot(req)).unwrap(); assert_eq!(resp.status(), StatusCode::OK); - assert_eq!( - resp.headers().get("content-type").unwrap(), - "application/json" - ); + assert_eq!(resp.headers().get("content-type").unwrap(), "application/json"); } #[test] @@ -400,9 +577,65 @@ mod tests { let resp = block_on(router.oneshot(req)).unwrap(); assert_eq!(resp.status(), StatusCode::NOT_FOUND); } + + #[test] + fn config_happy_path_returns_envelope_data_secret_safe() { + let data = serde_json::json!({ "greeting": "hi", "api_token": "demo_api_token" }); + let resp = run_config(StubStore(Ok(Some(valid_envelope_json(data))))); + assert_eq!(resp.status(), StatusCode::OK); + // Raw envelope `data` is returned verbatim: the secret field holds the + // KEY NAME, never a resolved value. + // (Read the body via the crate's test body helper and assert the JSON + // contains "api_token":"demo_api_token".) + } + + #[test] + fn config_missing_blob_is_not_found() { + let resp = run_config(StubStore(Ok(None))); + assert_eq!(resp.status(), StatusCode::NOT_FOUND); + } + + #[test] + fn config_backend_unavailable_maps_503() { + let resp = run_config(StubStore(Err(ConfigStoreError::unavailable("x")))); + assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE); + } + + #[test] + fn config_invalid_key_maps_400() { + let resp = run_config(StubStore(Err(ConfigStoreError::invalid_key("x")))); + assert_eq!(resp.status(), StatusCode::BAD_REQUEST); + } + + #[test] + fn config_malformed_envelope_maps_500() { + let resp = run_config(StubStore(Ok(Some("not json".to_owned())))); + assert_eq!(resp.status(), StatusCode::INTERNAL_SERVER_ERROR); + } + + #[test] + fn config_sha_mismatch_maps_500() { + // Valid JSON envelope shape but wrong sha → verify() fails. + let bad = r#"{"data":{"a":1},"generated_at":"t","sha256":"deadbeef","version":1}"#; + let resp = run_config(StubStore(Ok(Some(bad.to_owned())))); + assert_eq!(resp.status(), StatusCode::INTERNAL_SERVER_ERROR); + } + + #[test] + fn config_unknown_version_maps_500() { + let bad = r#"{"data":{},"generated_at":"t","sha256":"x","version":99}"#; + let resp = run_config(StubStore(Ok(Some(bad.to_owned())))); + assert_eq!(resp.status(), StatusCode::INTERNAL_SERVER_ERROR); + } } ``` +Notes: verify `ConfigStoreError` variant/constructor names (`unavailable`, +`invalid_key`, `internal`) against config_store.rs:177-199, and the +`crate::http::Response` / body-reading test helper the crate already uses. The +happy-path body assertion should use whatever body-collection helper the other +core tests use (e.g. a `block_on` over the body) — match existing conventions. + - [ ] **Step 3: Run tests to verify they fail** Run: `cargo test -p edgezero-core introspection` @@ -547,7 +780,9 @@ Expected: builds cleanly. - [ ] **Step 4: Verify a consumer still builds** -Run: `cargo build -p edgezero-core` then `cargo check -p app-demo-core --manifest-path examples/app-demo/Cargo.toml` (or `cd examples/app-demo && cargo check -p app-demo-core`). +`examples/app-demo` is `exclude`d from the root workspace (Cargo.toml:12), so it must be built from its own directory: + +Run: `cargo build -p edgezero-core` then `cd examples/app-demo && cargo check -p app-demo-core` Expected: builds; the generated `build_router` now sets manifest JSON. - [ ] **Step 5: Commit** @@ -643,35 +878,74 @@ description = "Registered route table" - [ ] **Step 2: Write the failing router-level test** -In app-demo-core's test module (colocated with `build_router`/`App`), add: +In app-demo-core's test module (colocated with `build_router`/`App`), add. A +routing miss ALSO returns 404 via `oneshot` (router.rs), so an `OK | NOT_FOUND` +assertion would pass even if `/config` were never wired. Instead, **seed a +`ConfigRegistry`** so a wired `/config` route returns 200, proving the trigger +exists, and assert the raw envelope `data` exposes the key-name (never a +resolved secret value): ```rust #[test] fn introspection_routes_are_wired() { use edgezero_core::body::Body; + use edgezero_core::config_store::{ConfigStore, ConfigStoreError, ConfigStoreHandle}; use edgezero_core::http::{request_builder, Method, StatusCode}; + use edgezero_core::store_registry::{ConfigRegistry, ConfigStoreBinding, StoreRegistry}; + use edgezero_core::blob_envelope::BlobEnvelope; + use async_trait::async_trait; use futures::executor::block_on; + use std::collections::BTreeMap; + use std::sync::Arc; let router = crate::build_router(); + + // manifest + routes need no config store. for path in ["/_app-demo/manifest", "/_app-demo/routes"] { let req = request_builder().method(Method::GET).uri(path).body(Body::empty()).unwrap(); let resp = block_on(router.oneshot(req)).unwrap(); assert_eq!(resp.status(), StatusCode::OK, "{path} should be 200"); assert_eq!(resp.headers().get("content-type").unwrap(), "application/json"); } - // /_app-demo/config is 404 without a populated config store, but must be routed - // (i.e. not a routing 404 with empty body). Assert it is reachable: - let req = request_builder().method(Method::GET).uri("/_app-demo/config").body(Body::empty()).unwrap(); + + // /config: seed a default config store with a valid envelope so a wired + // route returns 200 (a routing miss would be 404, proving nothing). + struct FixedStore(String); + #[async_trait(?Send)] + impl ConfigStore for FixedStore { + async fn get(&self, _key: &str) -> Result, ConfigStoreError> { + Ok(Some(self.0.clone())) + } + } + let data = serde_json::json!({ "greeting": "hi", "api_token": "demo_api_token" }); + let blob = serde_json::to_string(&BlobEnvelope::new(data, "2026-01-01T00:00:00Z".to_owned())).unwrap(); + let registry: ConfigRegistry = StoreRegistry::new( + [( + "app_config".to_owned(), + ConfigStoreBinding { + handle: ConfigStoreHandle::new(Arc::new(FixedStore(blob))), + default_key: "app_config".to_owned(), + }, + )].into_iter().collect::>(), + "app_config".to_owned(), + ); + let mut req = request_builder().method(Method::GET).uri("/_app-demo/config").body(Body::empty()).unwrap(); + req.extensions_mut().insert(registry); let resp = block_on(router.oneshot(req)).unwrap(); - assert!(matches!(resp.status(), StatusCode::OK | StatusCode::NOT_FOUND)); + assert_eq!(resp.status(), StatusCode::OK, "/config should be wired and 200 with a store"); + // (Collect the body and assert it contains "api_token":"demo_api_token" — + // the raw key-name — and NOT a resolved secret value. Use the app-demo + // test suite's existing body-collection helper.) } ``` -(Match the app-demo crate's existing test imports/module location; `build_router` is generated by `app!`.) +(Match the app-demo crate's existing test imports/module location; `build_router` is generated by `app!`. Confirm app-demo's default config store id is `app_config` per its `[stores.config]`.) - [ ] **Step 3: Run test to verify it fails, then passes** -Run: `cargo test -p app-demo-core introspection_routes_are_wired` +`examples/app-demo` is excluded from the root workspace, so run from its directory: + +Run: `cd examples/app-demo && cargo test -p app-demo-core introspection_routes_are_wired` Expected: initially FAILS if triggers not yet parsed/handler path unresolved; after Step 1 + Tasks 3-4, PASS. - [ ] **Step 4: Add the same rows to the generated-app template** @@ -762,6 +1036,15 @@ git add docs/guide/routing.md git commit -m "Docs: replace route-listing with introspection routes" ``` +> **Out-of-scope, flagged for decision (review finding #8):** CLI docs +> (`docs/guide/cli-reference.md:241`, `docs/guide/cli-walkthrough.md:153`) state +> that typed `config push` "strips secret fields", which reportedly contradicts +> the key-name envelope model (`examples/app-demo/.../config_flow.rs:206`). This +> is a **pre-existing** inaccuracy about `config push` semantics, independent of +> introspection routes, and the push behavior itself has not been re-verified +> here. It is intentionally excluded from this plan. If desired, correct it in a +> separate change after confirming the actual push behavior. + --- ### Task 8: Full verification (CI gates + app-demo smoke) @@ -781,7 +1064,14 @@ Expected: no warnings. - [ ] **Step 3: Workspace tests** Run: `cargo test --workspace --all-targets` -Expected: all pass, including the new manifest/router/introspection/app-demo tests. +Expected: all pass (manifest/router/introspection). **Note:** the root workspace +`exclude`s `examples/app-demo` (Cargo.toml:12), so this does NOT run the +app-demo tests — those are covered by Step 3b (matching CI's separate job). + +- [ ] **Step 3b: app-demo tests (separate workspace)** + +Run: `cd examples/app-demo && cargo test --workspace --all-targets` +Expected: all pass, including `introspection_routes_are_wired`. - [ ] **Step 4: Feature compilation** @@ -815,17 +1105,19 @@ gh pr ready 300 ## Self-Review **Spec coverage:** -- Manifest→JSON (baked, Serialize): Task 1 + Task 4. ✓ (errata: secret redaction, version/kind) -- Config→envelope data (secret-safe, verify): Task 3. ✓ +- Manifest→JSON (baked, Serialize): Task 1 + Task 4. ✓ (errata: secret redaction, version/kind, manual enum Serialize with wire casing) +- Config→envelope data (secret-safe, verify): Task 3, with full status-code coverage (200/404/400/503/500×3). ✓ - Routes→live index: Task 3. ✓ -- Router-chokepoint injection (no global/no adapter changes): Task 2. ✓ +- Router-chokepoint injection (no global/no adapter changes): Task 2, with handler + middleware visibility tests. ✓ - `RequestContext::introspection()`: Task 2. ✓ - `#[action]` self-alias: Task 3 Step 1. ✓ - Remove `enable_route_listing`/`/__edgezero/routes`: Task 5. ✓ -- Templates + app-demo default triggers under `/_/…`: Task 6. ✓ -- Docs update: Task 7. ✓ -- CI gates: Task 8. ✓ +- Templates + app-demo default triggers under `/_/…`: Task 6 (config test seeds a registry to prove wiring). ✓ +- Docs update: Task 7 (cli-doc drift flagged out-of-scope). ✓ +- CI gates incl. separate app-demo workspace: Task 8. ✓ + +**Review findings applied (2026-07-02):** enum serialization + casing test (blocking); owned `RedactedBinding` + removed nonexistent `ManifestAdapterDeployed` (blocking); `Response` from `crate::http` (blocking); full config status-code tests (high); app-demo config test seeds a registry and asserts 200 (high); `cd examples/app-demo` for excluded-workspace commands (high); middleware-visibility test (medium); cli-doc drift flagged, not silently absorbed (medium). -**Placeholder scan:** No TBD/TODO; every code step shows real code. Two verification notes ("confirm field names", "verify `{{{adapter_list}}}` name") are guardrails against drift, not missing content. +**Placeholder scan:** No TBD/TODO; every code step shows real code. Verification notes ("confirm `ConfigStoreError` constructor names", "verify `{{{adapter_list}}}` name", "match body-collection helper") are drift guardrails, not missing content. -**Type consistency:** `IntrospectionData { manifest_json: Option>, routes: Arc<[RouteInfo]> }`, `with_manifest_json(impl Into>)`, and `introspection() -> Option<&IntrospectionData>` are used identically across Tasks 2/3/6. Handler names `manifest`/`config`/`routes` match the trigger `handler = "edgezero_core::introspection::…"` strings in Task 6. +**Type consistency:** `IntrospectionData { manifest_json: Option>, routes: Arc<[RouteInfo]> }`, `with_manifest_json(impl Into>)`, and `introspection() -> Option<&IntrospectionData>` are used identically across Tasks 2/3/6. Handler names `manifest`/`config`/`routes` match the trigger `handler = "edgezero_core::introspection::…"` strings in Task 6. Manual enum `Serialize` (Task 1 Step 5a) matches the `Deserialize` wire forms. From 7301bcefcac19ba4b5c204b6563b2da6ab31c217 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Wed, 1 Jul 2026 20:30:54 -0700 Subject: [PATCH 04/74] Revise plan (round 2): real body assertions, oneshot config tests, body-mode rename, Internal->500, scaffold CI checks --- .../plans/2026-07-02-introspection-routes.md | 107 +++++++++++++----- 1 file changed, 78 insertions(+), 29 deletions(-) diff --git a/docs/superpowers/plans/2026-07-02-introspection-routes.md b/docs/superpowers/plans/2026-07-02-introspection-routes.md index 6073f166..a6150fd1 100644 --- a/docs/superpowers/plans/2026-07-02-introspection-routes.md +++ b/docs/superpowers/plans/2026-07-02-introspection-routes.md @@ -122,13 +122,16 @@ level = "info" let manifest: Manifest = toml::from_str(toml).unwrap(); let json = serde_json::to_value(&manifest).unwrap(); assert_eq!(json["triggers"]["http"][0]["methods"][0], "POST"); - assert_eq!(json["triggers"]["http"][0]["body_mode"], "buffered"); + // `body_mode` is serde-renamed to `body-mode` (manifest.rs:243), so the + // serialized key is `body-mode`, NOT `body_mode`. + assert_eq!(json["triggers"]["http"][0]["body-mode"], "buffered"); assert_eq!(json["logging"]["axum"]["level"], "info"); } ``` -(Adjust the `[logging.axum]`/`body-mode` key spellings to match the manifest's -actual serde field renames — verify against manifest.rs before running.) +(The `body-mode` key matches the `#[serde(rename = "body-mode")]` on +`ManifestHttpTrigger::body_mode`. Verify the `[logging.]` shape against +manifest.rs before running.) - [ ] **Step 2: Run test to verify it fails** @@ -490,9 +493,7 @@ struct RouteView { mod tests { use super::*; use crate::config_store::{ConfigStore, ConfigStoreError, ConfigStoreHandle}; - use crate::context::RequestContext; use crate::http::{request_builder, Method}; - use crate::params::PathParams; use crate::router::RouterService; use crate::store_registry::{ConfigRegistry, ConfigStoreBinding, StoreRegistry}; use async_trait::async_trait; @@ -520,8 +521,16 @@ mod tests { } } - // Build a request carrying a default ConfigRegistry backed by `store`, - // run it through the `config` handler, and return the response. + // Collect a buffered response body into JSON (introspection responses are + // always `Body::Once`). `Body::to_json` works on the buffered variant. + fn body_json(resp: crate::http::Response) -> serde_json::Value { + resp.into_body().to_json().expect("buffered JSON body") + } + + // Build a request carrying a default ConfigRegistry backed by `store`, and + // drive it THROUGH THE ROUTER via `oneshot` (which maps handler `EdgeError` + // to a response internally — so we neither import `IntoResponse` nor unwrap + // an error path by hand). fn run_config(store: StubStore) -> crate::http::Response { let registry: ConfigRegistry = StoreRegistry::new( [( @@ -535,14 +544,14 @@ mod tests { .collect::>(), "default".to_owned(), ); + let router = RouterService::builder().get("/c", config).build(); let mut request = request_builder() .method(Method::GET) .uri("/c") .body(Body::empty()) .unwrap(); request.extensions_mut().insert(registry); - let ctx = RequestContext::new(request, PathParams::default()); - block_on(super::config(ctx)).unwrap_or_else(|e| e.into_response().unwrap()) + block_on(router.oneshot(request)).unwrap() } fn valid_envelope_json(data: serde_json::Value) -> String { @@ -560,6 +569,8 @@ mod tests { let resp = block_on(router.oneshot(req)).unwrap(); assert_eq!(resp.status(), StatusCode::OK); assert_eq!(resp.headers().get("content-type").unwrap(), "application/json"); + // Body is the injected manifest JSON verbatim. + assert_eq!(body_json(resp), serde_json::json!({ "app": { "name": "t" } })); } #[test] @@ -568,6 +579,10 @@ mod tests { let req = request_builder().method(Method::GET).uri("/r").body(Body::empty()).unwrap(); let resp = block_on(router.oneshot(req)).unwrap(); assert_eq!(resp.status(), StatusCode::OK); + // Shape: [{ "method", "path" }] — the /r route itself is present. + let body = body_json(resp); + let arr = body.as_array().expect("routes array"); + assert!(arr.iter().any(|e| e["method"] == "GET" && e["path"] == "/r")); } #[test] @@ -583,10 +598,11 @@ mod tests { let data = serde_json::json!({ "greeting": "hi", "api_token": "demo_api_token" }); let resp = run_config(StubStore(Ok(Some(valid_envelope_json(data))))); assert_eq!(resp.status(), StatusCode::OK); - // Raw envelope `data` is returned verbatim: the secret field holds the - // KEY NAME, never a resolved value. - // (Read the body via the crate's test body helper and assert the JSON - // contains "api_token":"demo_api_token".) + // Raw envelope `data` verbatim: the secret field holds the KEY NAME, + // never a resolved value. + let body = body_json(resp); + assert_eq!(body["greeting"], "hi"); + assert_eq!(body["api_token"], "demo_api_token"); } #[test] @@ -607,6 +623,12 @@ mod tests { assert_eq!(resp.status(), StatusCode::BAD_REQUEST); } + #[test] + fn config_backend_internal_maps_500() { + let resp = run_config(StubStore(Err(ConfigStoreError::internal(anyhow::anyhow!("x"))))); + assert_eq!(resp.status(), StatusCode::INTERNAL_SERVER_ERROR); + } + #[test] fn config_malformed_envelope_maps_500() { let resp = run_config(StubStore(Ok(Some("not json".to_owned())))); @@ -630,11 +652,11 @@ mod tests { } ``` -Notes: verify `ConfigStoreError` variant/constructor names (`unavailable`, -`invalid_key`, `internal`) against config_store.rs:177-199, and the -`crate::http::Response` / body-reading test helper the crate already uses. The -happy-path body assertion should use whatever body-collection helper the other -core tests use (e.g. a `block_on` over the body) — match existing conventions. +Notes: the `StubStore::0 = Err(...)` arm is matched by variant, so the three +`ConfigStoreError` constructors (`unavailable`, `invalid_key`, `internal`) must +match config_store.rs:177-199. `body_json` relies on `Body::to_json` (body.rs) +and `http::Response::into_body`; both exist. The malformed/sha/version cases are +driven by raw strings so they don't depend on the stub's error arm. - [ ] **Step 3: Run tests to verify they fail** @@ -900,13 +922,21 @@ fn introspection_routes_are_wired() { let router = crate::build_router(); - // manifest + routes need no config store. - for path in ["/_app-demo/manifest", "/_app-demo/routes"] { - let req = request_builder().method(Method::GET).uri(path).body(Body::empty()).unwrap(); - let resp = block_on(router.oneshot(req)).unwrap(); - assert_eq!(resp.status(), StatusCode::OK, "{path} should be 200"); - assert_eq!(resp.headers().get("content-type").unwrap(), "application/json"); - } + // manifest: 200 + JSON body whose [app].name is "app-demo". + let req = request_builder().method(Method::GET).uri("/_app-demo/manifest").body(Body::empty()).unwrap(); + let resp = block_on(router.oneshot(req)).unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + assert_eq!(resp.headers().get("content-type").unwrap(), "application/json"); + let manifest_body: serde_json::Value = resp.into_body().to_json().unwrap(); + assert_eq!(manifest_body["app"]["name"], "app-demo"); + + // routes: 200 + [{method,path}] including the root route. + let req = request_builder().method(Method::GET).uri("/_app-demo/routes").body(Body::empty()).unwrap(); + let resp = block_on(router.oneshot(req)).unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + let routes_body: serde_json::Value = resp.into_body().to_json().unwrap(); + let arr = routes_body.as_array().expect("routes array"); + assert!(arr.iter().any(|e| e["method"] == "GET" && e["path"] == "/")); // /config: seed a default config store with a valid envelope so a wired // route returns 200 (a routing miss would be 404, proving nothing). @@ -933,9 +963,10 @@ fn introspection_routes_are_wired() { req.extensions_mut().insert(registry); let resp = block_on(router.oneshot(req)).unwrap(); assert_eq!(resp.status(), StatusCode::OK, "/config should be wired and 200 with a store"); - // (Collect the body and assert it contains "api_token":"demo_api_token" — - // the raw key-name — and NOT a resolved secret value. Use the app-demo - // test suite's existing body-collection helper.) + // Raw envelope `data`: secret field holds the KEY NAME, not a resolved value. + let config_body: serde_json::Value = resp.into_body().to_json().unwrap(); + assert_eq!(config_body["api_token"], "demo_api_token"); + assert_eq!(config_body["greeting"], "hi"); } ``` @@ -1073,6 +1104,22 @@ app-demo tests — those are covered by Step 3b (matching CI's separate job). Run: `cd examples/app-demo && cargo test --workspace --all-targets` Expected: all pass, including `introspection_routes_are_wired`. +- [ ] **Step 3c: Generated-project build (template surface)** + +Task 6 edits the generated-app template, so exercise CI's ignored end-to-end +scaffold-and-build test (test.yml): + +Run: `cargo test -p edgezero-cli --test generated_project_builds -- --ignored` +Expected: a project scaffolded from the template (now with the three +introspection triggers) compiles. + +- [ ] **Step 3d: Nested AppConfig audit (template + app-demo surface)** + +The template and app-demo are both audited by CI (test.yml): + +Run: `cargo run -q --bin check_no_nested_app_config --features nested-app-config-check -- examples/app-demo crates/edgezero-cli/src/templates` +Expected: passes (the introspection triggers add no nested `AppConfig`). + - [ ] **Step 4: Feature compilation** Run: `cargo check --workspace --all-targets --features "fastly cloudflare spin"` @@ -1116,7 +1163,9 @@ gh pr ready 300 - Docs update: Task 7 (cli-doc drift flagged out-of-scope). ✓ - CI gates incl. separate app-demo workspace: Task 8. ✓ -**Review findings applied (2026-07-02):** enum serialization + casing test (blocking); owned `RedactedBinding` + removed nonexistent `ManifestAdapterDeployed` (blocking); `Response` from `crate::http` (blocking); full config status-code tests (high); app-demo config test seeds a registry and asserts 200 (high); `cd examples/app-demo` for excluded-workspace commands (high); middleware-visibility test (medium); cli-doc drift flagged, not silently absorbed (medium). +**Review findings applied (round 1):** enum serialization + casing test (blocking); owned `RedactedBinding` + removed nonexistent `ManifestAdapterDeployed` (blocking); `Response` from `crate::http` (blocking); full config status-code tests (high); app-demo config test seeds a registry and asserts 200 (high); `cd examples/app-demo` for excluded-workspace commands (high); middleware-visibility test (medium); cli-doc drift flagged, not silently absorbed (medium). + +**Review findings applied (round 2):** real body assertions for manifest (equality), routes (`[{method,path}]` shape), and config (`api_token` key-name present, secret-safe) in Tasks 3 & 6 (high); `run_config` now routes through `oneshot` so no `IntoResponse` import is needed, and unused test imports dropped (high); `body-mode` serde-rename fixed in the casing test (high); `ConfigStoreError::Internal → 500` test added (medium); Task 8 gains generated-project-build (`--ignored`) and nested-AppConfig-audit steps matching CI's template-surface jobs (medium). **Placeholder scan:** No TBD/TODO; every code step shows real code. Verification notes ("confirm `ConfigStoreError` constructor names", "verify `{{{adapter_list}}}` name", "match body-collection helper") are drift guardrails, not missing content. From 4254ed41bbc989fb6cf9373a205061f4de62a3c4 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Wed, 1 Jul 2026 20:40:56 -0700 Subject: [PATCH 05/74] Revise plan (round 3): single-filter test commands, scoped doc greps, roadmap update, macro crate test --- .../plans/2026-07-02-introspection-routes.md | 46 +++++++++++++++---- 1 file changed, 36 insertions(+), 10 deletions(-) diff --git a/docs/superpowers/plans/2026-07-02-introspection-routes.md b/docs/superpowers/plans/2026-07-02-introspection-routes.md index a6150fd1..a9b8a66c 100644 --- a/docs/superpowers/plans/2026-07-02-introspection-routes.md +++ b/docs/superpowers/plans/2026-07-02-introspection-routes.md @@ -135,7 +135,7 @@ manifest.rs before running.) - [ ] **Step 2: Run test to verify it fails** -Run: `cargo test -p edgezero-core serializes_manifest_and_redacts_secret_values serializes_enums_with_wire_casing` +Run: `cargo test -p edgezero-core serializes_` (one filter matching both `serializes_manifest_and_redacts_secret_values` and `serializes_enums_with_wire_casing` — Cargo takes a single test-name filter) Expected: FAIL to compile — `Manifest` does not implement `Serialize`; `ManifestApp` has no `version`/`kind`. - [ ] **Step 3: Add `version`/`kind` to `ManifestApp`** @@ -264,7 +264,7 @@ Add `#[serde(skip_serializing_if = "...")]` to keep output clean where fields ar - [ ] **Step 6: Run tests** -Run: `cargo test -p edgezero-core serializes_manifest_and_redacts_secret_values serializes_enums_with_wire_casing` +Run: `cargo test -p edgezero-core serializes_` (one filter matching both `serializes_manifest_and_redacts_secret_values` and `serializes_enums_with_wire_casing` — Cargo takes a single test-name filter) Expected: PASS. Then: `cargo test -p edgezero-core manifest` — Expected: all existing manifest tests still PASS. @@ -372,7 +372,7 @@ existing middleware tests in this crate use.) - [ ] **Step 2: Run test to verify it fails** -Run: `cargo test -p edgezero-core dispatch_injects_introspection_data middleware_sees_introspection_data` +Run: `cargo test -p edgezero-core introspection_data` (one filter matching both `dispatch_injects_introspection_data` and `middleware_sees_introspection_data`) Expected: FAIL to compile — `with_manifest_json` and `RequestContext::introspection` do not exist. - [ ] **Step 3: Add `IntrospectionData` + builder field/setter** @@ -431,7 +431,7 @@ In `context.rs`, near `config_store_default_binding`: - [ ] **Step 6: Run tests** -Run: `cargo test -p edgezero-core dispatch_injects_introspection_data middleware_sees_introspection_data` +Run: `cargo test -p edgezero-core introspection_data` (one filter matching both `dispatch_injects_introspection_data` and `middleware_sees_introspection_data`) Expected: PASS. Then: `cargo test -p edgezero-core router` — Expected: PASS (existing route-listing tests still pass; they are removed in Task 5). @@ -795,10 +795,22 @@ Then in the emitted `build_router()` (the `quote! { ... pub fn build_router() .. } ``` -- [ ] **Step 3: Verify the macro crate compiles** +- [ ] **Step 3: Build and test the macro crate** Run: `cargo build -p edgezero-macros` Expected: builds cleanly. +Then: `cargo test -p edgezero-macros` +Expected: PASS — the existing `app_config_derive` + `tests/ui` trybuild suite +still passes (CLAUDE.md requires `cargo test` after any code change). + +> **Macro-output coverage:** the spec calls for macro expansion coverage +> (spec:303). The `app!` output for this change (`with_manifest_json()`) is +> exercised end-to-end in **Task 6**: app-demo's `introspection_routes_are_wired` +> test builds the real `app!`-generated `build_router()` and asserts +> `/_app-demo/manifest` returns the baked JSON with `[app].name == "app-demo"`. +> That is a stronger check than a string-match expansion test, so no separate +> trybuild case is added for the positive path. (trybuild remains the right tool +> only for compile-fail cases, none of which this change introduces.) - [ ] **Step 4: Verify a consumer still builds** @@ -1029,13 +1041,16 @@ git commit -m "Wire default introspection triggers into app-demo and generated a **Files:** - Modify: `docs/guide/routing.md` (around :118, the route-listing reference) +- Modify: `docs/guide/roadmap.md:16` ("route listing + body-mode behavior") **Interfaces:** none (documentation only). -- [ ] **Step 1: Locate the reference** +- [ ] **Step 1: Locate the references** -Run: `grep -n "route listing\|__edgezero/routes\|enable_route_listing" docs/guide/routing.md` -Expected: a reference around line 118. Do NOT touch any `.__edgezero_chunks` docs (unrelated). +Run: `grep -rn "route listing\|__edgezero/routes\|enable_route_listing" docs/guide` +Expected: `docs/guide/routing.md` (~:118) and `docs/guide/roadmap.md:16`. Scope the +grep to `docs/guide` (NOT `docs/`) so it does not match this plan and the spec +under `docs/superpowers`. Do NOT touch any `.__edgezero_chunks` docs (unrelated). - [ ] **Step 2: Replace with introspection-route docs** @@ -1055,10 +1070,19 @@ methods = ["GET"] handler = "edgezero_core::introspection::manifest" ``` +- [ ] **Step 2b: Update roadmap.md** + +In `docs/guide/roadmap.md:16`, the "Example coverage" bullet ends with "…logging +precedence, and route listing + body-mode behavior". Replace "route listing" +with "introspection routes" so it reads "…logging precedence, and introspection +routes + body-mode behavior". + - [ ] **Step 3: Verify no stale references remain** -Run: `grep -rn "enable_route_listing\|__edgezero/routes" docs/` -Expected: no matches (excluding `.__edgezero_chunks` which is a different token — verify the grep does not match it; if it does, refine to `__edgezero/routes`). +Run: `grep -rn "enable_route_listing\|__edgezero/routes\|route listing" docs/guide` +Expected: no matches under `docs/guide` (scope to `docs/guide`, not `docs/`, so +the plan/spec under `docs/superpowers` are not matched; `.__edgezero_chunks` is a +different token and should not appear). - [ ] **Step 4: Commit** @@ -1165,6 +1189,8 @@ gh pr ready 300 **Review findings applied (round 1):** enum serialization + casing test (blocking); owned `RedactedBinding` + removed nonexistent `ManifestAdapterDeployed` (blocking); `Response` from `crate::http` (blocking); full config status-code tests (high); app-demo config test seeds a registry and asserts 200 (high); `cd examples/app-demo` for excluded-workspace commands (high); middleware-visibility test (medium); cli-doc drift flagged, not silently absorbed (medium). +**Review findings applied (round 3):** single-filter `cargo test` commands (Cargo takes one test-name filter) in Tasks 1 & 2 (high); Task 7 stale-doc greps scoped to `docs/guide` so they don't match the plan/spec under `docs/superpowers` (high); Task 7 also updates `docs/guide/roadmap.md:16` "route listing" phrasing (medium); Task 4 now runs `cargo test -p edgezero-macros` per CLAUDE.md, with macro-output coverage delegated to Task 6's end-to-end assertion rather than a brittle expansion test (medium). + **Review findings applied (round 2):** real body assertions for manifest (equality), routes (`[{method,path}]` shape), and config (`api_token` key-name present, secret-safe) in Tasks 3 & 6 (high); `run_config` now routes through `oneshot` so no `IntoResponse` import is needed, and unused test imports dropped (high); `body-mode` serde-rename fixed in the casing test (high); `ConfigStoreError::Internal → 500` test added (medium); Task 8 gains generated-project-build (`--ignored`) and nested-AppConfig-audit steps matching CI's template-surface jobs (medium). **Placeholder scan:** No TBD/TODO; every code step shows real code. Verification notes ("confirm `ConfigStoreError` constructor names", "verify `{{{adapter_list}}}` name", "match body-collection helper") are drift guardrails, not missing content. From 9011684699d7fde0a3e8a4399d975686e90da003 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Wed, 1 Jul 2026 20:45:26 -0700 Subject: [PATCH 06/74] Plan: stage roadmap.md alongside routing.md in Task 7 commit --- docs/superpowers/plans/2026-07-02-introspection-routes.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/superpowers/plans/2026-07-02-introspection-routes.md b/docs/superpowers/plans/2026-07-02-introspection-routes.md index a9b8a66c..6aada2a7 100644 --- a/docs/superpowers/plans/2026-07-02-introspection-routes.md +++ b/docs/superpowers/plans/2026-07-02-introspection-routes.md @@ -1087,7 +1087,7 @@ different token and should not appear). - [ ] **Step 4: Commit** ```bash -git add docs/guide/routing.md +git add docs/guide/routing.md docs/guide/roadmap.md git commit -m "Docs: replace route-listing with introspection routes" ``` From 5ab3546c59b2170a77e2be1d0b395d77dfb55839 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Wed, 1 Jul 2026 21:05:20 -0700 Subject: [PATCH 07/74] Make Manifest serializable with secret-value redaction --- crates/edgezero-core/src/manifest.rs | 177 +++++++++++++++++++++++---- 1 file changed, 153 insertions(+), 24 deletions(-) diff --git a/crates/edgezero-core/src/manifest.rs b/crates/edgezero-core/src/manifest.rs index c7746530..57ef3221 100644 --- a/crates/edgezero-core/src/manifest.rs +++ b/crates/edgezero-core/src/manifest.rs @@ -1,6 +1,6 @@ use log::LevelFilter; use serde::de::Error as DeError; -use serde::Deserialize; +use serde::{Deserialize, Serialize}; use std::collections::{BTreeMap, BTreeSet}; use std::path::{Path, PathBuf}; use std::sync::Arc; @@ -83,7 +83,7 @@ impl ManifestLoader { } } -#[derive(Debug, Deserialize, Validate)] +#[derive(Debug, Deserialize, Serialize, Validate)] #[validate(schema(function = "validate_manifest_adapter_keys_case_unique"))] #[expect( clippy::partial_pub_fields, @@ -214,20 +214,26 @@ impl Manifest { } } -#[derive(Debug, Default, Deserialize, Validate)] +#[derive(Debug, Default, Deserialize, Serialize, Validate)] #[non_exhaustive] pub struct ManifestApp { #[serde(default)] #[validate(length(min = 1_u64))] pub entry: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[validate(length(min = 1_u64))] + pub kind: Option, #[serde(default)] pub middleware: Vec, #[serde(default)] #[validate(length(min = 1_u64))] pub name: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[validate(length(min = 1_u64))] + pub version: Option, } -#[derive(Debug, Default, Deserialize, Validate)] +#[derive(Debug, Default, Deserialize, Serialize, Validate)] #[non_exhaustive] pub struct ManifestTriggers { #[serde(default)] @@ -235,7 +241,7 @@ pub struct ManifestTriggers { pub http: Vec, } -#[derive(Clone, Debug, Deserialize, Validate)] +#[derive(Clone, Debug, Deserialize, Serialize, Validate)] #[non_exhaustive] pub struct ManifestHttpTrigger { #[serde(default)] @@ -273,10 +279,10 @@ impl ManifestHttpTrigger { } } -#[derive(Debug, Default, Deserialize, Validate)] +#[derive(Debug, Default, Deserialize, Serialize, Validate)] #[non_exhaustive] pub struct ManifestEnvironment { - #[serde(default)] + #[serde(default, serialize_with = "serialize_secrets")] #[validate(nested)] pub secrets: Vec, #[serde(default)] @@ -284,7 +290,7 @@ pub struct ManifestEnvironment { pub variables: Vec, } -#[derive(Debug, Deserialize, Validate)] +#[derive(Debug, Deserialize, Serialize, Validate)] #[non_exhaustive] pub struct ManifestBinding { #[serde(default)] @@ -316,6 +322,14 @@ impl ManifestBinding { } } +#[derive(Clone, Debug)] +pub struct ResolvedEnvironmentBinding { + pub description: Option, + pub env: String, + pub name: String, + pub value: Option, +} + impl ResolvedEnvironmentBinding { fn from_manifest(binding: &ManifestBinding) -> Self { Self { @@ -327,21 +341,13 @@ impl ResolvedEnvironmentBinding { } } -#[derive(Clone, Debug)] -pub struct ResolvedEnvironmentBinding { - pub description: Option, - pub env: String, - pub name: String, - pub value: Option, -} - #[derive(Clone, Debug, Default)] pub struct ResolvedEnvironment { pub secrets: Vec, pub variables: Vec, } -#[derive(Debug, Default, Deserialize, Validate)] +#[derive(Debug, Default, Deserialize, Serialize, Validate)] #[non_exhaustive] #[validate(schema(function = "validate_manifest_adapter"))] pub struct ManifestAdapter { @@ -365,7 +371,7 @@ pub struct ManifestAdapter { pub logging: ManifestLoggingConfig, } -#[derive(Debug, Default, Deserialize, Validate)] +#[derive(Debug, Default, Deserialize, Serialize, Validate)] #[non_exhaustive] #[validate(schema(function = "validate_manifest_adapter_definition"))] pub struct ManifestAdapterDefinition { @@ -402,7 +408,7 @@ pub struct ManifestAdapterDefinition { pub port: Option, } -#[derive(Debug, Default, Deserialize, Validate)] +#[derive(Debug, Default, Deserialize, Serialize, Validate)] #[non_exhaustive] pub struct ManifestAdapterBuild { #[serde(default)] @@ -415,7 +421,7 @@ pub struct ManifestAdapterBuild { pub target: Option, } -#[derive(Debug, Default, Deserialize, Validate)] +#[derive(Debug, Default, Deserialize, Serialize, Validate)] #[non_exhaustive] pub struct ManifestAdapterCommands { /// Per-project override for `edgezero auth login --adapter `. @@ -457,7 +463,7 @@ pub struct ManifestAdapterCommands { /// adapter sections, etc.) already reject legacy fields below this /// level, so adding the rejection HERE seals the only remaining /// silent-typo path. -#[derive(Debug, Default, Deserialize, Validate)] +#[derive(Debug, Default, Deserialize, Serialize, Validate)] #[serde(deny_unknown_fields)] #[non_exhaustive] pub struct ManifestStores { @@ -479,7 +485,7 @@ pub struct ManifestStores { /// tuning. Platform-specific runtime config (store names, tuning) is supplied /// out of band; in this interim model a store's name resolves to its logical /// [`StoreDeclaration::default_id`]. -#[derive(Debug, Deserialize, Validate)] +#[derive(Debug, Deserialize, Serialize, Validate)] #[non_exhaustive] #[validate(schema(function = "validate_store_declaration"))] pub struct StoreDeclaration { @@ -516,7 +522,7 @@ impl StoreDeclaration { // Logging (unchanged) // --------------------------------------------------------------------------- -#[derive(Debug, Default, Deserialize, Validate)] +#[derive(Debug, Default, Deserialize, Serialize, Validate)] #[non_exhaustive] pub struct ManifestLogging { #[serde(flatten)] @@ -524,7 +530,7 @@ pub struct ManifestLogging { pub adapters: BTreeMap, } -#[derive(Debug, Default, Deserialize, Clone, Validate)] +#[derive(Debug, Default, Deserialize, Serialize, Clone, Validate)] #[non_exhaustive] pub struct ManifestLoggingConfig { #[serde(default)] @@ -634,6 +640,13 @@ impl<'de> Deserialize<'de> for HttpMethod { } } +impl serde::Serialize for HttpMethod { + #[inline] + fn serialize(&self, serializer: S) -> Result { + serializer.serialize_str(self.as_str()) + } +} + #[derive(Clone, Debug, Eq, PartialEq)] #[non_exhaustive] pub enum BodyMode { @@ -664,6 +677,16 @@ impl<'de> Deserialize<'de> for BodyMode { } } +impl serde::Serialize for BodyMode { + #[inline] + fn serialize(&self, serializer: S) -> Result { + serializer.serialize_str(match self { + Self::Buffered => "buffered", + Self::Stream => "stream", + }) + } +} + #[derive(Clone, Copy, Debug, Eq, PartialEq, Default)] #[non_exhaustive] pub enum LogLevel { @@ -734,6 +757,46 @@ impl<'de> Deserialize<'de> for LogLevel { } } +impl serde::Serialize for LogLevel { + #[inline] + fn serialize(&self, serializer: S) -> Result { + serializer.serialize_str(self.as_str()) + } +} + +/// Serialize a `[[environment.secrets]]` list without exposing `value`. +/// Secret bindings share `ManifestBinding` with variables, whose `value` +/// is safe to emit; secret values must never appear in manifest output. +fn serialize_secrets(secrets: &[ManifestBinding], serializer: S) -> Result +where + S: serde::Serializer, +{ + use serde::ser::SerializeSeq as _; + + #[derive(Serialize)] + struct RedactedBinding { + #[serde(skip_serializing_if = "Vec::is_empty")] + adapters: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + description: Option, + #[serde(skip_serializing_if = "Option::is_none")] + env: Option, + name: String, + // `value` intentionally omitted. + } + + let mut seq = serializer.serialize_seq(Some(secrets.len()))?; + for binding in secrets { + seq.serialize_element(&RedactedBinding { + adapters: binding.adapters.clone(), + description: binding.description.clone(), + env: binding.env.clone(), + name: binding.name.clone(), + })?; + } + seq.end() +} + fn resolve_root_path(path: &Path, cwd: &Path) -> PathBuf { match path.parent() { Some(parent) if parent.as_os_str().is_empty() => cwd.to_path_buf(), @@ -996,6 +1059,72 @@ env = "APP_TOKEN" assert_eq!(manifest.app.name.as_deref(), Some("demo")); } + #[test] + fn serializes_manifest_and_redacts_secret_values() { + let toml = r#" +[app] +name = "t" +version = "0.1.0" +kind = "http" + +[[triggers.http]] +id = "root" +path = "/" +methods = ["GET"] +handler = "t::handlers::root" + +[[environment.variables]] +name = "LOG_LEVEL" +value = "info" + +[[environment.secrets]] +name = "API_TOKEN" +value = "super-secret-value" +"#; + let manifest: Manifest = toml::from_str(toml).unwrap(); + let json = serde_json::to_value(&manifest).unwrap(); + + // [app] version/kind round-trip + assert_eq!(json["app"]["version"], "0.1.0"); + assert_eq!(json["app"]["kind"], "http"); + // variables keep their value + assert_eq!(json["environment"]["variables"][0]["value"], "info"); + // secrets NEVER expose value + let secret = &json["environment"]["secrets"][0]; + assert_eq!(secret["name"], "API_TOKEN"); + assert!( + secret.get("value").is_none(), + "secret value must be redacted" + ); + // Enums serialize to their wire strings, not Rust variant names. + assert_eq!(json["triggers"]["http"][0]["methods"][0], "GET"); + } + + #[test] + fn serializes_enums_with_wire_casing() { + let toml = r#" +[app] +name = "t" + +[[triggers.http]] +id = "r" +path = "/" +methods = ["POST"] +handler = "t::h::r" +body-mode = "buffered" + +[logging.axum] +level = "info" +"#; + let manifest: Manifest = toml::from_str(toml).unwrap(); + let json = serde_json::to_value(&manifest).unwrap(); + assert_eq!(json["triggers"]["http"][0]["methods"][0], "POST"); + // `body_mode` is serde-renamed to `body-mode` (manifest.rs:243), so the + // serialized key is `body-mode`, NOT `body_mode`. + assert_eq!(json["triggers"]["http"][0]["body-mode"], "buffered"); + assert_eq!(json["logging"]["axum"]["level"], "info"); + } + #[test] fn try_load_from_str_rejects_invalid_toml() { let err = ManifestLoader::try_load_from_str("not a [valid manifest\n") From d09c068ca94a217898a0cf6af6d9d17827ad823d Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Wed, 1 Jul 2026 21:31:04 -0700 Subject: [PATCH 08/74] Inject IntrospectionData at router dispatch chokepoint --- crates/edgezero-core/src/context.rs | 8 +++ crates/edgezero-core/src/router.rs | 101 +++++++++++++++++++++++++++- 2 files changed, 107 insertions(+), 2 deletions(-) diff --git a/crates/edgezero-core/src/context.rs b/crates/edgezero-core/src/context.rs index 56fea46c..d3656e9f 100644 --- a/crates/edgezero-core/src/context.rs +++ b/crates/edgezero-core/src/context.rs @@ -3,6 +3,7 @@ use crate::error::EdgeError; use crate::http::Request; use crate::params::PathParams; use crate::proxy::ProxyHandle; +use crate::router::IntrospectionData; use crate::store_registry::{ BoundConfigStore, BoundKvStore, BoundSecretStore, ConfigRegistry, ConfigStoreBinding, KvRegistry, SecretRegistry, StoreRegistry, @@ -90,6 +91,13 @@ impl RequestContext { self.request } + /// The per-request [`IntrospectionData`] injected by the router, if any. + #[must_use] + #[inline] + pub fn introspection(&self) -> Option<&IntrospectionData> { + self.request.extensions().get::() + } + /// # Errors /// Returns [`EdgeError::bad_request`] if the body is not valid JSON for `T`. #[inline] diff --git a/crates/edgezero-core/src/router.rs b/crates/edgezero-core/src/router.rs index 18e242d7..201b39d7 100644 --- a/crates/edgezero-core/src/router.rs +++ b/crates/edgezero-core/src/router.rs @@ -64,6 +64,15 @@ impl RouteInfo { } } +/// Per-request introspection payload injected by [`RouterInner::dispatch`]. +#[derive(Clone)] +pub struct IntrospectionData { + /// The app manifest serialized to JSON at compile time by `app!`. + pub manifest_json: Option>, + /// Every registered route, in registration order. + pub routes: Arc<[RouteInfo]>, +} + #[derive(Serialize)] struct RouteListingEntry { method: String, @@ -78,6 +87,7 @@ enum RouteMatch<'route> { #[derive(Default)] pub struct RouterBuilder { + manifest_json: Option>, middlewares: Vec, route_info: Vec, route_listing_path: Option, @@ -157,7 +167,12 @@ impl RouterBuilder { .unwrap_or_else(|err| panic!("duplicate route definition for {path}: {err}")); } - RouterService::new(self.routes, self.middlewares, route_index) + RouterService::new( + self.routes, + self.middlewares, + route_index, + self.manifest_json, + ) } #[must_use] @@ -255,16 +270,29 @@ impl RouterBuilder { self.add_route(path, method, handler); self } + + #[must_use] + #[inline] + pub fn with_manifest_json>>(mut self, json: S) -> Self { + self.manifest_json = Some(json.into()); + self + } } struct RouterInner { + manifest_json: Option>, middlewares: Vec, route_index: Arc<[RouteInfo]>, routes: HashMap>, } impl RouterInner { - async fn dispatch(&self, request: Request) -> Result { + async fn dispatch(&self, mut request: Request) -> Result { + request.extensions_mut().insert(IntrospectionData { + manifest_json: self.manifest_json.clone(), + routes: Arc::clone(&self.route_index), + }); + let method = request.method().clone(); let path = request.uri().path().to_owned(); @@ -344,9 +372,11 @@ impl RouterService { routes: HashMap>, middlewares: Vec, route_index: Arc<[RouteInfo]>, + manifest_json: Option>, ) -> Self { Self { inner: Arc::new(RouterInner { + manifest_json, middlewares, route_index, routes, @@ -764,6 +794,73 @@ mod tests { assert!(matches!(ready, Poll::Ready(Ok(())))); } + #[test] + fn dispatch_injects_introspection_data() { + let seen: Arc>> = Arc::new(Mutex::new(None)); + let seen_capture = Arc::clone(&seen); + + let handler = move |ctx: RequestContext| { + let seen_inner = Arc::clone(&seen_capture); + async move { + let data = ctx.introspection().expect("introspection data present"); + *seen_inner.lock().unwrap() = + Some((data.manifest_json.is_some(), data.routes.len())); + Ok::<_, EdgeError>("ok") + } + }; + + let router = RouterService::builder() + .with_manifest_json("{\"app\":{\"name\":\"t\"}}") + .get("/", handler) + .build(); + + let request = request_builder() + .method(Method::GET) + .uri("/") + .body(Body::empty()) + .unwrap(); + block_on(router.oneshot(request)).unwrap(); + + let (had_manifest, route_count) = seen.lock().unwrap().expect("handler ran"); + assert!(had_manifest, "manifest_json should be injected"); + assert_eq!(route_count, 1); + } + + #[test] + fn middleware_sees_introspection_data() { + struct Probe(Arc>); + #[async_trait::async_trait(?Send)] + impl Middleware for Probe { + async fn handle( + &self, + ctx: RequestContext, + next: Next<'_>, + ) -> Result { + *self.0.lock().unwrap() = ctx.introspection().is_some(); + next.run(ctx).await + } + } + + let saw = Arc::new(Mutex::new(false)); + let router = RouterService::builder() + .with_manifest_json("{}") + .middleware(Probe(Arc::clone(&saw))) + .get("/", |_ctx: RequestContext| async { + Ok::<_, EdgeError>("ok") + }) + .build(); + let request = request_builder() + .method(Method::GET) + .uri("/") + .body(Body::empty()) + .unwrap(); + block_on(router.oneshot(request)).unwrap(); + assert!( + *saw.lock().unwrap(), + "middleware should see introspection data" + ); + } + #[test] fn streams_body_through_router() { use bytes::Bytes; From beed0a40b78074189658126b904ad6250204079c Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Wed, 1 Jul 2026 21:36:55 -0700 Subject: [PATCH 09/74] Add edgezero_core::introspection handlers (manifest/config/routes) --- crates/edgezero-core/src/introspection.rs | 264 ++++++++++++++++++++++ crates/edgezero-core/src/lib.rs | 5 + 2 files changed, 269 insertions(+) create mode 100644 crates/edgezero-core/src/introspection.rs diff --git a/crates/edgezero-core/src/introspection.rs b/crates/edgezero-core/src/introspection.rs new file mode 100644 index 00000000..dde4d715 --- /dev/null +++ b/crates/edgezero-core/src/introspection.rs @@ -0,0 +1,264 @@ +//! Framework-supplied introspection handlers. Bind via `[[triggers.http]]`: +//! `handler = "edgezero_core::introspection::manifest"` etc. + +use crate::blob_envelope::BlobEnvelope; +use crate::body::Body; +use crate::context::RequestContext; +use crate::error::EdgeError; +// NOTE: `Response` is an HTTP alias exported from `crate::http`, NOT +// `crate::response` (response.rs itself imports it from crate::http). +use crate::http::{response_builder, Response, StatusCode}; +use edgezero_core::action; +use serde::Serialize; + +#[derive(Serialize)] +struct RouteView { + method: String, + path: String, +} + +fn json_response(status: StatusCode, body: Body) -> Result { + response_builder() + .status(status) + .header("content-type", "application/json") + .body(body) + .map_err(EdgeError::internal) +} + +/// GET — the app manifest as JSON (baked at compile time by `app!`). +#[action] +pub async fn manifest(ctx: RequestContext) -> Result { + let json = ctx + .introspection() + .and_then(|data| data.manifest_json.clone()) + .ok_or_else(|| { + EdgeError::internal(anyhow::anyhow!("manifest introspection data missing")) + })?; + json_response(StatusCode::OK, Body::text(json.to_string())) +} + +/// GET — `[{ "method", "path" }]` for every registered route. +#[action] +pub async fn routes(ctx: RequestContext) -> Result { + let views: Vec = ctx + .introspection() + .map(|data| { + data.routes + .iter() + .map(|route| RouteView { + method: route.method().as_str().to_owned(), + path: route.path().to_owned(), + }) + .collect() + }) + .unwrap_or_default(); + let body = Body::json(&views).map_err(EdgeError::internal)?; + json_response(StatusCode::OK, body) +} + +/// GET — the default config-store envelope `data` (secret-safe: secret +/// fields remain unresolved key-name references). +#[action] +pub async fn config(ctx: RequestContext) -> Result { + let binding = ctx + .config_store_default_binding() + .ok_or_else(|| EdgeError::not_found("no default config store registered"))?; + // ConfigStoreError → EdgeError preserves 503/400/500 (see extractor.rs). + let raw = binding + .handle + .get(&binding.default_key) + .await + .map_err(EdgeError::from)? + .ok_or_else(|| EdgeError::not_found("no config blob in default store"))?; + let envelope: BlobEnvelope = serde_json::from_str(&raw) + .map_err(|err| EdgeError::internal(anyhow::anyhow!("envelope parse failed: {err}")))?; + envelope.verify().map_err(|err| { + EdgeError::internal(anyhow::anyhow!("envelope verification failed: {err}")) + })?; + let body = Body::json(&envelope.into_data()).map_err(EdgeError::internal)?; + json_response(StatusCode::OK, body) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::config_store::{ConfigStore, ConfigStoreError, ConfigStoreHandle}; + use crate::http::{request_builder, Method, Response}; + use crate::router::RouterService; + use crate::store_registry::{ConfigRegistry, ConfigStoreBinding, StoreRegistry}; + use async_trait::async_trait; + use futures::executor::block_on; + use std::collections::BTreeMap; + use std::sync::Arc; + + // A config store returning a fixed result for `get`, used to drive the + // config handler's status-code mapping. Mirrors the pattern in + // extractor.rs::config_extractor_resolves_from_registry. + struct StubStore(Result, ConfigStoreError>); + #[async_trait(?Send)] + impl ConfigStore for StubStore { + async fn get(&self, _key: &str) -> Result, ConfigStoreError> { + match &self.0 { + Ok(val) => Ok(val.clone()), + Err(ConfigStoreError::Unavailable { .. }) => { + Err(ConfigStoreError::unavailable("down")) + } + Err(ConfigStoreError::InvalidKey { .. }) => { + Err(ConfigStoreError::invalid_key("bad")) + } + Err(_) => Err(ConfigStoreError::internal(anyhow::anyhow!("boom"))), + } + } + } + + // Collect a buffered response body into JSON (introspection responses are + // always `Body::Once`). `Body::to_json` works on the buffered variant. + fn body_json(resp: Response) -> serde_json::Value { + resp.into_body().to_json().expect("buffered JSON body") + } + + // Build a request carrying a default ConfigRegistry backed by `store`, and + // drive it THROUGH THE ROUTER via `oneshot` (which maps handler `EdgeError` + // to a response internally — so we neither import `IntoResponse` nor unwrap + // an error path by hand). + fn run_config(store: StubStore) -> Response { + let registry: ConfigRegistry = StoreRegistry::new( + [( + "default".to_owned(), + ConfigStoreBinding { + handle: ConfigStoreHandle::new(Arc::new(store)), + default_key: "default".to_owned(), + }, + )] + .into_iter() + .collect::>(), + "default".to_owned(), + ); + let router = RouterService::builder().get("/c", config).build(); + let mut request = request_builder() + .method(Method::GET) + .uri("/c") + .body(Body::empty()) + .unwrap(); + request.extensions_mut().insert(registry); + block_on(router.oneshot(request)).unwrap() + } + + fn valid_envelope_json(data: serde_json::Value) -> String { + // Build a real envelope so sha/version are correct. + serde_json::to_string(&BlobEnvelope::new(data, "2026-01-01T00:00:00Z".to_owned())).unwrap() + } + + #[test] + fn manifest_returns_injected_json() { + let router = RouterService::builder() + .with_manifest_json("{\"app\":{\"name\":\"t\"}}") + .get("/m", manifest) + .build(); + let req = request_builder() + .method(Method::GET) + .uri("/m") + .body(Body::empty()) + .unwrap(); + let resp = block_on(router.oneshot(req)).unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + assert_eq!( + resp.headers().get("content-type").unwrap(), + "application/json" + ); + // Body is the injected manifest JSON verbatim. + assert_eq!( + body_json(resp), + serde_json::json!({ "app": { "name": "t" } }) + ); + } + + #[test] + fn routes_lists_registered_routes() { + let router = RouterService::builder().get("/r", routes).build(); + let req = request_builder() + .method(Method::GET) + .uri("/r") + .body(Body::empty()) + .unwrap(); + let resp = block_on(router.oneshot(req)).unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + // Shape: [{ "method", "path" }] — the /r route itself is present. + let body = body_json(resp); + let arr = body.as_array().expect("routes array"); + assert!(arr + .iter() + .any(|entry| entry["method"] == "GET" && entry["path"] == "/r")); + } + + #[test] + fn config_without_store_is_not_found() { + let router = RouterService::builder().get("/c", config).build(); + let req = request_builder() + .method(Method::GET) + .uri("/c") + .body(Body::empty()) + .unwrap(); + let resp = block_on(router.oneshot(req)).unwrap(); + assert_eq!(resp.status(), StatusCode::NOT_FOUND); + } + + #[test] + fn config_happy_path_returns_envelope_data_secret_safe() { + let data = serde_json::json!({ "greeting": "hi", "api_token": "demo_api_token" }); + let resp = run_config(StubStore(Ok(Some(valid_envelope_json(data))))); + assert_eq!(resp.status(), StatusCode::OK); + // Raw envelope `data` verbatim: the secret field holds the KEY NAME, + // never a resolved value. + let body = body_json(resp); + assert_eq!(body["greeting"], "hi"); + assert_eq!(body["api_token"], "demo_api_token"); + } + + #[test] + fn config_missing_blob_is_not_found() { + let resp = run_config(StubStore(Ok(None))); + assert_eq!(resp.status(), StatusCode::NOT_FOUND); + } + + #[test] + fn config_backend_unavailable_maps_503() { + let resp = run_config(StubStore(Err(ConfigStoreError::unavailable("x")))); + assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE); + } + + #[test] + fn config_invalid_key_maps_400() { + let resp = run_config(StubStore(Err(ConfigStoreError::invalid_key("x")))); + assert_eq!(resp.status(), StatusCode::BAD_REQUEST); + } + + #[test] + fn config_backend_internal_maps_500() { + let resp = run_config(StubStore(Err(ConfigStoreError::internal(anyhow::anyhow!( + "x" + ))))); + assert_eq!(resp.status(), StatusCode::INTERNAL_SERVER_ERROR); + } + + #[test] + fn config_malformed_envelope_maps_500() { + let resp = run_config(StubStore(Ok(Some("not json".to_owned())))); + assert_eq!(resp.status(), StatusCode::INTERNAL_SERVER_ERROR); + } + + #[test] + fn config_sha_mismatch_maps_500() { + // Valid JSON envelope shape but wrong sha → verify() fails. + let bad = r#"{"data":{"a":1},"generated_at":"t","sha256":"deadbeef","version":1}"#; + let resp = run_config(StubStore(Ok(Some(bad.to_owned())))); + assert_eq!(resp.status(), StatusCode::INTERNAL_SERVER_ERROR); + } + + #[test] + fn config_unknown_version_maps_500() { + let bad = r#"{"data":{},"generated_at":"t","sha256":"x","version":99}"#; + let resp = run_config(StubStore(Ok(Some(bad.to_owned())))); + assert_eq!(resp.status(), StatusCode::INTERNAL_SERVER_ERROR); + } +} diff --git a/crates/edgezero-core/src/lib.rs b/crates/edgezero-core/src/lib.rs index 0337e5e3..d88bd5d8 100644 --- a/crates/edgezero-core/src/lib.rs +++ b/crates/edgezero-core/src/lib.rs @@ -9,6 +9,10 @@ reason = "proc-macros must be re-exported through the parent crate" )] +// Required so `#[action]` handlers defined inside this crate resolve the +// absolute `::edgezero_core::…` paths the proc-macro emits. +extern crate self as edgezero_core; + pub mod addr; pub mod app; pub mod app_config; @@ -23,6 +27,7 @@ pub mod error; pub mod extractor; pub mod handler; pub mod http; +pub mod introspection; pub mod key_value_store; pub mod manifest; pub mod middleware; From 83dd8c45f85beec48c61a9ca79067a08d1029818 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Wed, 1 Jul 2026 21:41:31 -0700 Subject: [PATCH 10/74] app! macro: bake manifest JSON into build_router via with_manifest_json --- crates/edgezero-macros/Cargo.toml | 1 + crates/edgezero-macros/src/app.rs | 10 ++++++++++ 2 files changed, 11 insertions(+) diff --git a/crates/edgezero-macros/Cargo.toml b/crates/edgezero-macros/Cargo.toml index c108d1ca..19fb9cfa 100644 --- a/crates/edgezero-macros/Cargo.toml +++ b/crates/edgezero-macros/Cargo.toml @@ -16,6 +16,7 @@ log = { workspace = true } proc-macro2 = "1" quote = "1" serde = { workspace = true, features = ["derive"] } +serde_json = { workspace = true } syn = { version = "2", features = ["full"] } toml = { workspace = true } validator = { workspace = true, features = ["derive"] } diff --git a/crates/edgezero-macros/src/app.rs b/crates/edgezero-macros/src/app.rs index 4858d5a1..cddfbeaf 100644 --- a/crates/edgezero-macros/src/app.rs +++ b/crates/edgezero-macros/src/app.rs @@ -120,6 +120,15 @@ pub fn expand_app(input: TokenStream) -> TokenStream { } manifest.finalize(); + let manifest_json = match serde_json::to_string(&manifest) { + Ok(json) => json, + Err(err) => { + let msg = format!("failed to serialize manifest to JSON: {err}"); + return quote!(compile_error!(#msg);).into(); + } + }; + let manifest_json_lit = LitStr::new(&manifest_json, Span::call_site()); + let app_ident = args .app_ident .unwrap_or_else(|| Ident::new("App", Span::call_site())); @@ -170,6 +179,7 @@ pub fn expand_app(input: TokenStream) -> TokenStream { pub fn build_router() -> edgezero_core::router::RouterService { let mut builder = edgezero_core::router::RouterService::builder(); + builder = builder.with_manifest_json(#manifest_json_lit); #(#middleware_tokens)* #(#route_tokens)* builder.build() From 5ed4e8c7678b285c738087e70fecc89dd795cbb5 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Wed, 1 Jul 2026 21:44:02 -0700 Subject: [PATCH 11/74] Use workspace deps for edgezero-macros proc-macro2/quote/syn Match the edgezero-cli precedent and the workspace-dependency convention. Adds quote to [workspace.dependencies]. --- Cargo.lock | 1 + Cargo.toml | 1 + crates/edgezero-macros/Cargo.toml | 6 +++--- 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index b49c91ae..2100fc07 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -829,6 +829,7 @@ dependencies = [ "proc-macro2", "quote", "serde", + "serde_json", "syn 2.0.117", "tempfile", "toml", diff --git a/Cargo.toml b/Cargo.toml index 1fea962c..09e1b085 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -69,6 +69,7 @@ sha2 = "0.10" similar = "2" simple_logger = "5" proc-macro2 = { version = "1", features = ["span-locations"] } +quote = "1" syn = { version = "2", features = ["full", "extra-traits", "visit"] } subtle = "2" # Pinned to the `~6.0` range (allows 6.0.x, blocks 6.1+) so a minor diff --git a/crates/edgezero-macros/Cargo.toml b/crates/edgezero-macros/Cargo.toml index 19fb9cfa..bb584cd5 100644 --- a/crates/edgezero-macros/Cargo.toml +++ b/crates/edgezero-macros/Cargo.toml @@ -13,11 +13,11 @@ proc-macro = true [dependencies] log = { workspace = true } -proc-macro2 = "1" -quote = "1" +proc-macro2 = { workspace = true } +quote = { workspace = true } serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } -syn = { version = "2", features = ["full"] } +syn = { workspace = true } toml = { workspace = true } validator = { workspace = true, features = ["derive"] } From 9b3b820837090cc8d7f5364feaf3f98ace12d709 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Wed, 1 Jul 2026 21:44:23 -0700 Subject: [PATCH 12/74] Sync app-demo Cargo.lock for edgezero-macros serde_json dep --- examples/app-demo/Cargo.lock | 1 + 1 file changed, 1 insertion(+) diff --git a/examples/app-demo/Cargo.lock b/examples/app-demo/Cargo.lock index 4eab9b78..0c8bb82e 100644 --- a/examples/app-demo/Cargo.lock +++ b/examples/app-demo/Cargo.lock @@ -871,6 +871,7 @@ dependencies = [ "proc-macro2", "quote", "serde", + "serde_json", "syn 2.0.118", "toml", "validator", From 2cd9b0c54edab6458c66b3ef52f1ae007a4b0fc6 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Wed, 1 Jul 2026 21:47:35 -0700 Subject: [PATCH 13/74] Remove legacy route-listing machinery and /__edgezero/routes --- crates/edgezero-core/src/router.rs | 219 +---------------------------- 1 file changed, 4 insertions(+), 215 deletions(-) diff --git a/crates/edgezero-core/src/router.rs b/crates/edgezero-core/src/router.rs index 201b39d7..7baa346a 100644 --- a/crates/edgezero-core/src/router.rs +++ b/crates/edgezero-core/src/router.rs @@ -3,23 +3,16 @@ use std::sync::Arc; use std::task::{Context, Poll}; use matchit::Router as PathRouter; -use serde::Serialize; use tower_service::Service; -use crate::body::Body; use crate::context::RequestContext; use crate::error::EdgeError; use crate::handler::{BoxHandler, IntoHandler}; -use crate::http::{ - header::CONTENT_TYPE, response_builder, HandlerFuture, HeaderValue, Method, Request, Response, - ResponseBuilder, StatusCode, -}; +use crate::http::{HandlerFuture, Method, Request, Response}; use crate::middleware::{BoxMiddleware, Middleware, Next}; use crate::params::PathParams; use crate::response::IntoResponse as _; -pub const DEFAULT_ROUTE_LISTING_PATH: &str = "/__edgezero/routes"; - struct RouteEntry { handler: BoxHandler, } @@ -73,12 +66,6 @@ pub struct IntrospectionData { pub routes: Arc<[RouteInfo]>, } -#[derive(Serialize)] -struct RouteListingEntry { - method: String, - path: String, -} - enum RouteMatch<'route> { Found(&'route RouteEntry, PathParams), MethodNotAllowed(Vec), @@ -90,7 +77,6 @@ pub struct RouterBuilder { manifest_json: Option>, middlewares: Vec, route_info: Vec, - route_listing_path: Option, routes: HashMap>, } @@ -118,54 +104,10 @@ impl RouterBuilder { .push(RouteInfo::new(method, path.to_owned())); } - /// # Panics - /// Panics if a route is registered for both an explicit path and the route-listing path. - /// Both paths are programmer-supplied at build time; a duplicate is a routing-config bug - /// that should fail loudly before the binary ever serves traffic. - #[expect( - clippy::panic, - reason = "duplicate route is a build-time programmer error, not a runtime condition" - )] #[must_use] #[inline] - pub fn build(mut self) -> RouterService { - let listing_path = self.route_listing_path.clone(); - - let mut route_info = self.route_info.clone(); - if let Some(path) = &listing_path { - route_info.push(RouteInfo::new(Method::GET, path.clone())); - } - - let route_index: Arc<[RouteInfo]> = Arc::from(route_info); - - if let Some(path) = listing_path { - let outer_index = Arc::clone(&route_index); - let listing_handler = move |_ctx: RequestContext| { - let inner_index = Arc::clone(&outer_index); - async move { - let payload: Vec = inner_index - .iter() - .map(|route| RouteListingEntry { - method: route.method().as_str().to_owned(), - path: route.path().to_owned(), - }) - .collect(); - - build_listing_response(&payload, response_builder()) - } - }; - - self.routes - .entry(Method::GET) - .or_default() - .insert( - path.as_str(), - RouteEntry { - handler: listing_handler.into_handler(), - }, - ) - .unwrap_or_else(|err| panic!("duplicate route definition for {path}: {err}")); - } + pub fn build(self) -> RouterService { + let route_index: Arc<[RouteInfo]> = Arc::from(self.route_info); RouterService::new( self.routes, @@ -184,33 +126,6 @@ impl RouterBuilder { self.route(path, Method::DELETE, handler) } - #[must_use] - #[inline] - pub fn enable_route_listing(self) -> Self { - self.enable_route_listing_at(DEFAULT_ROUTE_LISTING_PATH) - } - - /// # Panics - /// Panics if `path` is empty or does not begin with `/`. - #[must_use] - #[inline] - pub fn enable_route_listing_at(mut self, path: S) -> Self - where - S: Into, - { - let route_listing_path = path.into(); - assert!( - !route_listing_path.is_empty(), - "route listing path cannot be empty" - ); - assert!( - route_listing_path.starts_with('/'), - "route listing path must begin with '/'" - ); - self.route_listing_path = Some(route_listing_path); - self - } - #[must_use] #[inline] pub fn get(self, path: &str, handler: H) -> Self @@ -403,19 +318,6 @@ impl RouterService { } } -fn build_listing_response( - payload: &T, - builder: ResponseBuilder, -) -> Result { - let body = Body::json(payload).map_err(EdgeError::internal)?; - let response = builder - .status(StatusCode::OK) - .header(CONTENT_TYPE, HeaderValue::from_static("application/json")) - .body(body) - .map_err(EdgeError::internal)?; - Ok(response) -} - #[cfg(test)] mod tests { use super::*; @@ -427,9 +329,7 @@ mod tests { use crate::response::response_with_body; use futures::executor::block_on; use futures::task::noop_waker_ref; - use serde::ser::Error as _; - use serde::{Deserialize, Serialize}; - use serde_json::json; + use serde::Deserialize; use std::sync::{Arc, Mutex}; use std::task::{Context, Poll}; @@ -646,117 +546,6 @@ mod tests { assert_eq!(response.status(), StatusCode::OK); } - #[test] - #[should_panic(expected = "duplicate route definition")] - fn route_listing_duplicate_path_panics() { - let _service = RouterService::builder() - .enable_route_listing() - .get(DEFAULT_ROUTE_LISTING_PATH, ok_handler) - .build(); - } - - #[test] - fn route_listing_outputs_all_routes() { - async fn noop(_ctx: RequestContext) -> Result<(), EdgeError> { - Ok(()) - } - - let service = RouterService::builder() - .enable_route_listing() - .get("/health", noop) - .post("/items", noop) - .build(); - - let request = request_builder() - .method(Method::GET) - .uri(DEFAULT_ROUTE_LISTING_PATH) - .body(Body::empty()) - .expect("request"); - - let response = block_on(service.clone().call(request)).expect("response"); - assert_eq!(response.status(), StatusCode::OK); - - let body = response.body().as_bytes().expect("buffered"); - let payload: Vec = serde_json::from_slice(body).expect("json payload"); - - assert!(payload.contains(&json!({ - "method": "GET", - "path": DEFAULT_ROUTE_LISTING_PATH - }))); - assert!(payload.contains(&json!({ - "method": "GET", - "path": "/health" - }))); - assert!(payload.contains(&json!({ - "method": "POST", - "path": "/items" - }))); - - let routes = service.routes(); - assert!(routes - .iter() - .any(|route| route.path() == "/health" && *route.method() == Method::GET)); - - let health_request = request_builder() - .method(Method::GET) - .uri("/health") - .body(Body::empty()) - .expect("request"); - let health_response = block_on(service.clone().call(health_request)).expect("response"); - assert_eq!(health_response.status(), StatusCode::NO_CONTENT); - - let items_request = request_builder() - .method(Method::POST) - .uri("/items") - .body(Body::empty()) - .expect("request"); - let items_response = block_on(service.clone().call(items_request)).expect("response"); - assert_eq!(items_response.status(), StatusCode::NO_CONTENT); - } - - #[test] - #[should_panic(expected = "route listing path cannot be empty")] - fn route_listing_rejects_empty_path() { - let _builder = RouterService::builder().enable_route_listing_at(""); - } - - #[test] - #[should_panic(expected = "route listing path must begin with '/'")] - fn route_listing_rejects_missing_slash() { - let _builder = RouterService::builder().enable_route_listing_at("routes"); - } - - #[test] - fn route_listing_response_handles_builder_failure() { - #[derive(Serialize)] - struct Payload { - ok: bool, - } - - let builder = response_builder().header("bad\nname", "value"); - let err = - build_listing_response(&Payload { ok: true }, builder).expect_err("expected error"); - assert_eq!(err.status(), StatusCode::INTERNAL_SERVER_ERROR); - } - - #[test] - fn route_listing_response_handles_json_failure() { - struct FailingSerialize; - - impl Serialize for FailingSerialize { - fn serialize(&self, _serializer: S) -> Result - where - S: serde::Serializer, - { - Err(S::Error::custom("boom")) - } - } - - let err = build_listing_response(&FailingSerialize, response_builder()) - .expect_err("expected error"); - assert_eq!(err.status(), StatusCode::INTERNAL_SERVER_ERROR); - } - #[test] fn route_matches_path_params() { #[derive(Deserialize)] From 68693891f527b80b2ce508cd414478505c8f5088 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Wed, 1 Jul 2026 22:11:46 -0700 Subject: [PATCH 14/74] Wire default introspection triggers into app-demo and generated apps --- .../src/templates/root/edgezero.toml.hbs | 26 ++++++ .../crates/app-demo-core/src/handlers.rs | 83 ++++++++++++++++++- examples/app-demo/edgezero.toml | 26 ++++++ 3 files changed, 134 insertions(+), 1 deletion(-) diff --git a/crates/edgezero-cli/src/templates/root/edgezero.toml.hbs b/crates/edgezero-cli/src/templates/root/edgezero.toml.hbs index b4f1c78c..c8e53cfa 100644 --- a/crates/edgezero-cli/src/templates/root/edgezero.toml.hbs +++ b/crates/edgezero-cli/src/templates/root/edgezero.toml.hbs @@ -52,6 +52,32 @@ methods = ["GET", "POST"] handler = "{{proj_core_mod}}::handlers::proxy_demo" adapters = [{{{adapter_list}}}] +# -- Introspection routes ------------------------------------------------------ + +[[triggers.http]] +id = "manifest" +path = "/_{{name}}/manifest" +methods = ["GET"] +handler = "edgezero_core::introspection::manifest" +adapters = [{{{adapter_list}}}] +description = "App manifest as JSON" + +[[triggers.http]] +id = "config" +path = "/_{{name}}/config" +methods = ["GET"] +handler = "edgezero_core::introspection::config" +adapters = [{{{adapter_list}}}] +description = "Effective app config (secret-safe)" + +[[triggers.http]] +id = "routes" +path = "/_{{name}}/routes" +methods = ["GET"] +handler = "edgezero_core::introspection::routes" +adapters = [{{{adapter_list}}}] +description = "Registered route table" + # -- Stores ---------------------------------------------------------------- # # `[stores.]` declares logical store ids only. `default` is required diff --git a/examples/app-demo/crates/app-demo-core/src/handlers.rs b/examples/app-demo/crates/app-demo-core/src/handlers.rs index a854c0c2..8358ac5c 100644 --- a/examples/app-demo/crates/app-demo-core/src/handlers.rs +++ b/examples/app-demo/crates/app-demo-core/src/handlers.rs @@ -308,6 +308,7 @@ pub async fn secrets_echo( mod tests { use super::*; use async_trait::async_trait; + use edgezero_core::blob_envelope::BlobEnvelope; use edgezero_core::body::Body; use edgezero_core::config_store::{ConfigStore, ConfigStoreError, ConfigStoreHandle}; use edgezero_core::context::RequestContext; @@ -318,7 +319,9 @@ mod tests { use edgezero_core::proxy::{ProxyClient, ProxyHandle, ProxyResponse}; use edgezero_core::response::IntoResponse as _; use edgezero_core::secret_store::{InMemorySecretStore, SecretHandle}; - use edgezero_core::store_registry::{ConfigStoreBinding, KvRegistry}; + use edgezero_core::store_registry::{ + ConfigRegistry, ConfigStoreBinding, KvRegistry, StoreRegistry, + }; use futures::executor::block_on; use std::collections::{BTreeMap, HashMap}; use std::sync::{Arc, Mutex}; @@ -419,6 +422,84 @@ mod tests { } } + struct FixedStore(String); + + #[async_trait(?Send)] + impl ConfigStore for FixedStore { + async fn get(&self, _key: &str) -> Result, ConfigStoreError> { + Ok(Some(self.0.clone())) + } + } + + #[test] + fn introspection_routes_are_wired() { + let router = crate::build_router(); + + // manifest: 200 + JSON body whose [app].name is "app-demo". + let manifest_req = request_builder() + .method(Method::GET) + .uri("/_app-demo/manifest") + .body(Body::empty()) + .unwrap(); + let manifest_resp = block_on(router.oneshot(manifest_req)).unwrap(); + assert_eq!(manifest_resp.status(), StatusCode::OK); + assert_eq!( + manifest_resp.headers().get("content-type").unwrap(), + "application/json" + ); + let manifest_body: serde_json::Value = manifest_resp.into_body().to_json().unwrap(); + assert_eq!(manifest_body["app"]["name"], "app-demo"); + + // routes: 200 + [{method,path}] including the root route. + let routes_req = request_builder() + .method(Method::GET) + .uri("/_app-demo/routes") + .body(Body::empty()) + .unwrap(); + let routes_resp = block_on(router.oneshot(routes_req)).unwrap(); + assert_eq!(routes_resp.status(), StatusCode::OK); + let routes_body: serde_json::Value = routes_resp.into_body().to_json().unwrap(); + let arr = routes_body.as_array().expect("routes array"); + assert!(arr + .iter() + .any(|entry| entry["method"] == "GET" && entry["path"] == "/")); + + // /config: seed a default config store with a valid envelope so a wired + // route returns 200 (a routing miss would be 404, proving nothing). + let data = serde_json::json!({ "greeting": "hi", "api_token": "demo_api_token" }); + let blob = + serde_json::to_string(&BlobEnvelope::new(data, "2026-01-01T00:00:00Z".to_owned())) + .unwrap(); + let registry: ConfigRegistry = StoreRegistry::new( + [( + "app_config".to_owned(), + ConfigStoreBinding { + handle: ConfigStoreHandle::new(Arc::new(FixedStore(blob))), + default_key: "app_config".to_owned(), + }, + )] + .into_iter() + .collect::>(), + "app_config".to_owned(), + ); + let mut config_req = request_builder() + .method(Method::GET) + .uri("/_app-demo/config") + .body(Body::empty()) + .unwrap(); + config_req.extensions_mut().insert(registry); + let config_resp = block_on(router.oneshot(config_req)).unwrap(); + assert_eq!( + config_resp.status(), + StatusCode::OK, + "/config should be wired and 200 with a store" + ); + // Raw envelope `data`: secret field holds the KEY NAME, not a resolved value. + let config_body: serde_json::Value = config_resp.into_body().to_json().unwrap(); + assert_eq!(config_body["api_token"], "demo_api_token"); + assert_eq!(config_body["greeting"], "hi"); + } + #[test] fn build_proxy_target_merges_segments_and_query() { let original = Uri::from_static("/proxy/status?foo=bar"); diff --git a/examples/app-demo/edgezero.toml b/examples/app-demo/edgezero.toml index bd8b93cd..f5462639 100644 --- a/examples/app-demo/edgezero.toml +++ b/examples/app-demo/edgezero.toml @@ -101,6 +101,32 @@ handler = "app_demo_core::handlers::kv_note_delete" adapters = ["axum", "cloudflare", "fastly", "spin"] description = "Delete a note by id" +# -- Introspection routes ------------------------------------------------------ + +[[triggers.http]] +id = "manifest" +path = "/_app-demo/manifest" +methods = ["GET"] +handler = "edgezero_core::introspection::manifest" +adapters = ["axum", "cloudflare", "fastly", "spin"] +description = "App manifest as JSON" + +[[triggers.http]] +id = "config" +path = "/_app-demo/config" +methods = ["GET"] +handler = "edgezero_core::introspection::config" +adapters = ["axum", "cloudflare", "fastly", "spin"] +description = "Effective app config (secret-safe)" + +[[triggers.http]] +id = "routes" +path = "/_app-demo/routes" +methods = ["GET"] +handler = "edgezero_core::introspection::routes" +adapters = ["axum", "cloudflare", "fastly", "spin"] +description = "Registered route table" + # -- Secrets demo route -------------------------------------------------------- [[triggers.http]] From adbc94de39b6eb2dfaa7fdb565a538c756a34cf2 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Wed, 1 Jul 2026 22:15:55 -0700 Subject: [PATCH 15/74] Docs: replace route-listing with introspection routes --- docs/guide/roadmap.md | 2 +- docs/guide/routing.md | 47 +++++++++++++++++++++++++++---------------- 2 files changed, 31 insertions(+), 18 deletions(-) diff --git a/docs/guide/roadmap.md b/docs/guide/roadmap.md index 6b92ac1f..492d8ba1 100644 --- a/docs/guide/roadmap.md +++ b/docs/guide/roadmap.md @@ -14,7 +14,7 @@ shift as the roadmap evolves. - Adapter behavior matrix: document which adapters buffer bodies, which preserve streaming, and where proxy headers/automatic decompression apply so expectations match runtime behavior. - Example coverage: add focused guides for `axum.toml`, manifest `description` fields, logging - precedence, and route listing + body-mode behavior to reduce ambiguity. + precedence, and introspection routes + body-mode behavior to reduce ambiguity. - Spin support: add first-class Spin adapter support and document how EdgeZero manifests mirror Spin-compatible deployments. - Provider additions: prototype a third adapter (e.g. AWS Lambda@Edge or Vercel Edge Functions) diff --git a/docs/guide/routing.md b/docs/guide/routing.md index 98649eed..a8c2e4fb 100644 --- a/docs/guide/routing.md +++ b/docs/guide/routing.md @@ -113,33 +113,46 @@ RouterService::builder() EdgeZero automatically returns `405 Method Not Allowed` for requests that match a path but use an unsupported method. -## Route Listing +## Introspection Routes -Enable route listing for debugging: +EdgeZero provides three bindable handlers in `edgezero_core::introspection` for debugging and runtime inspection: -```rust -let router = RouterService::builder() - .enable_route_listing() - .get("/hello", hello) - .build(); +- **`manifest`**: Returns the full manifest JSON with secret values redacted. +- **`config`**: Returns the effective app config from the default config store, with secret fields appearing as unresolved key-name references (secret-safe). +- **`routes`**: Returns the registered route table as `[{method, path}]`. + +Bind them in your manifest's `[[triggers.http]]` like any handler. By default, generated apps and app-demo mount them under `/_/{manifest,config,routes}`: + +```toml +[[triggers.http]] +id = "manifest" +path = "/_my-app/manifest" +methods = ["GET"] +handler = "edgezero_core::introspection::manifest" + +[[triggers.http]] +id = "config" +path = "/_my-app/config" +methods = ["GET"] +handler = "edgezero_core::introspection::config" + +[[triggers.http]] +id = "routes" +path = "/_my-app/routes" +methods = ["GET"] +handler = "edgezero_core::introspection::routes" ``` -This exposes a JSON endpoint at `/__edgezero/routes`: +The `routes` handler returns JSON like: ```json [ - { "method": "GET", "path": "/hello" }, - { "method": "GET", "path": "/__edgezero/routes" } + { "method": "GET", "path": "/_my-app/manifest" }, + { "method": "GET", "path": "/_my-app/config" }, + { "method": "GET", "path": "/_my-app/routes" } ] ``` -Customize the listing path: - -```rust -RouterService::builder() - .enable_route_listing_at("/debug/routes") -``` - ## Path Syntax EdgeZero uses matchit's path syntax: From 89996235d122807a1f5b18f34bb3d46c4c3cd7a3 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Wed, 1 Jul 2026 22:40:19 -0700 Subject: [PATCH 16/74] Track manifest as build input; strengthen introspection tests; doc caveat - emit `include_bytes!` const in app! output so edits to edgezero.toml trigger a Cargo rebuild without requiring a .rs change - strengthen middleware_sees_introspection_data to assert manifest_json presence and non-empty route list, mirroring dispatch test - add content-type assertion to routes_lists_registered_routes - add security caveat to routing.md Introspection Routes section noting endpoints are unauthenticated and environment.variables values are emitted --- crates/edgezero-core/src/introspection.rs | 4 ++++ crates/edgezero-core/src/router.rs | 16 ++++++++++------ crates/edgezero-macros/src/app.rs | 5 +++++ docs/guide/routing.md | 4 ++++ 4 files changed, 23 insertions(+), 6 deletions(-) diff --git a/crates/edgezero-core/src/introspection.rs b/crates/edgezero-core/src/introspection.rs index dde4d715..74138628 100644 --- a/crates/edgezero-core/src/introspection.rs +++ b/crates/edgezero-core/src/introspection.rs @@ -183,6 +183,10 @@ mod tests { .unwrap(); let resp = block_on(router.oneshot(req)).unwrap(); assert_eq!(resp.status(), StatusCode::OK); + assert_eq!( + resp.headers().get("content-type").unwrap(), + "application/json" + ); // Shape: [{ "method", "path" }] — the /r route itself is present. let body = body_json(resp); let arr = body.as_array().expect("routes array"); diff --git a/crates/edgezero-core/src/router.rs b/crates/edgezero-core/src/router.rs index 7baa346a..d5ba3049 100644 --- a/crates/edgezero-core/src/router.rs +++ b/crates/edgezero-core/src/router.rs @@ -617,7 +617,7 @@ mod tests { #[test] fn middleware_sees_introspection_data() { - struct Probe(Arc>); + struct Probe(Arc>>); #[async_trait::async_trait(?Send)] impl Middleware for Probe { async fn handle( @@ -625,14 +625,16 @@ mod tests { ctx: RequestContext, next: Next<'_>, ) -> Result { - *self.0.lock().unwrap() = ctx.introspection().is_some(); + *self.0.lock().unwrap() = ctx + .introspection() + .map(|data| (data.manifest_json.is_some(), data.routes.len())); next.run(ctx).await } } - let saw = Arc::new(Mutex::new(false)); + let saw: Arc>> = Arc::new(Mutex::new(None)); let router = RouterService::builder() - .with_manifest_json("{}") + .with_manifest_json("{\"app\":{\"name\":\"t\"}}") .middleware(Probe(Arc::clone(&saw))) .get("/", |_ctx: RequestContext| async { Ok::<_, EdgeError>("ok") @@ -644,9 +646,11 @@ mod tests { .body(Body::empty()) .unwrap(); block_on(router.oneshot(request)).unwrap(); + let (had_manifest, route_count) = saw.lock().unwrap().expect("middleware ran"); + assert!(had_manifest, "middleware should see manifest_json"); assert!( - *saw.lock().unwrap(), - "middleware should see introspection data" + route_count > 0, + "middleware should see non-empty route list" ); } diff --git a/crates/edgezero-macros/src/app.rs b/crates/edgezero-macros/src/app.rs index cddfbeaf..9d52e3a8 100644 --- a/crates/edgezero-macros/src/app.rs +++ b/crates/edgezero-macros/src/app.rs @@ -149,12 +149,17 @@ pub fn expand_app(input: TokenStream) -> TokenStream { }; let stores_tokens = build_stores_tokens(&manifest); + let manifest_path_lit = LitStr::new(&manifest_path.to_string_lossy(), Span::call_site()); + // The emitted `Hooks` impl below explicitly defines `configure` and // `build_app` even though their bodies mirror the trait defaults. This is // required because `missing_trait_methods` (restriction = deny) forbids // relying on trait defaults in the impl. If `Hooks::configure` or // `Hooks::build_app` defaults change, update these emitted bodies to match. let output = quote! { + // Force a rebuild when the manifest file changes (include_bytes tracks it as a build input). + const _: &[u8] = include_bytes!(#manifest_path_lit); + pub struct #app_ident; impl edgezero_core::app::Hooks for #app_ident { diff --git a/docs/guide/routing.md b/docs/guide/routing.md index a8c2e4fb..ad5dea0b 100644 --- a/docs/guide/routing.md +++ b/docs/guide/routing.md @@ -143,6 +143,10 @@ methods = ["GET"] handler = "edgezero_core::introspection::routes" ``` +::: warning Security +These endpoints are unauthenticated wherever they are bound — restrict access at the network or middleware layer before exposing them publicly. Note that `/manifest` emits `environment.variables[].value` verbatim; only `environment.secrets` values are redacted. Do not store secrets in `[environment.variables]`. +::: + The `routes` handler returns JSON like: ```json From 56cca3420577dfd041da78fe38d8290ad886a7e5 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Wed, 1 Jul 2026 23:56:51 -0700 Subject: [PATCH 17/74] Spec/plan: introspection access via independent extractors (no gating) --- .../plans/2026-07-02-introspection-routes.md | 96 +++++++++++++++++++ .../2026-07-01-introspection-routes-design.md | 24 +++++ 2 files changed, 120 insertions(+) diff --git a/docs/superpowers/plans/2026-07-02-introspection-routes.md b/docs/superpowers/plans/2026-07-02-introspection-routes.md index 6aada2a7..df562c8a 100644 --- a/docs/superpowers/plans/2026-07-02-introspection-routes.md +++ b/docs/superpowers/plans/2026-07-02-introspection-routes.md @@ -1196,3 +1196,99 @@ gh pr ready 300 **Placeholder scan:** No TBD/TODO; every code step shows real code. Verification notes ("confirm `ConfigStoreError` constructor names", "verify `{{{adapter_list}}}` name", "match body-collection helper") are drift guardrails, not missing content. **Type consistency:** `IntrospectionData { manifest_json: Option>, routes: Arc<[RouteInfo]> }`, `with_manifest_json(impl Into>)`, and `introspection() -> Option<&IntrospectionData>` are used identically across Tasks 2/3/6. Handler names `manifest`/`config`/`routes` match the trigger `handler = "edgezero_core::introspection::…"` strings in Task 6. Manual enum `Serialize` (Task 1 Step 5a) matches the `Deserialize` wire forms. + +--- + +## Addendum (2026-07-02): independent typed extractors + +**Why:** Tasks 1–8 have `manifest`/`routes` read `ctx.introspection()` directly. +Per user direction, expose the injected data through independent typed +extractors instead — matching the `Json`/`Path`/`AppConfig` idiom and making each +handler's dependency explicit. **Nothing else changes:** injection stays +unconditional at `RouterInner::dispatch`, routes stay plain `[[triggers.http]]` +bindings (already mountable), and there is NO per-route gating, NO `RouteEntry` +flag, NO `app!` macro change. `config` is untouched (it uses the config store). + +Base: merge-ready branch head (after `8999623`). Single task, edgezero-core only. + +### Task 9: independent `ManifestJson` / `RouteTable` extractors + +**Files:** `crates/edgezero-core/src/introspection.rs` (add two extractors, refactor two handlers, update/extend colocated tests). + +- [ ] **Step 1 (RED): add extractor tests + refactor-target tests.** Keep the + existing `manifest_returns_injected_json` / `routes_lists_registered_routes` / + all `config_*` tests as-is (they drive through `oneshot`, so they still pass + once handlers use extractors). Add: + - `manifest_json_extractor_reads_injected` — build a router with the `manifest` + handler + `with_manifest_json("{\"app\":{}}")`, `oneshot`, assert 200 + body. + - `manifest_json_extractor_absent_is_500` — a router WITHOUT `with_manifest_json` + bound to `manifest` yields 500 (payload has `manifest_json: None`). + Run `cargo test -p edgezero-core introspection` — new tests fail to compile + (extractors absent). + +- [ ] **Step 2: add the extractors** in `introspection.rs`: +```rust +use crate::extractor::FromRequest; // match the crate's actual FromRequest path +use crate::router::RouteInfo; +use std::sync::Arc; + +/// Extractor for the baked manifest JSON (the `IntrospectionData` injected at +/// dispatch). Errors 500 if introspection data is absent. +pub struct ManifestJson(pub Arc); + +#[async_trait::async_trait(?Send)] +impl FromRequest for ManifestJson { + async fn from_request(ctx: &RequestContext) -> Result { + ctx.introspection() + .and_then(|d| d.manifest_json.clone()) + .map(ManifestJson) + .ok_or_else(|| EdgeError::internal(anyhow::anyhow!( + "manifest introspection data not available" + ))) + } +} + +/// Extractor for the live route index. +pub struct RouteTable(pub Arc<[RouteInfo]>); + +#[async_trait::async_trait(?Send)] +impl FromRequest for RouteTable { + async fn from_request(ctx: &RequestContext) -> Result { + ctx.introspection() + .map(|d| RouteTable(Arc::clone(&d.routes))) + .ok_or_else(|| EdgeError::internal(anyhow::anyhow!( + "route-table introspection data not available" + ))) + } +} +``` + (Confirm the `FromRequest` trait path and `async_trait` usage against + `extractor.rs`; match how existing extractors declare the impl.) + +- [ ] **Step 3: refactor the handlers** (`config` unchanged): +```rust +#[action] +pub async fn manifest(ManifestJson(json): ManifestJson) -> Result { + json_response(StatusCode::OK, Body::text(json.to_string())) +} + +#[action] +pub async fn routes(RouteTable(table): RouteTable) -> Result { + let views: Vec = table + .iter() + .map(|r| RouteView { method: r.method().as_str().to_owned(), path: r.path().to_owned() }) + .collect(); + json_response(StatusCode::OK, Body::json(&views).map_err(EdgeError::internal)?) +} +``` + +- [ ] **Step 4 (GREEN):** `cargo test -p edgezero-core introspection` (all pass, + incl. the existing body-shape assertions and the new extractor tests); then + `cargo test -p edgezero-core`, `cargo fmt`, `cargo clippy -p edgezero-core --all-targets -- -D warnings`. + +- [ ] **Step 5: app-demo unchanged but re-verify** — the triggers already bind + `edgezero_core::introspection::{manifest,config,routes}`; handler signatures + changed but the paths/behavior didn't. Run + `cd examples/app-demo && cargo test -p app-demo-core introspection_routes_are_wired`. + +- [ ] **Step 6: Commit** — `git commit -m "Expose introspection via ManifestJson/RouteTable extractors"` diff --git a/docs/superpowers/specs/2026-07-01-introspection-routes-design.md b/docs/superpowers/specs/2026-07-01-introspection-routes-design.md index d61ef63f..761f2416 100644 --- a/docs/superpowers/specs/2026-07-01-introspection-routes-design.md +++ b/docs/superpowers/specs/2026-07-01-introspection-routes-design.md @@ -64,6 +64,30 @@ We want a single, consistent, "bind it yourself" mechanism for all three. in core. No process-global state; no per-adapter changes. 6. **Remove route listing** — delete the entire `enable_route_listing` machinery and `/__edgezero/routes`. +7. **Typed extractors for access (revised 2026-07-02)** — handlers access the + injected data through independent typed extractors, not `ctx.introspection()`. + See the Revision section. + +> **Revision — 2026-07-02: independent typed extractors.** +> The injection (Decision 5) and the dispatch chokepoint are unchanged — +> `IntrospectionData` is still injected at `RouterInner::dispatch`. The only +> change is how handlers *access* it: instead of reaching into +> `ctx.introspection()` directly (Component 4), the two handlers that need it +> declare the dependency via independent typed extractors, matching the +> `Json`/`Path`/`AppConfig` idiom: +> +> - `ManifestJson(pub Arc)` — the baked manifest JSON. Used by `manifest`. +> - `RouteTable(pub Arc<[RouteInfo]>)` — the live route index. Used by `routes`. +> +> Both implement `FromRequest`, read from the injected `IntrospectionData` via +> `ctx.introspection()`, and error (500 internal) if it is absent. `config` takes +> `RequestContext` and uses neither — it reads the default config store. +> +> No per-route gating, no `RouteEntry` flag, no `app!` macro changes, no builder +> methods: the routes remain plain `[[triggers.http]]` bindings (mountable by +> apps), and the extractor on the handler signature is the only thing that +> changes. Where this conflicts with Component 4's "handler reads +> `ctx.introspection()`", the Revision governs. ## Architecture From 0feb1947e0ce495496c4d050dcbab33326c8f760 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Wed, 1 Jul 2026 23:58:58 -0700 Subject: [PATCH 18/74] Expose introspection via ManifestJson/RouteTable extractors --- crates/edgezero-core/src/introspection.rs | 81 +++++++++++++++++------ 1 file changed, 62 insertions(+), 19 deletions(-) diff --git a/crates/edgezero-core/src/introspection.rs b/crates/edgezero-core/src/introspection.rs index 74138628..650b17b0 100644 --- a/crates/edgezero-core/src/introspection.rs +++ b/crates/edgezero-core/src/introspection.rs @@ -5,11 +5,15 @@ use crate::blob_envelope::BlobEnvelope; use crate::body::Body; use crate::context::RequestContext; use crate::error::EdgeError; +use crate::extractor::FromRequest; // NOTE: `Response` is an HTTP alias exported from `crate::http`, NOT // `crate::response` (response.rs itself imports it from crate::http). use crate::http::{response_builder, Response, StatusCode}; +use crate::router::RouteInfo; +use async_trait::async_trait; use edgezero_core::action; use serde::Serialize; +use std::sync::Arc; #[derive(Serialize)] struct RouteView { @@ -17,6 +21,42 @@ struct RouteView { path: String, } +/// Extractor for the baked manifest JSON carried in the request's +/// [`crate::router::IntrospectionData`]. Errors with 500 if the data is +/// absent (i.e. the router did not inject it). +pub struct ManifestJson(pub Arc); + +#[async_trait(?Send)] +impl FromRequest for ManifestJson { + #[inline] + async fn from_request(ctx: &RequestContext) -> Result { + ctx.introspection() + .and_then(|data| data.manifest_json.clone()) + .map(ManifestJson) + .ok_or_else(|| { + EdgeError::internal(anyhow::anyhow!("manifest introspection data not available")) + }) + } +} + +/// Extractor for the live route index carried in the request's +/// [`crate::router::IntrospectionData`]. Errors with 500 if the data is absent. +pub struct RouteTable(pub Arc<[RouteInfo]>); + +#[async_trait(?Send)] +impl FromRequest for RouteTable { + #[inline] + async fn from_request(ctx: &RequestContext) -> Result { + ctx.introspection() + .map(|data| RouteTable(Arc::clone(&data.routes))) + .ok_or_else(|| { + EdgeError::internal(anyhow::anyhow!( + "route-table introspection data not available" + )) + }) + } +} + fn json_response(status: StatusCode, body: Body) -> Result { response_builder() .status(status) @@ -27,31 +67,20 @@ fn json_response(status: StatusCode, body: Body) -> Result /// GET — the app manifest as JSON (baked at compile time by `app!`). #[action] -pub async fn manifest(ctx: RequestContext) -> Result { - let json = ctx - .introspection() - .and_then(|data| data.manifest_json.clone()) - .ok_or_else(|| { - EdgeError::internal(anyhow::anyhow!("manifest introspection data missing")) - })?; +pub async fn manifest(ManifestJson(json): ManifestJson) -> Result { json_response(StatusCode::OK, Body::text(json.to_string())) } /// GET — `[{ "method", "path" }]` for every registered route. #[action] -pub async fn routes(ctx: RequestContext) -> Result { - let views: Vec = ctx - .introspection() - .map(|data| { - data.routes - .iter() - .map(|route| RouteView { - method: route.method().as_str().to_owned(), - path: route.path().to_owned(), - }) - .collect() +pub async fn routes(RouteTable(table): RouteTable) -> Result { + let views: Vec = table + .iter() + .map(|route| RouteView { + method: route.method().as_str().to_owned(), + path: route.path().to_owned(), }) - .unwrap_or_default(); + .collect(); let body = Body::json(&views).map_err(EdgeError::internal)?; json_response(StatusCode::OK, body) } @@ -173,6 +202,20 @@ mod tests { ); } + #[test] + fn manifest_without_baked_json_is_500() { + // No `with_manifest_json`: IntrospectionData is still injected, but + // `manifest_json` is None, so the `ManifestJson` extractor errors 500. + let router = RouterService::builder().get("/m", manifest).build(); + let req = request_builder() + .method(Method::GET) + .uri("/m") + .body(Body::empty()) + .unwrap(); + let resp = block_on(router.oneshot(req)).unwrap(); + assert_eq!(resp.status(), StatusCode::INTERNAL_SERVER_ERROR); + } + #[test] fn routes_lists_registered_routes() { let router = RouterService::builder().get("/r", routes).build(); From c9c6f341c6fb4f1df67eb02216b7a27786db8168 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Thu, 2 Jul 2026 00:14:46 -0700 Subject: [PATCH 19/74] Spec/plan: per-route gated introspection injection (macro auto-flags, no edgezero.toml change) --- .../plans/2026-07-02-introspection-routes.md | 183 ++++++++++-------- .../2026-07-01-introspection-routes-design.md | 39 ++-- 2 files changed, 125 insertions(+), 97 deletions(-) diff --git a/docs/superpowers/plans/2026-07-02-introspection-routes.md b/docs/superpowers/plans/2026-07-02-introspection-routes.md index df562c8a..edbf0a76 100644 --- a/docs/superpowers/plans/2026-07-02-introspection-routes.md +++ b/docs/superpowers/plans/2026-07-02-introspection-routes.md @@ -1199,96 +1199,113 @@ gh pr ready 300 --- -## Addendum (2026-07-02): independent typed extractors - -**Why:** Tasks 1–8 have `manifest`/`routes` read `ctx.introspection()` directly. -Per user direction, expose the injected data through independent typed -extractors instead — matching the `Json`/`Path`/`AppConfig` idiom and making each -handler's dependency explicit. **Nothing else changes:** injection stays -unconditional at `RouterInner::dispatch`, routes stay plain `[[triggers.http]]` -bindings (already mountable), and there is NO per-route gating, NO `RouteEntry` -flag, NO `app!` macro change. `config` is untouched (it uses the config store). - -Base: merge-ready branch head (after `8999623`). Single task, edgezero-core only. - -### Task 9: independent `ManifestJson` / `RouteTable` extractors - -**Files:** `crates/edgezero-core/src/introspection.rs` (add two extractors, refactor two handlers, update/extend colocated tests). - -- [ ] **Step 1 (RED): add extractor tests + refactor-target tests.** Keep the - existing `manifest_returns_injected_json` / `routes_lists_registered_routes` / - all `config_*` tests as-is (they drive through `oneshot`, so they still pass - once handlers use extractors). Add: - - `manifest_json_extractor_reads_injected` — build a router with the `manifest` - handler + `with_manifest_json("{\"app\":{}}")`, `oneshot`, assert 200 + body. - - `manifest_json_extractor_absent_is_500` — a router WITHOUT `with_manifest_json` - bound to `manifest` yields 500 (payload has `manifest_json: None`). - Run `cargo test -p edgezero-core introspection` — new tests fail to compile - (extractors absent). - -- [ ] **Step 2: add the extractors** in `introspection.rs`: +--- + +## Addendum (2026-07-02): independent extractors + per-route gated injection + +Two increments over Tasks 1–8. **Task 9 (extractors) is already committed** +(`0feb194`): `ManifestJson`/`RouteTable` extractors added, `manifest`/`routes` +handlers refactored to use them (`config` unchanged). This addendum records that +plus the remaining gating work. + +**Task 10 (gating)** makes injection per-route instead of every-request, with NO +app-facing change: the `app!` macro auto-flags routes whose handler is in the +`edgezero_core::introspection::` namespace. No `[introspection]` section, no +`edgezero.toml` knob. + +Base: after `0feb194`. + +### Task 10a: per-route gating in the router (edgezero-core) + +**Files:** `crates/edgezero-core/src/router.rs` (+ its tests) + +- [ ] **Step 1: flag on `RouteEntry`** — add `needs_introspection: bool`; update the manual `Clone`/`clone_from` impls to copy it. +- [ ] **Step 2: `add_route` takes the flag; add `route_introspective`.** ```rust -use crate::extractor::FromRequest; // match the crate's actual FromRequest path -use crate::router::RouteInfo; -use std::sync::Arc; - -/// Extractor for the baked manifest JSON (the `IntrospectionData` injected at -/// dispatch). Errors 500 if introspection data is absent. -pub struct ManifestJson(pub Arc); - -#[async_trait::async_trait(?Send)] -impl FromRequest for ManifestJson { - async fn from_request(ctx: &RequestContext) -> Result { - ctx.introspection() - .and_then(|d| d.manifest_json.clone()) - .map(ManifestJson) - .ok_or_else(|| EdgeError::internal(anyhow::anyhow!( - "manifest introspection data not available" - ))) + fn add_route(&mut self, path: &str, method: Method, handler: H, needs_introspection: bool) + where H: IntoHandler { + let router = self.routes.entry(method.clone()).or_default(); + router + .insert(path, RouteEntry { handler: handler.into_handler(), needs_introspection }) + .unwrap_or_else(|err| panic!("duplicate route definition for {path}: {err}")); + self.route_info.push(RouteInfo::new(method, path.to_owned())); } -} -/// Extractor for the live route index. -pub struct RouteTable(pub Arc<[RouteInfo]>); - -#[async_trait::async_trait(?Send)] -impl FromRequest for RouteTable { - async fn from_request(ctx: &RequestContext) -> Result { - ctx.introspection() - .map(|d| RouteTable(Arc::clone(&d.routes))) - .ok_or_else(|| EdgeError::internal(anyhow::anyhow!( - "route-table introspection data not available" - ))) + pub fn route(mut self, path: &str, method: Method, handler: H) -> Self + where H: IntoHandler { self.add_route(path, method, handler, false); self } + + /// Register a route whose handler needs introspection data + /// (`ManifestJson`/`RouteTable`). Only such routes get `IntrospectionData` + /// injected at dispatch. The `app!` macro emits this automatically for + /// handlers in the `edgezero_core::introspection::` namespace. + #[must_use] + pub fn route_introspective(mut self, path: &str, method: Method, handler: H) -> Self + where H: IntoHandler { self.add_route(path, method, handler, true); self } +``` + (`get`/`post`/`put`/`delete` keep delegating to `route` → flag stays false.) +- [ ] **Step 3: precompute the payload once in `build()`** and store `introspection: Arc` on `RouterInner` (replacing the bare `manifest_json` field it currently threads): +```rust + pub fn build(self) -> RouterService { + let route_index: Arc<[RouteInfo]> = Arc::from(self.route_info); + let introspection = Arc::new(IntrospectionData { + manifest_json: self.manifest_json, + routes: Arc::clone(&route_index), + }); + RouterService::new(self.routes, self.middlewares, route_index, introspection) } -} ``` - (Confirm the `FromRequest` trait path and `async_trait` usage against - `extractor.rs`; match how existing extractors declare the impl.) - -- [ ] **Step 3: refactor the handlers** (`config` unchanged): + (Update `RouterService::new` to take `introspection: Arc`.) +- [ ] **Step 4: gate the insert in `dispatch`** — move it AFTER `find_route`, only for flagged routes: ```rust -#[action] -pub async fn manifest(ManifestJson(json): ManifestJson) -> Result { - json_response(StatusCode::OK, Body::text(json.to_string())) -} - -#[action] -pub async fn routes(RouteTable(table): RouteTable) -> Result { - let views: Vec = table - .iter() - .map(|r| RouteView { method: r.method().as_str().to_owned(), path: r.path().to_owned() }) - .collect(); - json_response(StatusCode::OK, Body::json(&views).map_err(EdgeError::internal)?) -} + async fn dispatch(&self, request: Request) -> Result { + let method = request.method().clone(); + let path = request.uri().path().to_owned(); + match self.find_route(&method, &path) { + RouteMatch::Found(entry, params) => { + let needs = entry.needs_introspection; + let mut request = request; + if needs { + request.extensions_mut().insert(Arc::clone(&self.introspection)); + } + let ctx = RequestContext::new(request, params); + let next = Next::new(&self.middlewares, entry.handler.as_ref()); + next.run(ctx).await + } + RouteMatch::MethodNotAllowed(mut allowed) => { + allowed.sort_by(|l, r| l.as_str().cmp(r.as_str())); + Err(EdgeError::method_not_allowed(&method, &allowed)) + } + RouteMatch::NotFound => Err(EdgeError::not_found(path)), + } + } +``` + `RequestContext::introspection()` (context.rs) now reads `Arc`: +```rust + pub fn introspection(&self) -> Option<&crate::router::IntrospectionData> { + self.request.extensions().get::>() + .map(|arc| arc.as_ref()) + } ``` +- [ ] **Step 5: tests** — the existing `dispatch_injects_introspection_data` / `middleware_sees_introspection_data` tests register their probe route with `.route_introspective("/", Method::GET, handler)`; ADD a negative test: a plain `.get("/x", handler)` route sees `ctx.introspection().is_none()`. Run `cargo test -p edgezero-core router introspection_data`, then full `cargo test -p edgezero-core`, `cargo fmt`, `cargo clippy -p edgezero-core --all-targets -- -D warnings`. +- [ ] **Step 6: introspection.rs tests** — the extractor tests that build routers with `.get("/m", manifest)` must switch to `.route_introspective("/m", Method::GET, manifest)` so the data is injected (else they now 500). Keep `manifest_without_baked_json_is_500` but make it register introspective WITHOUT `with_manifest_json` (data present, `manifest_json` None → 500). Add/keep a test that a NON-introspective binding of `manifest` yields 500 (no data injected). Run `cargo test -p edgezero-core introspection`. +- [ ] **Step 7: Commit** — `git commit -m "Gate introspection injection per route via route_introspective"` + +### Task 10b: `app!` macro auto-flags introspection routes -- [ ] **Step 4 (GREEN):** `cargo test -p edgezero-core introspection` (all pass, - incl. the existing body-shape assertions and the new extractor tests); then - `cargo test -p edgezero-core`, `cargo fmt`, `cargo clippy -p edgezero-core --all-targets -- -D warnings`. +**Files:** `crates/edgezero-macros/src/app.rs` (+ verify app-demo / generated project) -- [ ] **Step 5: app-demo unchanged but re-verify** — the triggers already bind - `edgezero_core::introspection::{manifest,config,routes}`; handler signatures - changed but the paths/behavior didn't. Run - `cd examples/app-demo && cargo test -p app-demo-core introspection_routes_are_wired`. +- [ ] **Step 1: emit `route_introspective` for introspection-namespace handlers.** In `build_route_tokens`, per trigger: +```rust +const INTROSPECTION_NS: &str = "edgezero_core::introspection::"; +let is_introspective = trigger.handler.as_deref().is_some_and(|h| h.starts_with(INTROSPECTION_NS)); +let register = if is_introspective { + quote! { builder = builder.route_introspective(#path_lit, #method_tok, #handler_path); } +} else { + quote! { builder = builder.route(#path_lit, #method_tok, #handler_path); } +}; +``` + Match the file's existing `#method_tok`/`#handler_path` construction and how it currently emits `get`/`post`/`route`. Keep non-introspection routes byte-for-byte as before. `with_manifest_json` + `include_bytes!` tracking stay as-is. +- [ ] **Step 2: verify** — `cargo build -p edgezero-macros && cargo test -p edgezero-macros`; `cargo fmt --all && cargo clippy -p edgezero-macros -p edgezero-core --all-targets -- -D warnings`; `cd examples/app-demo && cargo test -p app-demo-core introspection_routes_are_wired` (app-demo binds the three handlers → macro auto-flags manifest/routes; assertions unchanged; `/config` unflagged but works via config store); `cargo test -p edgezero-cli --test generated_project_builds -- --ignored`. +- [ ] **Step 3: Commit** — `git commit -m "app!: auto-flag introspection-namespace routes as introspective"` -- [ ] **Step 6: Commit** — `git commit -m "Expose introspection via ManifestJson/RouteTable extractors"` +### Task 11: full verification + whole-branch review before pushing to #300. diff --git a/docs/superpowers/specs/2026-07-01-introspection-routes-design.md b/docs/superpowers/specs/2026-07-01-introspection-routes-design.md index 761f2416..2380cd7f 100644 --- a/docs/superpowers/specs/2026-07-01-introspection-routes-design.md +++ b/docs/superpowers/specs/2026-07-01-introspection-routes-design.md @@ -68,26 +68,37 @@ We want a single, consistent, "bind it yourself" mechanism for all three. injected data through independent typed extractors, not `ctx.introspection()`. See the Revision section. -> **Revision — 2026-07-02: independent typed extractors.** -> The injection (Decision 5) and the dispatch chokepoint are unchanged — -> `IntrospectionData` is still injected at `RouterInner::dispatch`. The only -> change is how handlers *access* it: instead of reaching into -> `ctx.introspection()` directly (Component 4), the two handlers that need it -> declare the dependency via independent typed extractors, matching the -> `Json`/`Path`/`AppConfig` idiom: +> **Revision — 2026-07-02: independent typed extractors + per-route gated injection.** +> Two changes to Decision 5 and Component 4: > +> **(1) Access via independent typed extractors.** The two handlers that need +> introspection data declare the dependency via extractors, matching the +> `Json`/`Path`/`AppConfig` idiom, instead of reaching into `ctx.introspection()`: > - `ManifestJson(pub Arc)` — the baked manifest JSON. Used by `manifest`. > - `RouteTable(pub Arc<[RouteInfo]>)` — the live route index. Used by `routes`. -> -> Both implement `FromRequest`, read from the injected `IntrospectionData` via +> Both implement `FromRequest`, read the injected `IntrospectionData` via > `ctx.introspection()`, and error (500 internal) if it is absent. `config` takes > `RequestContext` and uses neither — it reads the default config store. > -> No per-route gating, no `RouteEntry` flag, no `app!` macro changes, no builder -> methods: the routes remain plain `[[triggers.http]]` bindings (mountable by -> apps), and the extractor on the handler signature is the only thing that -> changes. Where this conflicts with Component 4's "handler reads -> `ctx.introspection()`", the Revision governs. +> **(2) Gated injection (supersedes Decision 5's "every request").** The router +> no longer injects `IntrospectionData` unconditionally. `RouteEntry` carries a +> `needs_introspection` flag; `RouterInner` precomputes a single +> `Arc` at `build()`; `dispatch` inserts a clone **only after +> matching a flagged route**. Non-introspection traffic (virtually all requests) +> pays nothing. No process-global state; each router owns its payload (so tests +> and multiple apps in one process stay independent). +> +> **How a route gets flagged — no app-facing change.** App authors write the +> same `[[triggers.http]]` row (`handler = "edgezero_core::introspection::…"`); +> the `app!` macro recognizes handlers in the `edgezero_core::introspection::` +> namespace and emits the flagged registration (`route_introspective`) +> automatically. There is NO `[introspection]` manifest section and no per-route +> knob in `edgezero.toml`. Manual `RouterBuilder` users opt in via +> `route_introspective(path, method, handler)`; a plain `.get(...)` binding of an +> introspection handler leaves the route unflagged and the extractor returns 500. +> +> Where this conflicts with Decision 5 ("every request") or Component 4 ("handler +> reads `ctx.introspection()`"), the Revision governs. ## Architecture From 692dd1781120c854b508fb8c033889e85d627fbb Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Thu, 2 Jul 2026 00:43:24 -0700 Subject: [PATCH 20/74] Spec/plan: #[action(introspection)] opt-in drives gated injection (app! untouched) --- .../plans/2026-07-02-introspection-routes.md | 155 +++++++----------- .../2026-07-01-introspection-routes-design.md | 46 ++++-- 2 files changed, 88 insertions(+), 113 deletions(-) diff --git a/docs/superpowers/plans/2026-07-02-introspection-routes.md b/docs/superpowers/plans/2026-07-02-introspection-routes.md index edbf0a76..a198559a 100644 --- a/docs/superpowers/plans/2026-07-02-introspection-routes.md +++ b/docs/superpowers/plans/2026-07-02-introspection-routes.md @@ -1201,111 +1201,70 @@ gh pr ready 300 --- -## Addendum (2026-07-02): independent extractors + per-route gated injection +--- -Two increments over Tasks 1–8. **Task 9 (extractors) is already committed** -(`0feb194`): `ManifestJson`/`RouteTable` extractors added, `manifest`/`routes` -handlers refactored to use them (`config` unchanged). This addendum records that -plus the remaining gating work. +## Addendum (2026-07-02): `#[action(introspection)]` opt-in + gated injection -**Task 10 (gating)** makes injection per-route instead of every-request, with NO -app-facing change: the `app!` macro auto-flags routes whose handler is in the -`edgezero_core::introspection::` namespace. No `[introspection]` section, no -`edgezero.toml` knob. +Supersedes the earlier "unconditional inject" (Tasks 2/3) and the dropped +"app!-flagging" idea. **Task 9 (extractors) is already committed** (`0feb194`): +`ManifestJson`/`RouteTable` added; `manifest`/`routes` refactored to use them. +This addendum makes injection per-route via an explicit `#[action]` opt-in. -Base: after `0feb194`. +**Design:** `#[action]` (no args) unchanged → handler fn (reports +`needs_introspection()==false` via the `Fn` blanket `DynHandler` impl). +`#[action(introspection)]` → handler emitted as a unit struct with its own +`impl DynHandler` reporting `true`. The router reads `boxed.needs_introspection()` +at `add_route`, flags the `RouteEntry`, and `dispatch` injects `IntrospectionData` +only for flagged routes. `app!` and `edgezero.toml` are untouched. No global, no +`FromRequest` const, no unstable specialization. Blast radius confirmed zero +(only `manifest`/`routes` become structs; neither is ever called directly). -### Task 10a: per-route gating in the router (edgezero-core) +Base: after `0feb194`. -**Files:** `crates/edgezero-core/src/router.rs` (+ its tests) +### Task 10a: `DynHandler::needs_introspection` (edgezero-core/src/handler.rs) +- [ ] Add to the `DynHandler` trait: `fn needs_introspection(&self) -> bool { false }`. Object-safe (concrete `&self`→bool). The blanket `impl DynHandler for F` inherits the default. `cargo test -p edgezero-core`; fmt; clippy. +- [ ] Commit: `Add DynHandler::needs_introspection (default false)` -- [ ] **Step 1: flag on `RouteEntry`** — add `needs_introspection: bool`; update the manual `Clone`/`clone_from` impls to copy it. -- [ ] **Step 2: `add_route` takes the flag; add `route_introspective`.** +### Task 10b: `#[action(introspection)]` param + struct codegen (edgezero-macros/src/action.rs) +- [ ] Replace the `!attr.is_empty()` rejection with a parser: `Punctuated` via `Parser::parse2`. Empty → `needs_introspection=false`. `introspection` → true. Any other ident → `compile_error!` "unknown #[action] parameter `X`; supported: introspection". (`use syn::parse::Parser; use syn::punctuated::Punctuated; use syn::Token;`) +- [ ] When `needs_introspection` is false: emit exactly today's output (the handler fn) — unchanged. +- [ ] When true: emit the inner fn (unchanged) plus: ```rust - fn add_route(&mut self, path: &str, method: Method, handler: H, needs_introspection: bool) - where H: IntoHandler { - let router = self.routes.entry(method.clone()).or_default(); - router - .insert(path, RouteEntry { handler: handler.into_handler(), needs_introspection }) - .unwrap_or_else(|err| panic!("duplicate route definition for {path}: {err}")); - self.route_info.push(RouteInfo::new(method, path.to_owned())); - } +#(#attrs)* +#[allow(non_camel_case_types)] +#vis struct #ident; - pub fn route(mut self, path: &str, method: Method, handler: H) -> Self - where H: IntoHandler { self.add_route(path, method, handler, false); self } - - /// Register a route whose handler needs introspection data - /// (`ManifestJson`/`RouteTable`). Only such routes get `IntrospectionData` - /// injected at dispatch. The `app!` macro emits this automatically for - /// handlers in the `edgezero_core::introspection::` namespace. - #[must_use] - pub fn route_introspective(mut self, path: &str, method: Method, handler: H) -> Self - where H: IntoHandler { self.add_route(path, method, handler, true); self } -``` - (`get`/`post`/`put`/`delete` keep delegating to `route` → flag stays false.) -- [ ] **Step 3: precompute the payload once in `build()`** and store `introspection: Arc` on `RouterInner` (replacing the bare `manifest_json` field it currently threads): -```rust - pub fn build(self) -> RouterService { - let route_index: Arc<[RouteInfo]> = Arc::from(self.route_info); - let introspection = Arc::new(IntrospectionData { - manifest_json: self.manifest_json, - routes: Arc::clone(&route_index), - }); - RouterService::new(self.routes, self.middlewares, route_index, introspection) - } -``` - (Update `RouterService::new` to take `introspection: Arc`.) -- [ ] **Step 4: gate the insert in `dispatch`** — move it AFTER `find_route`, only for flagged routes: -```rust - async fn dispatch(&self, request: Request) -> Result { - let method = request.method().clone(); - let path = request.uri().path().to_owned(); - match self.find_route(&method, &path) { - RouteMatch::Found(entry, params) => { - let needs = entry.needs_introspection; - let mut request = request; - if needs { - request.extensions_mut().insert(Arc::clone(&self.introspection)); - } - let ctx = RequestContext::new(request, params); - let next = Next::new(&self.middlewares, entry.handler.as_ref()); - next.run(ctx).await - } - RouteMatch::MethodNotAllowed(mut allowed) => { - allowed.sort_by(|l, r| l.as_str().cmp(r.as_str())); - Err(EdgeError::method_not_allowed(&method, &allowed)) - } - RouteMatch::NotFound => Err(EdgeError::not_found(path)), - } - } -``` - `RequestContext::introspection()` (context.rs) now reads `Arc`: -```rust - pub fn introspection(&self) -> Option<&crate::router::IntrospectionData> { - self.request.extensions().get::>() - .map(|arc| arc.as_ref()) +impl ::edgezero_core::handler::DynHandler for #ident { + #[inline] + fn call(&self, __ctx: ::edgezero_core::context::RequestContext) + -> ::edgezero_core::http::HandlerFuture { + ::std::boxed::Box::pin(async move { + #(#extract_stmts)* + let result = #inner_ident(#(#arg_idents),*).await; + ::edgezero_core::responder::Responder::respond(result) + }) } + #[inline] + fn needs_introspection(&self) -> bool { true } +} ``` -- [ ] **Step 5: tests** — the existing `dispatch_injects_introspection_data` / `middleware_sees_introspection_data` tests register their probe route with `.route_introspective("/", Method::GET, handler)`; ADD a negative test: a plain `.get("/x", handler)` route sees `ctx.introspection().is_none()`. Run `cargo test -p edgezero-core router introspection_data`, then full `cargo test -p edgezero-core`, `cargo fmt`, `cargo clippy -p edgezero-core --all-targets -- -D warnings`. -- [ ] **Step 6: introspection.rs tests** — the extractor tests that build routers with `.get("/m", manifest)` must switch to `.route_introspective("/m", Method::GET, manifest)` so the data is injected (else they now 500). Keep `manifest_without_baked_json_is_500` but make it register introspective WITHOUT `with_manifest_json` (data present, `manifest_json` None → 500). Add/keep a test that a NON-introspective binding of `manifest` yields 500 (no data injected). Run `cargo test -p edgezero-core introspection`. -- [ ] **Step 7: Commit** — `git commit -m "Gate introspection injection per route via route_introspective"` - -### Task 10b: `app!` macro auto-flags introspection routes - -**Files:** `crates/edgezero-macros/src/app.rs` (+ verify app-demo / generated project) - -- [ ] **Step 1: emit `route_introspective` for introspection-namespace handlers.** In `build_route_tokens`, per trigger: -```rust -const INTROSPECTION_NS: &str = "edgezero_core::introspection::"; -let is_introspective = trigger.handler.as_deref().is_some_and(|h| h.starts_with(INTROSPECTION_NS)); -let register = if is_introspective { - quote! { builder = builder.route_introspective(#path_lit, #method_tok, #handler_path); } -} else { - quote! { builder = builder.route(#path_lit, #method_tok, #handler_path); } -}; -``` - Match the file's existing `#method_tok`/`#handler_path` construction and how it currently emits `get`/`post`/`route`. Keep non-introspection routes byte-for-byte as before. `with_manifest_json` + `include_bytes!` tracking stay as-is. -- [ ] **Step 2: verify** — `cargo build -p edgezero-macros && cargo test -p edgezero-macros`; `cargo fmt --all && cargo clippy -p edgezero-macros -p edgezero-core --all-targets -- -D warnings`; `cd examples/app-demo && cargo test -p app-demo-core introspection_routes_are_wired` (app-demo binds the three handlers → macro auto-flags manifest/routes; assertions unchanged; `/config` unflagged but works via config store); `cargo test -p edgezero-cli --test generated_project_builds -- --ignored`. -- [ ] **Step 3: Commit** — `git commit -m "app!: auto-flag introspection-namespace routes as introspective"` - -### Task 11: full verification + whole-branch review before pushing to #300. + (`__ctx` is underscore-prefixed → no unused-var warning when a handler uses no extractors.) +- [ ] Update the macro's own unit tests: `rejects_attribute_arguments` → now assert `#[action(bogus)]` yields "unknown #[action] parameter"; add a test that `#[action(introspection)]` output contains `struct` + `DynHandler` + `needs_introspection`; keep `wraps_async_function` (plain `#[action]` still a fn). `cargo test -p edgezero-macros`; fmt; clippy. +- [ ] Commit: `#[action]: accept (introspection) param; emit capability-carrying handler struct` + +### Task 10c: router gating + context accessor (edgezero-core/src/router.rs, context.rs) +- [ ] `RouteEntry { handler, needs_introspection: bool }` (+ manual Clone copies the bool). +- [ ] `add_route`: `let boxed = handler.into_handler(); let needs = boxed.needs_introspection(); RouteEntry { handler: boxed, needs_introspection: needs }`. (No `route_introspective` needed — the flag is read from the handler.) +- [ ] `build()`: precompute `let introspection = Arc::new(IntrospectionData { manifest_json: self.manifest_json, routes: Arc::clone(&route_index) });` and pass to `RouterService::new` (replace the `manifest_json` param). `RouterInner` stores `introspection: Arc`. +- [ ] `dispatch`: remove the unconditional insert; after `RouteMatch::Found(entry, params)`, `if entry.needs_introspection { request.extensions_mut().insert(Arc::clone(&self.introspection)); }` then build `RequestContext`. +- [ ] `context.rs::introspection()`: read `Arc` (`get::>().map(|a| a.as_ref())`). +- [ ] Tests: rewrite `dispatch_injects_introspection_data`/`middleware_sees_introspection_data` to register a local probe `struct` whose `DynHandler::needs_introspection()` returns true (closures report false), asserting `ctx.introspection().is_some()`; ADD a negative test that a plain `.get("/x", closure)` route sees `ctx.introspection().is_none()`. `cargo test -p edgezero-core`; fmt; clippy. +- [ ] Commit: `Gate IntrospectionData injection on RouteEntry.needs_introspection` + +### Task 10d: opt the handlers in (edgezero-core/src/introspection.rs) +- [ ] Change `manifest` and `routes` from `#[action]` to `#[action(introspection)]`. `config` stays `#[action]`. The existing extractor tests still pass (`.get("/m", manifest)` registers the struct; auto-flagged → injected → 200; `manifest_without_baked_json_is_500` still 500 because `manifest_json` is None). `cargo test -p edgezero-core introspection`. +- [ ] Commit: `Opt manifest/routes into introspection injection via #[action(introspection)]` + +### Task 11: verification + review +- [ ] Full gates: fmt; clippy `-D warnings`; `cargo test --workspace --all-targets`; `cd examples/app-demo && cargo test --workspace --all-targets` (all existing handlers unchanged → app-demo unaffected; `introspection_routes_are_wired` still passes); `cargo test -p edgezero-cli --test generated_project_builds -- --ignored`; nested-AppConfig audit; feature checks; wasm32-wasip2. +- [ ] Whole-branch review of the addendum commits, then push to #300. diff --git a/docs/superpowers/specs/2026-07-01-introspection-routes-design.md b/docs/superpowers/specs/2026-07-01-introspection-routes-design.md index 2380cd7f..3b56e95b 100644 --- a/docs/superpowers/specs/2026-07-01-introspection-routes-design.md +++ b/docs/superpowers/specs/2026-07-01-introspection-routes-design.md @@ -80,22 +80,38 @@ We want a single, consistent, "bind it yourself" mechanism for all three. > `ctx.introspection()`, and error (500 internal) if it is absent. `config` takes > `RequestContext` and uses neither — it reads the default config store. > -> **(2) Gated injection (supersedes Decision 5's "every request").** The router -> no longer injects `IntrospectionData` unconditionally. `RouteEntry` carries a -> `needs_introspection` flag; `RouterInner` precomputes a single -> `Arc` at `build()`; `dispatch` inserts a clone **only after -> matching a flagged route**. Non-introspection traffic (virtually all requests) -> pays nothing. No process-global state; each router owns its payload (so tests -> and multiple apps in one process stay independent). +> **(2) Gated injection, driven by an explicit `#[action]` opt-in +> (supersedes Decision 5's "every request").** The router no longer injects +> `IntrospectionData` unconditionally. The capability is declared on the handler +> and rides it to registration — no `app!` change, no `edgezero.toml` change, no +> process-global, no unstable specialization: > -> **How a route gets flagged — no app-facing change.** App authors write the -> same `[[triggers.http]]` row (`handler = "edgezero_core::introspection::…"`); -> the `app!` macro recognizes handlers in the `edgezero_core::introspection::` -> namespace and emits the flagged registration (`route_introspective`) -> automatically. There is NO `[introspection]` manifest section and no per-route -> knob in `edgezero.toml`. Manual `RouterBuilder` users opt in via -> `route_introspective(path, method, handler)`; a plain `.get(...)` binding of an -> introspection handler leaves the route unflagged and the extractor returns 500. +> - **`#[action]` gains an optional parameter list.** `#[action]` (no args) is +> **unchanged** — it still expands to a handler fn, and via the `Fn` blanket +> `impl DynHandler` reports `needs_introspection() == false`. `#[action(introspection)]` +> expands the handler to a **unit struct** with its own `impl DynHandler` whose +> `needs_introspection()` returns `true`. The parameter list is future-proofed +> (`#[action(introspection, …)]`); unknown params are a compile error. A fn +> can't carry the flag past type-erasure, which is why opt-in handlers become +> structs — but that only affects handlers that opt in (here: `manifest`, +> `routes`); every other handler stays a fn, untouched. +> - **`DynHandler` gains `fn needs_introspection(&self) -> bool { false }`.** +> - **The router reads the flag generically at registration.** `add_route` calls +> `boxed.needs_introspection()` and stores it on `RouteEntry`. `RouterInner` +> precomputes a single `Arc` at `build()`; `dispatch` inserts +> a clone **only after matching a flagged route**. Non-introspection traffic +> (virtually all requests) pays nothing. Each router owns its payload, so tests +> and multiple apps in one process stay independent. +> - **The `ManifestJson`/`RouteTable` extractors are unchanged** — they remain the +> *access* mechanism (read `ctx.introspection()`); `#[action(introspection)]` is +> the *opt-in* that causes the data to be injected. `config` stays `#[action]` +> and uses neither. +> +> **No app-facing change.** App authors write the same `[[triggers.http]]` row; +> `manifest`/`routes` carry `#[action(introspection)]` in core, so the flag is +> intrinsic to those handlers. There is NO `[introspection]` manifest section, no +> per-route knob in `edgezero.toml`, and `app!` emits ordinary `builder.get(...)` +> — it never inspects handler paths. > > Where this conflicts with Decision 5 ("every request") or Component 4 ("handler > reads `ctx.introspection()`"), the Revision governs. From 9cb3b79953f06556894874190854dade1454502b Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Thu, 2 Jul 2026 00:54:27 -0700 Subject: [PATCH 21/74] Docs cleanup: single gated architecture in spec; plan status banner + atomic #[action(manifest|routes)] TDD tasks --- .../plans/2026-07-02-introspection-routes.md | 240 +++++-- .../2026-07-01-introspection-routes-design.md | 594 +++++++++--------- 2 files changed, 473 insertions(+), 361 deletions(-) diff --git a/docs/superpowers/plans/2026-07-02-introspection-routes.md b/docs/superpowers/plans/2026-07-02-introspection-routes.md index a198559a..beaa7ad3 100644 --- a/docs/superpowers/plans/2026-07-02-introspection-routes.md +++ b/docs/superpowers/plans/2026-07-02-introspection-routes.md @@ -2,9 +2,25 @@ > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. -**Goal:** Add three reusable core `#[action]` handlers — `edgezero_core::introspection::{manifest, config, routes}` — that any app binds via `[[triggers.http]]`, default-mounted at `/_/{manifest,config,routes}`. - -**Architecture:** The `app!` macro serializes the parsed manifest to JSON at expansion time and hands it to `RouterService::builder().with_manifest_json(...)`. `RouterInner::dispatch` injects an `IntrospectionData { manifest_json, routes }` extension into each request; the three core handlers read it (config reads the default config store instead). The legacy `enable_route_listing` machinery and `/__edgezero/routes` are removed. +> ## ⚠️ EXECUTION STATUS — READ FIRST +> +> **Tasks 1–9 below are COMPLETE and committed.** Do NOT re-implement them. +> +> **Tasks 2 and 3 describe the ORIGINAL architecture — UNCONDITIONAL per-request +> injection with handlers reading `ctx.introspection()` directly. That approach +> was SUPERSEDED.** They are retained only as the historical record of what was +> committed and then evolved. Do NOT implement Tasks 2/3 "as written"; the +> committed code has since been changed by the Addendum. +> +> **The ACTIVE, authoritative implementation path is the "Addendum (2026-07-02)" +> at the end of this document** — opt-in, per-route **gated** injection driven by +> `#[action(manifest|routes)]`, with typed extractors for access. It matches the +> rewritten spec. When the Addendum conflicts with the main body, the Addendum +> governs. See the spec's "Design evolution" section for the full path. + +**Goal:** Add three reusable core introspection handlers — `edgezero_core::introspection::{manifest, config, routes}` — that any app binds via `[[triggers.http]]`, default-mounted at `/_/{manifest,config,routes}`. + +**Architecture (final — see Addendum):** `Manifest` gains `Serialize`; the `app!` macro bakes it to JSON via `RouterService::builder().with_manifest_json(...)`. Handlers opt into introspection data with an atomic `#[action(manifest)]` / `#[action(routes)]` parameter, which expands them to capability-carrying handler structs; `add_route` reads `DynHandler::needs_introspection()` and flags the `RouteEntry`; `RouterInner::dispatch` injects a shared `Arc` **only for flagged routes**. `ManifestJson`/`RouteTable` extractors read it; `config` uses the default config store (no injection). `app!` and `edgezero.toml` never learn about introspection. The legacy `enable_route_listing` machinery and `/__edgezero/routes` are removed. **Tech Stack:** Rust 1.95 (edition 2021), `serde`/`serde_json`, `matchit` routing, `#[action]`/`app!` proc-macros, `futures::executor::block_on` for tests. WASM-first: no Tokio, no runtime-specific deps in core. @@ -1199,72 +1215,188 @@ gh pr ready 300 --- ---- - ---- - -## Addendum (2026-07-02): `#[action(introspection)]` opt-in + gated injection +## Addendum (2026-07-02): ACTIVE PATH — `#[action(manifest|routes)]` opt-in + gated injection -Supersedes the earlier "unconditional inject" (Tasks 2/3) and the dropped -"app!-flagging" idea. **Task 9 (extractors) is already committed** (`0feb194`): -`ManifestJson`/`RouteTable` added; `manifest`/`routes` refactored to use them. -This addendum makes injection per-route via an explicit `#[action]` opt-in. +**This supersedes Tasks 2 & 3's unconditional injection.** Task 9 (extractors) is +already committed (`0feb194`): `ManifestJson`/`RouteTable` extractors exist and +`manifest`/`routes` use them. The tasks below make injection **per-route gated** +via an atomic `#[action(...)]` opt-in, with `app!`/`edgezero.toml` untouched. -**Design:** `#[action]` (no args) unchanged → handler fn (reports -`needs_introspection()==false` via the `Fn` blanket `DynHandler` impl). -`#[action(introspection)]` → handler emitted as a unit struct with its own -`impl DynHandler` reporting `true`. The router reads `boxed.needs_introspection()` -at `add_route`, flags the `RouteEntry`, and `dispatch` injects `IntrospectionData` -only for flagged routes. `app!` and `edgezero.toml` are untouched. No global, no -`FromRequest` const, no unstable specialization. Blast radius confirmed zero -(only `manifest`/`routes` become structs; neither is ever called directly). +**Verified facts (from investigation):** +- `DynHandler` (`handler.rs`): `pub trait DynHandler: Send + Sync { fn call(&self, ctx: RequestContext) -> HandlerFuture; }`, blanket-impl'd for `F: Fn(RequestContext) -> Fut`. Object-safe with an added defaulted `needs_introspection`. `HandlerFuture = Pin> + 'static>>` (no `Send`). +- Handlers are invoked only via `DynHandler::call` (middleware `Next::run`), never as fns; only `manifest`/`routes` will become structs and neither is called directly → **zero blast radius**. +- `Responder::respond(self) -> Result`. Base: after `0feb194`. ### Task 10a: `DynHandler::needs_introspection` (edgezero-core/src/handler.rs) -- [ ] Add to the `DynHandler` trait: `fn needs_introspection(&self) -> bool { false }`. Object-safe (concrete `&self`→bool). The blanket `impl DynHandler for F` inherits the default. `cargo test -p edgezero-core`; fmt; clippy. -- [ ] Commit: `Add DynHandler::needs_introspection (default false)` -### Task 10b: `#[action(introspection)]` param + struct codegen (edgezero-macros/src/action.rs) -- [ ] Replace the `!attr.is_empty()` rejection with a parser: `Punctuated` via `Parser::parse2`. Empty → `needs_introspection=false`. `introspection` → true. Any other ident → `compile_error!` "unknown #[action] parameter `X`; supported: introspection". (`use syn::parse::Parser; use syn::punctuated::Punctuated; use syn::Token;`) -- [ ] When `needs_introspection` is false: emit exactly today's output (the handler fn) — unchanged. -- [ ] When true: emit the inner fn (unchanged) plus: +- [ ] **Step 1 — add the defaulted method.** In `handler.rs`, add to the trait: ```rust -#(#attrs)* -#[allow(non_camel_case_types)] -#vis struct #ident; +pub trait DynHandler: Send + Sync { + fn call(&self, ctx: RequestContext) -> HandlerFuture; -impl ::edgezero_core::handler::DynHandler for #ident { - #[inline] - fn call(&self, __ctx: ::edgezero_core::context::RequestContext) - -> ::edgezero_core::http::HandlerFuture { - ::std::boxed::Box::pin(async move { + /// Whether a route bound to this handler needs `IntrospectionData` injected + /// at dispatch. Default false; `#[action(manifest|routes)]` handlers override. + fn needs_introspection(&self) -> bool { + false + } +} +``` + The blanket `impl DynHandler for F` needs NO change (inherits the default). +- [ ] **Step 2 — verify.** `cargo build -p edgezero-core` (compiles; object safety intact). `cargo fmt -p edgezero-core`; `cargo clippy -p edgezero-core --all-targets -- -D warnings`. +- [ ] **Step 3 — commit:** `Add DynHandler::needs_introspection (default false)` + +### Task 10b: `#[action(manifest|routes)]` param parser + struct codegen (edgezero-macros/src/action.rs) + +- [ ] **Step 1 (RED) — macro unit tests.** Update/add tests in `action.rs`'s `#[cfg(test)]`: + - Replace `rejects_attribute_arguments` with `rejects_unknown_param`: input `#[action(bogus)]` on an async fn → rendered output contains `unknown #[action] parameter`. + - Add `introspection_param_emits_struct`: input `#[action(manifest)]` on `async fn manifest(...)` → rendered output contains `struct manifest` AND `DynHandler` AND `needs_introspection`. + - Keep `wraps_async_function` (plain `#[action]` still contains `fn demo` + `__demo_inner`). + Run `cargo test -p edgezero-macros` → the two new/changed tests FAIL (parser/codegen not present). +- [ ] **Step 2 — parse the param list.** Replace the current `if !attr.is_empty() { return Error…"does not accept arguments" }` guard with: +```rust +use syn::parse::Parser as _; +use syn::punctuated::Punctuated; + +let params: Punctuated = if attr.is_empty() { + Punctuated::new() +} else { + match Punctuated::::parse_terminated.parse2(attr.clone()) { + Ok(p) => p, + Err(err) => return err.to_compile_error(), + } +}; +let mut needs_introspection = false; +for param in ¶ms { + // Known atomic introspection capabilities. Extensible; all currently + // collapse to the single injected `IntrospectionData` bundle. + if param == "manifest" || param == "routes" { + needs_introspection = true; + } else { + return syn::Error::new( + param.span(), + format!("unknown #[action] parameter `{param}`; supported: manifest, routes"), + ) + .to_compile_error(); + } +} +``` +- [ ] **Step 3 — branch codegen.** Keep the existing `extract_stmts`/`arg_idents`/`inner_fn` computation. Then: +```rust +let output = if needs_introspection { + quote! { + #inner_fn + + #(#attrs)* + #[allow(non_camel_case_types)] + #vis struct #ident; + + impl ::edgezero_core::handler::DynHandler for #ident { + #[inline] + fn call(&self, __ctx: ::edgezero_core::context::RequestContext) + -> ::edgezero_core::http::HandlerFuture { + ::std::boxed::Box::pin(async move { + #(#extract_stmts)* + let result = #inner_ident(#(#arg_idents),*).await; + ::edgezero_core::responder::Responder::respond(result) + }) + } + #[inline] + fn needs_introspection(&self) -> bool { true } + } + } +} else { + // UNCHANGED from today — the fn form. + quote! { + #inner_fn + + #(#attrs)* + #vis async fn #ident( + __ctx: ::edgezero_core::context::RequestContext, + ) -> ::std::result::Result<::edgezero_core::http::Response, ::edgezero_core::error::EdgeError> { #(#extract_stmts)* let result = #inner_ident(#(#arg_idents),*).await; ::edgezero_core::responder::Responder::respond(result) + } + } +}; +output +``` +- [ ] **Step 4 (GREEN):** `cargo test -p edgezero-macros` (all pass). `cargo fmt --all`; `cargo clippy -p edgezero-macros --all-targets -- -D warnings`. +- [ ] **Step 5 — commit:** `#[action]: accept atomic (manifest|routes) params; emit capability-carrying handler struct` + +### Task 10c: router gating + accessor (edgezero-core/src/router.rs, context.rs) + +- [ ] **Step 1 (RED) — router tests.** In `router.rs` tests: + - Rewrite `dispatch_injects_introspection_data` and `middleware_sees_introspection_data` to register a LOCAL probe struct whose `DynHandler::needs_introspection()` returns `true` (a closure can't opt in), asserting `ctx.introspection().is_some()` from the handler and from a middleware respectively. + - Add `plain_route_gets_no_introspection`: a `.get("/x", |_ctx| async { Ok::<_,EdgeError>("ok") })` route asserts `ctx.introspection().is_none()`. + Example probe: +```rust +struct IntrospectiveProbe(std::sync::Arc>>); +impl crate::handler::DynHandler for IntrospectiveProbe { + fn call(&self, ctx: RequestContext) -> crate::http::HandlerFuture { + let cell = std::sync::Arc::clone(&self.0); + Box::pin(async move { + *cell.lock().unwrap() = Some(ctx.introspection().is_some()); + Ok("ok".into_response()?) // match the crate's response idiom }) } - #[inline] fn needs_introspection(&self) -> bool { true } } ``` - (`__ctx` is underscore-prefixed → no unused-var warning when a handler uses no extractors.) -- [ ] Update the macro's own unit tests: `rejects_attribute_arguments` → now assert `#[action(bogus)]` yields "unknown #[action] parameter"; add a test that `#[action(introspection)]` output contains `struct` + `DynHandler` + `needs_introspection`; keep `wraps_async_function` (plain `#[action]` still a fn). `cargo test -p edgezero-macros`; fmt; clippy. -- [ ] Commit: `#[action]: accept (introspection) param; emit capability-carrying handler struct` - -### Task 10c: router gating + context accessor (edgezero-core/src/router.rs, context.rs) -- [ ] `RouteEntry { handler, needs_introspection: bool }` (+ manual Clone copies the bool). -- [ ] `add_route`: `let boxed = handler.into_handler(); let needs = boxed.needs_introspection(); RouteEntry { handler: boxed, needs_introspection: needs }`. (No `route_introspective` needed — the flag is read from the handler.) -- [ ] `build()`: precompute `let introspection = Arc::new(IntrospectionData { manifest_json: self.manifest_json, routes: Arc::clone(&route_index) });` and pass to `RouterService::new` (replace the `manifest_json` param). `RouterInner` stores `introspection: Arc`. -- [ ] `dispatch`: remove the unconditional insert; after `RouteMatch::Found(entry, params)`, `if entry.needs_introspection { request.extensions_mut().insert(Arc::clone(&self.introspection)); }` then build `RequestContext`. -- [ ] `context.rs::introspection()`: read `Arc` (`get::>().map(|a| a.as_ref())`). -- [ ] Tests: rewrite `dispatch_injects_introspection_data`/`middleware_sees_introspection_data` to register a local probe `struct` whose `DynHandler::needs_introspection()` returns true (closures report false), asserting `ctx.introspection().is_some()`; ADD a negative test that a plain `.get("/x", closure)` route sees `ctx.introspection().is_none()`. `cargo test -p edgezero-core`; fmt; clippy. -- [ ] Commit: `Gate IntrospectionData injection on RouteEntry.needs_introspection` - -### Task 10d: opt the handlers in (edgezero-core/src/introspection.rs) -- [ ] Change `manifest` and `routes` from `#[action]` to `#[action(introspection)]`. `config` stays `#[action]`. The existing extractor tests still pass (`.get("/m", manifest)` registers the struct; auto-flagged → injected → 200; `manifest_without_baked_json_is_500` still 500 because `manifest_json` is None). `cargo test -p edgezero-core introspection`. -- [ ] Commit: `Opt manifest/routes into introspection injection via #[action(introspection)]` - -### Task 11: verification + review -- [ ] Full gates: fmt; clippy `-D warnings`; `cargo test --workspace --all-targets`; `cd examples/app-demo && cargo test --workspace --all-targets` (all existing handlers unchanged → app-demo unaffected; `introspection_routes_are_wired` still passes); `cargo test -p edgezero-cli --test generated_project_builds -- --ignored`; nested-AppConfig audit; feature checks; wasm32-wasip2. + Run `cargo test -p edgezero-core router introspection_data` → fails (flag/gating not present; `introspection()` type mismatch). +- [ ] **Step 2 — `RouteEntry` flag.** Add `needs_introspection: bool` and copy it in the manual `Clone`/`clone_from`. +- [ ] **Step 3 — read the flag in `add_route`:** +```rust +let boxed = handler.into_handler(); +let needs_introspection = boxed.needs_introspection(); +router.insert(path, RouteEntry { handler: boxed, needs_introspection }) + .unwrap_or_else(|err| panic!("duplicate route definition for {path}: {err}")); +``` +- [ ] **Step 4 — precompute the payload.** In `build()`: +```rust +let route_index: Arc<[RouteInfo]> = Arc::from(self.route_info); +let introspection = Arc::new(IntrospectionData { + manifest_json: self.manifest_json, + routes: Arc::clone(&route_index), +}); +RouterService::new(self.routes, self.middlewares, route_index, introspection) +``` + Change `RouterInner`'s `manifest_json: Option>` field to `introspection: Arc`, and `RouterService::new`'s last param likewise. +- [ ] **Step 5 — gate `dispatch`.** Remove the unconditional insert at the top; inside `RouteMatch::Found(entry, params)`: +```rust +let mut request = request; +if entry.needs_introspection { + request.extensions_mut().insert(::std::sync::Arc::clone(&self.introspection)); +} +let ctx = RequestContext::new(request, params); +``` + (`dispatch` reads `method`/`path` from `request` before the match, as today; make the bound `request` mutable in the arm.) +- [ ] **Step 6 — accessor.** `context.rs`: +```rust +pub fn introspection(&self) -> Option<&crate::router::IntrospectionData> { + self.request + .extensions() + .get::>() + .map(|arc| arc.as_ref()) +} +``` +- [ ] **Step 7 (GREEN):** `cargo test -p edgezero-core` (all pass). `cargo fmt`; `cargo clippy -p edgezero-core --all-targets -- -D warnings`. +- [ ] **Step 8 — commit:** `Gate IntrospectionData injection on RouteEntry.needs_introspection` + +### Task 10d: opt the handlers in + compile-level proof (edgezero-core/src/introspection.rs) + +- [ ] **Step 1 — opt in.** Change `manifest` from `#[action]` to `#[action(manifest)]`, and `routes` to `#[action(routes)]`. `config` stays `#[action]`. +- [ ] **Step 2 — compile/behavioral proof (this is the key test the reviewer asked for).** The existing `manifest_returns_injected_json` test already does `RouterService::builder().with_manifest_json("…").get("/m", manifest).build()` then `oneshot` → asserts 200 + body. Because `manifest` is now a UNIT STRUCT value, this test compiling and passing proves `.get("/m", manifest)` accepts the struct-as-handler and that `add_route` auto-flags it (else `oneshot` would 500 on the `ManifestJson` extractor). Keep it. Confirm `manifest_without_baked_json_is_500` still holds (route flagged → `IntrospectionData` injected, but `manifest_json` is `None` → extractor 500). Add `routes_returns_registered_routes` similarly if not already covered. +- [ ] **Step 3 — verify.** `cargo test -p edgezero-core introspection` (all pass, incl. body-shape assertions). +- [ ] **Step 4 — commit:** `Opt manifest/routes into gated injection via #[action(manifest|routes)]` + +### Task 11: full verification + whole-branch review + +- [ ] `cargo fmt --all -- --check`; `cargo clippy --workspace --all-targets --all-features -- -D warnings`; `cargo test --workspace --all-targets`. +- [ ] `cd examples/app-demo && cargo test --workspace --all-targets` (all other handlers unchanged → app-demo unaffected; `introspection_routes_are_wired` still passes: `manifest`/`routes` flagged, `config` via store). +- [ ] `cargo test -p edgezero-cli --test generated_project_builds -- --ignored` (template handlers are plain `#[action]` → still fns → generated project unaffected). +- [ ] `cargo run -q --bin check_no_nested_app_config --features nested-app-config-check -- examples/app-demo crates/edgezero-cli/src/templates`. +- [ ] `cargo check --workspace --all-targets --features "fastly cloudflare spin"`; `cargo check -p edgezero-adapter-spin --target wasm32-wasip2 --features spin`. - [ ] Whole-branch review of the addendum commits, then push to #300. diff --git a/docs/superpowers/specs/2026-07-01-introspection-routes-design.md b/docs/superpowers/specs/2026-07-01-introspection-routes-design.md index 3b56e95b..490ddd52 100644 --- a/docs/superpowers/specs/2026-07-01-introspection-routes-design.md +++ b/docs/superpowers/specs/2026-07-01-introspection-routes-design.md @@ -1,31 +1,39 @@ # Design: Pluggable Introspection Routes (manifest / config / routes) -**Date:** 2026-07-01 -**Status:** Approved — ready for implementation planning +**Date:** 2026-07-01 (architecture finalized 2026-07-02) +**Status:** Approved — implementation in progress **Scope:** `edgezero-core`, `edgezero-macros`, `examples/app-demo`, `edgezero-cli` templates +> **Note on history.** This spec was rewritten on 2026-07-02 to describe the +> **final** architecture only: **opt-in, per-route gated injection driven by +> `#[action(manifest|routes)]`, with typed extractors for access.** Earlier drafts +> described unconditional per-request injection with handlers reading +> `ctx.introspection()` directly; that approach was superseded (it taxed all +> traffic for endpoints hit rarely). The "Design evolution" section at the end +> records the path taken. Where any older wording survives elsewhere, this +> document governs. + ## Summary -Provide three reusable, framework-supplied HTTP handlers that let any EdgeZero -app expose its own metadata at runtime: +Three reusable, framework-supplied HTTP handlers let any EdgeZero app expose its +own metadata at runtime: -| Handler path | Emits | -| --------------------------------------- | ------------------------------------------------- | -| `edgezero_core::introspection::manifest` | The full manifest as JSON (baked at compile time) | +| Handler path | Emits | +| ---------------------------------------- | ------------------------------------------------------- | +| `edgezero_core::introspection::manifest` | The full manifest as JSON (baked at compile time) | | `edgezero_core::introspection::config` | The default config-store envelope `.data` (secret-safe) | -| `edgezero_core::introspection::routes` | `[{ "method", "path" }]` from the live route index | +| `edgezero_core::introspection::routes` | `[{ "method", "path" }]` from the live route index | -These are ordinary handlers. Apps wire them like any other route via -`[[triggers.http]]` in `edgezero.toml`, choosing their own paths. There is **no** -special manifest section and **no** dedicated builder API. `app-demo` and every -generated app ship with the three routes pre-wired under a per-app namespace -`/_/{manifest,config,routes}` (e.g. `/_app-demo/manifest`), but those -are plain trigger rows a developer can edit or delete. +Apps bind them like any other route via `[[triggers.http]]`, choosing their own +paths. There is **no** `[introspection]` manifest section and **no** dedicated +builder API. `app-demo` and every generated app ship the three routes pre-wired +under `/_/{manifest,config,routes}`, as plain trigger rows a developer +can edit or delete. -This design also **removes** the existing built-in route-listing machinery +This design also **removes** the legacy built-in route-listing machinery (`enable_route_listing`, `enable_route_listing_at`, `DEFAULT_ROUTE_LISTING_PATH`, `/__edgezero/routes`, `RouteListingEntry`, `build_listing_response`) in favor of -the new bindable `routes` handler. +the bindable `routes` handler. ## Motivation @@ -36,362 +44,334 @@ Today there is no runtime way to inspect what an app *is*: `run_app(include_str!("edgezero.toml"), …)` shape, so a running adapter binary no longer carries the manifest. - The **app config** is reachable at runtime through the config store, but only - via the typed `AppConfig` extractor, which resolves secrets and requires the + via the typed `AppConfig` extractor, which resolves secrets and needs the app's concrete config type. -- The only built-in introspection is an opt-in route listing at - `/__edgezero/routes`, wired through a bespoke builder method - (`enable_route_listing`) rather than the normal routing path. +- The only built-in introspection is a route listing at `/__edgezero/routes`, + wired through a bespoke builder method rather than the normal routing path. -We want a single, consistent, "bind it yourself" mechanism for all three. +We want a single, consistent, "bind it yourself" mechanism for all three — that +costs nothing for the ~100% of requests that are not introspection calls. -## Key Decisions (resolved during design) +## Key Decisions 1. **Manifest output** — bake the full manifest as JSON. `Manifest` gains `Serialize`; the `app!` macro serializes the parsed manifest at expansion time - and hands the JSON string to the router. -2. **Config output** — emit the raw config-store `BlobEnvelope.data`. This is - generic (core needs no knowledge of the app's typed config `C`) and - secret-safe: secret fields appear as unresolved key-name references, never - resolved values (resolution only happens inside the typed `AppConfig` - extractor). -3. **Wiring** — plain `[[triggers.http]]` bindings referencing stable core - handler paths. No `[introspection]` manifest section; no builder methods. -4. **Paths** — per-app namespace `/_/{manifest,config,routes}` - (single underscore). These are just the default paths written into the - templates; the developer controls them. -5. **Injection, not a global** — the app-specific data (manifest JSON + route - index) is injected into the request at the shared router dispatch chokepoint - in core. No process-global state; no per-adapter changes. -6. **Remove route listing** — delete the entire `enable_route_listing` machinery - and `/__edgezero/routes`. -7. **Typed extractors for access (revised 2026-07-02)** — handlers access the - injected data through independent typed extractors, not `ctx.introspection()`. - See the Revision section. - -> **Revision — 2026-07-02: independent typed extractors + per-route gated injection.** -> Two changes to Decision 5 and Component 4: -> -> **(1) Access via independent typed extractors.** The two handlers that need -> introspection data declare the dependency via extractors, matching the -> `Json`/`Path`/`AppConfig` idiom, instead of reaching into `ctx.introspection()`: -> - `ManifestJson(pub Arc)` — the baked manifest JSON. Used by `manifest`. -> - `RouteTable(pub Arc<[RouteInfo]>)` — the live route index. Used by `routes`. -> Both implement `FromRequest`, read the injected `IntrospectionData` via -> `ctx.introspection()`, and error (500 internal) if it is absent. `config` takes -> `RequestContext` and uses neither — it reads the default config store. -> -> **(2) Gated injection, driven by an explicit `#[action]` opt-in -> (supersedes Decision 5's "every request").** The router no longer injects -> `IntrospectionData` unconditionally. The capability is declared on the handler -> and rides it to registration — no `app!` change, no `edgezero.toml` change, no -> process-global, no unstable specialization: -> -> - **`#[action]` gains an optional parameter list.** `#[action]` (no args) is -> **unchanged** — it still expands to a handler fn, and via the `Fn` blanket -> `impl DynHandler` reports `needs_introspection() == false`. `#[action(introspection)]` -> expands the handler to a **unit struct** with its own `impl DynHandler` whose -> `needs_introspection()` returns `true`. The parameter list is future-proofed -> (`#[action(introspection, …)]`); unknown params are a compile error. A fn -> can't carry the flag past type-erasure, which is why opt-in handlers become -> structs — but that only affects handlers that opt in (here: `manifest`, -> `routes`); every other handler stays a fn, untouched. -> - **`DynHandler` gains `fn needs_introspection(&self) -> bool { false }`.** -> - **The router reads the flag generically at registration.** `add_route` calls -> `boxed.needs_introspection()` and stores it on `RouteEntry`. `RouterInner` -> precomputes a single `Arc` at `build()`; `dispatch` inserts -> a clone **only after matching a flagged route**. Non-introspection traffic -> (virtually all requests) pays nothing. Each router owns its payload, so tests -> and multiple apps in one process stay independent. -> - **The `ManifestJson`/`RouteTable` extractors are unchanged** — they remain the -> *access* mechanism (read `ctx.introspection()`); `#[action(introspection)]` is -> the *opt-in* that causes the data to be injected. `config` stays `#[action]` -> and uses neither. -> -> **No app-facing change.** App authors write the same `[[triggers.http]]` row; -> `manifest`/`routes` carry `#[action(introspection)]` in core, so the flag is -> intrinsic to those handlers. There is NO `[introspection]` manifest section, no -> per-route knob in `edgezero.toml`, and `app!` emits ordinary `builder.get(...)` -> — it never inspects handler paths. -> -> Where this conflicts with Decision 5 ("every request") or Component 4 ("handler -> reads `ctx.introspection()`"), the Revision governs. + and hands the JSON string to the router (`with_manifest_json`). +2. **Config output** — emit the raw config-store `BlobEnvelope.data`. Generic + (core needs no knowledge of the app's typed config `C`) and secret-safe: + secret fields stay as unresolved key-name references; resolution happens only + inside the typed `AppConfig` extractor. +3. **Wiring** — plain `[[triggers.http]]` bindings to stable core handler paths. + No `[introspection]` section, no builder enable-API, and the `app!` macro does + **not** inspect handler paths. +4. **Paths** — per-app namespace `/_/{manifest,config,routes}` (single + underscore); just the default paths written into the templates. +5. **Access via typed extractors** — handlers that need injected data declare it + in their signature, matching the `Json`/`Path`/`AppConfig` idiom: + - `ManifestJson(pub Arc)` — the baked manifest JSON (used by `manifest`). + - `RouteTable(pub Arc<[RouteInfo]>)` — the live route index (used by `routes`). + Both implement `FromRequest`, read the injected `IntrospectionData` via + `ctx.introspection()`, and return `500` if it is absent. `config` takes + `RequestContext` and uses neither. +6. **Opt-in, per-route gated injection driven by `#[action(...)]`** — the router + injects `IntrospectionData` **only for routes whose handler opted in**, never + for general traffic. The opt-in is an atomic `#[action]` parameter and the + capability rides the handler to registration (details in Component 2/3). No + process-global state, no unstable specialization, no `app!`/`edgezero.toml` + change. +7. **Remove route listing** — delete the `enable_route_listing` machinery and + `/__edgezero/routes`. ## Architecture ### Data flow ``` -compile time runtime (per request) ------------- --------------------- +compile time runtime +------------ ------- edgezero.toml - │ app!() macro parses Manifest + │ app!() parses Manifest │ serde_json::to_string(&manifest) ▼ build_router() - builder.with_manifest_json("{…}") RouterService::oneshot(req) - │ └─ RouterInner::dispatch(req) - ▼ │ req.extensions_mut().insert( -RouterInner { manifest_json, │ IntrospectionData { - route_index, … } │ manifest_json, routes }) - ▼ - handler reads ctx.introspection() - manifest → returns baked JSON - routes → projects route index - config → reads default config store + builder.with_manifest_json("{…}") RouterService::oneshot(req) + builder.get(path, introspection::routes) └─ RouterInner::dispatch(req) + │ │ find_route(req) → RouteEntry + ▼ │ if entry.needs_introspection: +RouterInner { │ req.extensions.insert( + introspection: Arc, ──────┼────► Arc::clone(introspection)) +} (built once) ▼ + handler runs; extractor reads + ctx.introspection(): + manifest → ManifestJson(json) + routes → RouteTable(index) + config → default config store (no injection) ``` +- **The opt-in is on the handler.** `#[action(manifest)]` / `#[action(routes)]` + expand the handler to a capability-carrying struct (below). `add_route` reads + that capability and flags the `RouteEntry`. `dispatch` injects the shared + `Arc` only for flagged routes. - **manifest**: parsed at compile time, re-serialized to JSON by the macro, - baked as a string literal into `build_router()`, stored on `RouterInner`, - injected into each request, returned verbatim. No runtime TOML dependency. -- **routes**: derived at request time from the live route index already held by - `RouterInner` (the actually-registered routes, not a manifest projection). -- **config**: read at request time from the default config store; independent of - the manifest JSON. - -### Component 1 — `Manifest: Serialize` (`edgezero-core/src/manifest.rs`) - -Add `Serialize` to the derive list on `Manifest` and every nested struct that -must appear in the output (`ManifestApp`, `ManifestTriggers`, -`ManifestHttpTrigger`, `ManifestEnvironment`, `ManifestBinding`, -`ManifestAdapter` and its sub-structs, `ManifestLogging*`, `ManifestStores`, -`StoreDeclaration`, etc.). - -- Internal-only fields already carry `#[serde(skip)]` (`root`, - `logging_resolved`) and stay out of the output. -- Secret **values** are never stored in the manifest — only binding - declarations (name / env / description) — so serialized output is secret-safe. -- Verify round-trip is not required; this is a one-way (serialize-for-output) - addition. Existing `Deserialize`/`Validate` behavior is unchanged. - -### Component 2 — Router injection (`edgezero-core/src/router.rs`) - -New public struct carrying the per-request introspection payload: + baked into `build_router()`, held on the router's `IntrospectionData`, injected + for `manifest`-flagged routes, returned verbatim. No runtime TOML dependency. +- **routes**: projected at request time from the live route index in + `IntrospectionData`. +- **config**: read from the default config store; needs no injection. + +### Component 1 — `Manifest: Serialize` (`edgezero-core/src/manifest.rs`) *(done)* + +`Serialize` added to `Manifest` and every nested struct that appears in output. +The enums `HttpMethod`/`BodyMode`/`LogLevel` get **manual** `Serialize` impls +mirroring their manual `Deserialize` (wire strings `"GET"`/`"buffered"`/`"info"`, +and `body_mode` serializes as the renamed key `body-mode`). Secret **values** are +never serialized: `environment.secrets` entries omit `value` via a +`serialize_with` redactor; `environment.variables` keep it. Internal fields +(`root`, `logging_resolved`) stay `#[serde(skip)]`. + +### Component 2 — `#[action]` opt-in + capability-carrying handlers (`edgezero-macros/src/action.rs`) + +`#[action]` gains an **optional atomic parameter list** naming the introspection +data the handler needs: + +- **`#[action]`** (no params) — unchanged. Expands to a handler **fn**, which via + the existing `Fn` blanket `impl DynHandler` reports `needs_introspection() == false`. +- **`#[action(manifest)]`, `#[action(routes)]`, `#[action(manifest, routes)]`** — + expand the handler to a **unit struct** with its own `impl DynHandler` whose + `needs_introspection()` returns `true`. (A fn can't carry a per-item flag past + type-erasure into `Arc`; a struct can. Only opt-in handlers + become structs; every other handler stays a fn.) + +The macro validates each param against the known set `{ manifest, routes }` and +emits `compile_error!` on an unknown ident. The set is extensible (future atomic +capabilities are new idents). The atomic names are the declarative surface; since +`IntrospectionData` is one cheap `Arc` bundle, all recognized capabilities +currently collapse to the single `needs_introspection()` gate (room to split +payloads later without an attribute change). + +Generated struct (paths absolute, matching the existing macro): ```rust -#[derive(Clone)] -pub struct IntrospectionData { - pub manifest_json: Option>, - pub routes: Arc<[RouteInfo]>, +#inner_fn // the user's body, unchanged + +#(#attrs)* +#[allow(non_camel_case_types)] +#vis struct #ident; + +impl ::edgezero_core::handler::DynHandler for #ident { + #[inline] + fn call(&self, __ctx: ::edgezero_core::context::RequestContext) + -> ::edgezero_core::http::HandlerFuture { + ::std::boxed::Box::pin(async move { + #(#extract_stmts)* // ::from_request(&__ctx).await? + let result = #inner_ident(#(#arg_idents),*).await; + ::edgezero_core::responder::Responder::respond(result) + }) + } + #[inline] + fn needs_introspection(&self) -> bool { true } } ``` -Changes: +### Component 3 — Router gating + accessor (`edgezero-core/src/{handler,router,context}.rs`) -- `RouterInner` gains `manifest_json: Option>`. -- `RouterBuilder` gains `manifest_json: Option>` plus a setter: +- **`DynHandler`** gains `fn needs_introspection(&self) -> bool { false }` + (object-safe; the `Fn` blanket impl inherits the default). +- **`RouteEntry`** gains `needs_introspection: bool`. `add_route` reads it from + the boxed handler at registration: ```rust - pub fn with_manifest_json>>(mut self, json: S) -> Self { … } + let boxed = handler.into_handler(); + let needs_introspection = boxed.needs_introspection(); + router.insert(path, RouteEntry { handler: boxed, needs_introspection }); ``` - `build()` threads it into `RouterService::new(...)` / `RouterInner`. -- `RouterInner::dispatch(mut req)` inserts the extension **before** middleware and - routing: +- **`RouterInner`** holds a precomputed `introspection: Arc`, + built once in `build()` from `self.manifest_json` + the route index. + `RouterBuilder::with_manifest_json(impl Into>)` (set by the `app!` + macro) supplies the JSON. +- **`RouterInner::dispatch`** injects only for flagged routes, after matching: ```rust - req.extensions_mut().insert(IntrospectionData { - manifest_json: self.manifest_json.clone(), - routes: Arc::clone(&self.route_index), - }); + match self.find_route(&method, &path) { + RouteMatch::Found(entry, params) => { + let mut request = request; + if entry.needs_introspection { + request.extensions_mut().insert(Arc::clone(&self.introspection)); + } + let ctx = RequestContext::new(request, params); + let next = Next::new(&self.middlewares, entry.handler.as_ref()); + next.run(ctx).await + } + // MethodNotAllowed / NotFound unchanged + } + ``` +- **`RequestContext::introspection()`** reads the `Arc`: + ```rust + pub fn introspection(&self) -> Option<&crate::router::IntrospectionData> { + self.request.extensions().get::>() + .map(|arc| arc.as_ref()) + } ``` - `route_index` is already an `Arc<[RouteInfo]>`, so the clone is cheap. - -### Component 3 — `RequestContext` accessor (`edgezero-core/src/context.rs`) +`IntrospectionData` is the injected payload: ```rust -#[inline] -pub fn introspection(&self) -> Option<&IntrospectionData> { - self.request.extensions().get::() +#[derive(Clone)] +pub struct IntrospectionData { + pub manifest_json: Option>, + pub routes: Arc<[RouteInfo]>, } ``` -Mirrors the existing extension-backed accessors (`config_store*`, `kv_store*`). - -### Component 4 — `edgezero_core::introspection` module (new file) +### Component 4 — `edgezero_core::introspection` module (`edgezero-core/src/introspection.rs`) -Three handlers written with `#[action]`, plus a small JSON shape for `routes`. +The extractors and three handlers: ```rust -/// GET — full manifest as JSON. -#[action] -pub async fn manifest(ctx: RequestContext) -> Result { - let json = ctx - .introspection() - .and_then(|d| d.manifest_json.clone()) - .ok_or_else(|| EdgeError::internal("manifest introspection data not available"))?; - // application/json, body = json verbatim +pub struct ManifestJson(pub Arc); +#[async_trait(?Send)] +impl FromRequest for ManifestJson { + async fn from_request(ctx: &RequestContext) -> Result { + ctx.introspection().and_then(|d| d.manifest_json.clone()).map(ManifestJson) + .ok_or_else(|| EdgeError::internal(anyhow::anyhow!("manifest introspection data not available"))) + } } -/// GET — [{ "method", "path" }] for every registered route. -#[action] -pub async fn routes(ctx: RequestContext) -> Result { - let routes = ctx.introspection().map(|d| &d.routes) /* → Vec */; - // application/json +pub struct RouteTable(pub Arc<[RouteInfo]>); +#[async_trait(?Send)] +impl FromRequest for RouteTable { + async fn from_request(ctx: &RequestContext) -> Result { + ctx.introspection().map(|d| RouteTable(Arc::clone(&d.routes))) + .ok_or_else(|| EdgeError::internal(anyhow::anyhow!("route-table introspection data not available"))) + } +} + +#[action(manifest)] +pub async fn manifest(ManifestJson(json): ManifestJson) -> Result { + json_response(StatusCode::OK, Body::text(json.to_string())) +} + +#[action(routes)] +pub async fn routes(RouteTable(table): RouteTable) -> Result { + let views: Vec = table.iter() + .map(|r| RouteView { method: r.method().as_str().to_owned(), path: r.path().to_owned() }) + .collect(); + json_response(StatusCode::OK, Body::json(&views).map_err(EdgeError::internal)?) } -/// GET — the default config-store envelope `data` (secret-safe). #[action] pub async fn config(ctx: RequestContext) -> Result { let binding = ctx.config_store_default_binding() .ok_or_else(|| EdgeError::not_found("no default config store registered"))?; - // read raw blob at binding.default_key via binding.handle - // parse BlobEnvelope, emit envelope.data as application/json + let raw = binding.handle.get(&binding.default_key).await + .map_err(EdgeError::from)? // preserves 503/400/500 + .ok_or_else(|| EdgeError::not_found("no config blob in default store"))?; + let envelope: BlobEnvelope = serde_json::from_str(&raw) + .map_err(|e| EdgeError::internal(anyhow::anyhow!("envelope parse failed: {e}")))?; + envelope.verify().map_err(|e| EdgeError::internal(anyhow::anyhow!("envelope verification failed: {e}")))?; + json_response(StatusCode::OK, Body::json(&envelope.into_data()).map_err(EdgeError::internal)?) } ``` -Notes: - -- `RouteEntryView { method: String, path: String }` replaces the removed - `RouteListingEntry`. -- `config` reads the raw blob string from the config-store handle (the same read - `extract_from_handle` performs) and parses `BlobEnvelope`; it does **not** run - secret resolution or typed deserialization. -- Error mapping: absent manifest → `500` internal (should not happen once wired); - missing config store or missing blob → `404`. -- The handlers must be reachable by the `app!` macro's `parse_handler_path`, - which already resolves arbitrary `a::b::c` paths (it resolves - `app_demo_core::handlers::root` today), so `edgezero_core::introspection::…` - resolves the same way. - -### Component 5 — `app!` macro (`edgezero-macros/src/app.rs`) - -- After parsing the manifest, serialize it: `serde_json::to_string(&manifest)`. - On serialization error, emit a `compile_error!`. -- Emit one added line in the generated `build_router()`: - ```rust - pub fn build_router() -> edgezero_core::router::RouterService { - let mut builder = edgezero_core::router::RouterService::builder(); - builder = builder.with_manifest_json(#manifest_json_lit); - #(#middleware_tokens)* - #(#route_tokens)* - builder.build() - } - ``` -- No route wiring for introspection (routes come from `[[triggers.http]]`). -- `edgezero-macros` needs `serde_json` as a (build-time) dependency; `Manifest` - must be `Serialize` (Component 1). - -### Component 6 — Removals - -Delete from `edgezero-core/src/router.rs`: - -- `pub const DEFAULT_ROUTE_LISTING_PATH` -- `RouterBuilder::enable_route_listing`, `RouterBuilder::enable_route_listing_at` -- `RouterBuilder.route_listing_path` field and the listing branch inside `build()` -- `build_listing_response` -- `RouteListingEntry` -- All associated unit tests (`route_listing_*`) - -Grep the workspace for any other references (docs, examples, adapter code) and -remove/update them so nothing depends on `/__edgezero/routes`. - -### Component 7 — Templates (default bindings) - -Add three trigger rows, wired to the core handlers, under `/_/…`. - -`examples/app-demo/edgezero.toml`: - -```toml -[[triggers.http]] -id = "manifest" -path = "/_app-demo/manifest" -methods = ["GET"] -handler = "edgezero_core::introspection::manifest" -description = "App manifest as JSON" - -[[triggers.http]] -id = "config" -path = "/_app-demo/config" -methods = ["GET"] -handler = "edgezero_core::introspection::config" -description = "Effective app config (secret-safe)" - -[[triggers.http]] -id = "routes" -path = "/_app-demo/routes" -methods = ["GET"] -handler = "edgezero_core::introspection::routes" -description = "Registered route table" -``` +`RouteView { method: String, path: String }` (derives `Serialize`) is the JSON +shape for `routes`. `Response` is imported from `crate::http`. + +### Component 5 — `app!` macro (`edgezero-macros/src/app.rs`) *(done, unchanged by the gating work)* + +- Serialize the parsed manifest (`serde_json::to_string`, `compile_error!` on + failure) and emit `builder = builder.with_manifest_json()` as the first + builder mutation in `build_router()`. +- Emit `const _: &[u8] = include_bytes!();` so Cargo treats + `edgezero.toml` as a build input (rebuild on manifest change). +- Route registration is ordinary `builder.get(path, handler)` / `route(...)`; the + macro does **not** inspect handler paths. Gating comes entirely from the + handler's `needs_introspection()`. + +### Component 6 — Removals *(done)* + +Deleted from `router.rs`: `DEFAULT_ROUTE_LISTING_PATH`, `enable_route_listing`, +`enable_route_listing_at`, the `route_listing_path` field + listing branch in +`build()`, `build_listing_response`, `RouteListingEntry`, and all `route_listing_*` +tests. No workspace references to `/__edgezero/routes` remain. -`crates/edgezero-cli/src/templates/root/edgezero.toml.hbs`: the same three rows, -using `path = "/_{{name}}/manifest"` etc. and the same `edgezero_core::introspection::*` -handlers. (`{{name}}` is the sanitized app name already used elsewhere in the -template.) +### Component 7 — Templates + app-demo (default bindings) *(done)* +Three `[[triggers.http]]` rows in `examples/app-demo/edgezero.toml` and in +`crates/edgezero-cli/src/templates/root/edgezero.toml.hbs` (using `/_{{name}}/…` +and `{{{adapter_list}}}`), bound to `edgezero_core::introspection::{manifest,config,routes}`. No template handler code is generated — the handlers live in core. ## Interfaces (summary) -| Unit | Public surface | Depends on | -| ----------------------- | ---------------------------------------------------------- | ----------------------------------- | -| `IntrospectionData` | `{ manifest_json: Option>, routes: Arc<[RouteInfo]> }` | `RouteInfo` | -| `RouterBuilder` | `with_manifest_json(impl Into>)` | — | -| `RequestContext` | `introspection() -> Option<&IntrospectionData>` | request extensions | -| `introspection::manifest` | `#[action]` GET → JSON | `ctx.introspection()` | -| `introspection::routes` | `#[action]` GET → JSON | `ctx.introspection()` | -| `introspection::config` | `#[action]` GET → JSON | default config store, `BlobEnvelope`| +| Unit | Public surface | +| -------------------------- | --------------------------------------------------------------- | +| `IntrospectionData` | `{ manifest_json: Option>, routes: Arc<[RouteInfo]> }` | +| `DynHandler` | `fn needs_introspection(&self) -> bool { false }` | +| `RouterBuilder` | `with_manifest_json(impl Into>)` | +| `RequestContext` | `introspection() -> Option<&IntrospectionData>` | +| `introspection::ManifestJson` | `FromRequest`; `pub Arc` | +| `introspection::RouteTable` | `FromRequest`; `pub Arc<[RouteInfo]>` | +| `introspection::{manifest,routes}` | `#[action(manifest)]` / `#[action(routes)]` GET → JSON | +| `introspection::config` | `#[action]` GET → JSON (default config store) | ## Error Handling -- **manifest** absent from `IntrospectionData`: `500 internal` (indicates a - wiring bug; always present once the macro sets it). -- **config**: no default config store → `404 not found`; no blob at - `default_key` → `404`; malformed envelope → `500 internal`. -- **routes**: `IntrospectionData` absent → empty list is acceptable, or `500`; - chosen behavior: return an empty array rather than error, since routes are - always injected by dispatch. +- **manifest** / **routes**: extractor returns `500 internal` if + `IntrospectionData` is absent (route not opted in) or, for `manifest`, if + `manifest_json` is `None` (no `with_manifest_json`). +- **config**: no default config store → `404`; no blob → `404`; `ConfigStoreError` + mapped via `EdgeError::from` (503 unavailable / 400 invalid-key / 500 internal); + malformed or unverifiable envelope → `500`. ## Testing Strategy Colocated `#[cfg(test)]`, `futures::executor::block_on` (no Tokio), no network. -- **router.rs**: dispatch test asserting an `IntrospectionData` extension is - present in the request seen by a handler, with the expected route index and - `manifest_json`. Remove old `route_listing_*` tests. -- **introspection module**: - - `manifest` returns the injected JSON with `application/json`. - - `routes` returns the projected `[{method, path}]`. - - `config` returns `BlobEnvelope.data` from a stub config store; `404` when no - store is registered; `404` when the blob is missing. -- **macro (`edgezero-macros`)**: trybuild/expansion assertion that - `with_manifest_json(...)` is emitted with valid JSON for a sample manifest. -- **app-demo**: extend router/handler tests to hit `/_app-demo/manifest`, - `/_app-demo/config`, `/_app-demo/routes` and assert shapes. - -## CI Gates (unchanged) +- **macros**: `#[action]` (no params) still emits a fn; `#[action(manifest)]` + emits a struct impl'ing `DynHandler` with `needs_introspection() == true`; + `#[action(bogus)]` is a compile error. Plus a **compile/behavioral** test in + core that a struct handler registers and runs: `.get("/m", manifest)` → + `oneshot` → 200 (proves the unit-struct-as-handler-value path works end to end). +- **handler.rs / router.rs**: `needs_introspection()` default is false for fn + handlers; a flagged route injects `IntrospectionData` (handler + middleware see + it); a non-flagged route does **not** (`ctx.introspection().is_none()`). +- **introspection module**: `manifest` returns injected JSON; `routes` returns + `[{method,path}]`; `config` returns envelope `data` and the full status matrix + (200/404/400/503/500×3); `manifest` with no baked JSON → 500. +- **app-demo**: `/_app-demo/{manifest,routes}` → 200 + body shape; `/config` with + a seeded `ConfigRegistry` → 200 + secret-safe key-name. + +## CI Gates 1. `cargo fmt --all -- --check` 2. `cargo clippy --workspace --all-targets --all-features -- -D warnings` -3. `cargo test --workspace --all-targets` -4. `cargo check --workspace --all-targets --features "fastly cloudflare spin"` -5. `cargo check -p edgezero-adapter-spin --target wasm32-wasip2 --features spin` +3. `cargo test --workspace --all-targets` (+ `cd examples/app-demo && cargo test --workspace --all-targets`) +4. `cargo test -p edgezero-cli --test generated_project_builds -- --ignored` +5. `cargo run -q --bin check_no_nested_app_config --features nested-app-config-check -- examples/app-demo crates/edgezero-cli/src/templates` +6. `cargo check --workspace --all-targets --features "fastly cloudflare spin"` +7. `cargo check -p edgezero-adapter-spin --target wasm32-wasip2 --features spin` ## Constraints & Non-Goals -- **WASM-first**: no Tokio, no runtime-specific deps added. `Arc`, `serde_json`, - and `Once*`-free injection are all WASM-safe. `serde_json` is added only to - `edgezero-macros` (a proc-macro crate that runs at build time). -- **No auth/gating in this iteration**: endpoints are exposed wherever the app - binds them. Because they are plain triggers, a developer who does not want them - simply omits the rows. Config output is already secret-safe. Access control - (e.g. dev-only, header-gated) is a possible follow-up, out of scope here. -- **Single-app assumption**: `manifest_json` is per-`RouterService`, so multiple - distinct apps in one process each carry their own — no shared/global state and - no cross-app leakage. -- **No `[introspection]` manifest section** and **no builder-based enable API** — - explicitly rejected in favor of plain `[[triggers.http]]` bindings. - -## File-Change Checklist (for planning) - -- [ ] `crates/edgezero-core/src/manifest.rs` — add `Serialize` derives. -- [ ] `crates/edgezero-core/src/router.rs` — `IntrospectionData`, - `with_manifest_json`, dispatch injection; remove route-listing machinery + - tests. -- [ ] `crates/edgezero-core/src/context.rs` — `introspection()` accessor. -- [ ] `crates/edgezero-core/src/introspection.rs` — new module, three handlers. -- [ ] `crates/edgezero-core/src/lib.rs` — export `introspection`. -- [ ] `crates/edgezero-macros/src/app.rs` — serialize manifest, emit - `with_manifest_json`; add `serde_json` dep. -- [ ] `examples/app-demo/edgezero.toml` — three trigger rows. -- [ ] `crates/edgezero-cli/src/templates/root/edgezero.toml.hbs` — three trigger - rows using `{{name}}`. -- [ ] Workspace grep — purge remaining `/__edgezero/routes` / - `enable_route_listing` references (docs, examples, adapters). +- **WASM-first**: no Tokio, no runtime-specific deps. `serde_json` is added only + to `edgezero-macros` (build-time proc-macro crate). +- **No auth/gating of exposure in this iteration**: endpoints are reachable + wherever bound; config output is secret-safe, and `/manifest` emits + `environment.variables[].value` (not secrets) — documented so operators don't + store secrets in `[environment.variables]`. Access control is a follow-up. +- **No process-global state**: `IntrospectionData` is per-`RouterService`, so + tests and multiple apps in one process stay independent. +- **No `[introspection]` manifest section, no builder enable-API, no `app!` + handler-path inspection** — the opt-in lives on `#[action(...)]`. + +## Design evolution (for reviewers) + +1. First cut: inject `IntrospectionData` on **every** request; handlers read + `ctx.introspection()`. Rejected — taxes all traffic for rarely-hit endpoints. +2. Considered a process-global (`OnceLock`) source — rejected: one-manifest- + per-process breaks unit tests and adds shared mutable state. +3. Considered `app!` recognizing the `edgezero_core::introspection::` handler + namespace to flag routes — rejected as a fragile string-match hack. +4. **Final:** the opt-in is an atomic `#[action(manifest|routes)]` parameter; + the capability rides the handler to registration via + `DynHandler::needs_introspection()`; the router gates injection per route. + Typed extractors (`ManifestJson`/`RouteTable`) are the access mechanism. + No global, no `app!` hack, no unstable specialization, and `#[action]` (no + params) is 100% unchanged so only `manifest`/`routes` become structs. From a9ad7bc89b73b6ab0ebf69cf3c3ab1f5bdd2a179 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Thu, 2 Jul 2026 08:04:36 -0700 Subject: [PATCH 22/74] Spec/plan: fully-atomic introspection (IntrospectionNeeds, per-capability inject); fix plan single-filter cmd + probe response --- .../plans/2026-07-02-introspection-routes.md | 250 ++++++++-------- .../2026-07-01-introspection-routes-design.md | 281 +++++++++++------- 2 files changed, 301 insertions(+), 230 deletions(-) diff --git a/docs/superpowers/plans/2026-07-02-introspection-routes.md b/docs/superpowers/plans/2026-07-02-introspection-routes.md index beaa7ad3..683f1a06 100644 --- a/docs/superpowers/plans/2026-07-02-introspection-routes.md +++ b/docs/superpowers/plans/2026-07-02-introspection-routes.md @@ -1215,46 +1215,56 @@ gh pr ready 300 --- -## Addendum (2026-07-02): ACTIVE PATH — `#[action(manifest|routes)]` opt-in + gated injection +## Addendum (2026-07-02): ACTIVE PATH — fully-atomic `#[action(manifest|routes)]` opt-in -**This supersedes Tasks 2 & 3's unconditional injection.** Task 9 (extractors) is -already committed (`0feb194`): `ManifestJson`/`RouteTable` extractors exist and -`manifest`/`routes` use them. The tasks below make injection **per-route gated** -via an atomic `#[action(...)]` opt-in, with `app!`/`edgezero.toml` untouched. +**This supersedes Tasks 2 & 3.** Task 9 (extractors + handler refactor) is +committed (`0feb194`). The tasks below add **fully-atomic, per-capability gated +injection** matching the rewritten spec: the handler declares exactly which data +it needs; the router injects each payload independently, only for routes that +asked. No `IntrospectionData` bundle, no `ctx.introspection()`, no +`needs_introspection` bool, no `app!`/`edgezero.toml` change. -**Verified facts (from investigation):** -- `DynHandler` (`handler.rs`): `pub trait DynHandler: Send + Sync { fn call(&self, ctx: RequestContext) -> HandlerFuture; }`, blanket-impl'd for `F: Fn(RequestContext) -> Fut`. Object-safe with an added defaulted `needs_introspection`. `HandlerFuture = Pin> + 'static>>` (no `Send`). -- Handlers are invoked only via `DynHandler::call` (middleware `Next::run`), never as fns; only `manifest`/`routes` will become structs and neither is called directly → **zero blast radius**. -- `Responder::respond(self) -> Result`. +**Verified contract (from investigation):** +- `DynHandler: Send + Sync { fn call(&self, RequestContext) -> HandlerFuture; }`, blanket-impl'd for `F: Fn(RequestContext) -> Fut`. Object-safe with an added defaulted method. `HandlerFuture = Pin> + 'static>>` (no `Send`). +- `http::Extensions` requires inserted types be `Clone + Send + Sync + 'static`. +- Handlers run only via `DynHandler::call`; only `manifest`/`routes` become structs; neither is called directly → zero blast radius. -Base: after `0feb194`. +Order is chosen so **every commit compiles green** (the gating swap lands last, +after the handlers are opted in while unconditional injection is still active). -### Task 10a: `DynHandler::needs_introspection` (edgezero-core/src/handler.rs) +### Task 10a: `IntrospectionNeeds` + `DynHandler::introspection_needs` (edgezero-core/src/handler.rs) -- [ ] **Step 1 — add the defaulted method.** In `handler.rs`, add to the trait: +- [ ] **Step 1 — add the value type + defaulted method** (additive; compiles green): ```rust +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub struct IntrospectionNeeds { + pub manifest: bool, + pub routes: bool, +} + +impl IntrospectionNeeds { + #[must_use] + pub fn any(self) -> bool { self.manifest || self.routes } +} + pub trait DynHandler: Send + Sync { fn call(&self, ctx: RequestContext) -> HandlerFuture; - /// Whether a route bound to this handler needs `IntrospectionData` injected - /// at dispatch. Default false; `#[action(manifest|routes)]` handlers override. - fn needs_introspection(&self) -> bool { - false + /// Introspection payloads a route bound to this handler needs injected. + /// Default none; `#[action(manifest|routes)]` handlers override. + fn introspection_needs(&self) -> IntrospectionNeeds { + IntrospectionNeeds::default() } } ``` - The blanket `impl DynHandler for F` needs NO change (inherits the default). -- [ ] **Step 2 — verify.** `cargo build -p edgezero-core` (compiles; object safety intact). `cargo fmt -p edgezero-core`; `cargo clippy -p edgezero-core --all-targets -- -D warnings`. -- [ ] **Step 3 — commit:** `Add DynHandler::needs_introspection (default false)` + The `Fn` blanket impl needs no change. +- [ ] **Step 2 — verify:** `cargo build -p edgezero-core`; `cargo fmt -p edgezero-core`; `cargo clippy -p edgezero-core --all-targets -- -D warnings`. +- [ ] **Step 3 — commit:** `Add IntrospectionNeeds + DynHandler::introspection_needs` ### Task 10b: `#[action(manifest|routes)]` param parser + struct codegen (edgezero-macros/src/action.rs) -- [ ] **Step 1 (RED) — macro unit tests.** Update/add tests in `action.rs`'s `#[cfg(test)]`: - - Replace `rejects_attribute_arguments` with `rejects_unknown_param`: input `#[action(bogus)]` on an async fn → rendered output contains `unknown #[action] parameter`. - - Add `introspection_param_emits_struct`: input `#[action(manifest)]` on `async fn manifest(...)` → rendered output contains `struct manifest` AND `DynHandler` AND `needs_introspection`. - - Keep `wraps_async_function` (plain `#[action]` still contains `fn demo` + `__demo_inner`). - Run `cargo test -p edgezero-macros` → the two new/changed tests FAIL (parser/codegen not present). -- [ ] **Step 2 — parse the param list.** Replace the current `if !attr.is_empty() { return Error…"does not accept arguments" }` guard with: +- [ ] **Step 1 (RED) — macro unit tests.** Replace `rejects_attribute_arguments` with `rejects_unknown_param` (`#[action(bogus)]` → output contains `unknown #[action] parameter`); add `manifest_param_emits_struct` (`#[action(manifest)]` → output contains `struct` + `DynHandler` + `introspection_needs`); keep `wraps_async_function`. Run `cargo test -p edgezero-macros` → the two new/changed tests FAIL. +- [ ] **Step 2 — parse params** (replace the `!attr.is_empty()` rejection): ```rust use syn::parse::Parser as _; use syn::punctuated::Punctuated; @@ -1267,136 +1277,132 @@ let params: Punctuated = if attr.is_empty() { Err(err) => return err.to_compile_error(), } }; -let mut needs_introspection = false; +let mut manifest_cap = false; +let mut routes_cap = false; for param in ¶ms { - // Known atomic introspection capabilities. Extensible; all currently - // collapse to the single injected `IntrospectionData` bundle. - if param == "manifest" || param == "routes" { - needs_introspection = true; - } else { - return syn::Error::new( - param.span(), - format!("unknown #[action] parameter `{param}`; supported: manifest, routes"), - ) - .to_compile_error(); + if param == "manifest" { manifest_cap = true; } + else if param == "routes" { routes_cap = true; } + else { + return syn::Error::new(param.span(), + format!("unknown #[action] parameter `{param}`; supported: manifest, routes")) + .to_compile_error(); } } +let is_struct = manifest_cap || routes_cap; ``` -- [ ] **Step 3 — branch codegen.** Keep the existing `extract_stmts`/`arg_idents`/`inner_fn` computation. Then: +- [ ] **Step 3 — branch codegen.** When `!is_struct`: emit exactly today's fn form (unchanged). When `is_struct`: ```rust -let output = if needs_introspection { - quote! { - #inner_fn - - #(#attrs)* - #[allow(non_camel_case_types)] - #vis struct #ident; - - impl ::edgezero_core::handler::DynHandler for #ident { - #[inline] - fn call(&self, __ctx: ::edgezero_core::context::RequestContext) - -> ::edgezero_core::http::HandlerFuture { - ::std::boxed::Box::pin(async move { - #(#extract_stmts)* - let result = #inner_ident(#(#arg_idents),*).await; - ::edgezero_core::responder::Responder::respond(result) - }) - } - #[inline] - fn needs_introspection(&self) -> bool { true } +quote! { + #inner_fn + + #(#attrs)* + #[allow(non_camel_case_types)] + #vis struct #ident; + + impl ::edgezero_core::handler::DynHandler for #ident { + #[inline] + fn call(&self, __ctx: ::edgezero_core::context::RequestContext) + -> ::edgezero_core::http::HandlerFuture { + ::std::boxed::Box::pin(async move { + #(#extract_stmts)* + let result = #inner_ident(#(#arg_idents),*).await; + ::edgezero_core::responder::Responder::respond(result) + }) } - } -} else { - // UNCHANGED from today — the fn form. - quote! { - #inner_fn - - #(#attrs)* - #vis async fn #ident( - __ctx: ::edgezero_core::context::RequestContext, - ) -> ::std::result::Result<::edgezero_core::http::Response, ::edgezero_core::error::EdgeError> { - #(#extract_stmts)* - let result = #inner_ident(#(#arg_idents),*).await; - ::edgezero_core::responder::Responder::respond(result) + #[inline] + fn introspection_needs(&self) -> ::edgezero_core::handler::IntrospectionNeeds { + ::edgezero_core::handler::IntrospectionNeeds { manifest: #manifest_cap, routes: #routes_cap } } } -}; -output +} ``` -- [ ] **Step 4 (GREEN):** `cargo test -p edgezero-macros` (all pass). `cargo fmt --all`; `cargo clippy -p edgezero-macros --all-targets -- -D warnings`. + (`#manifest_cap`/`#routes_cap` interpolate as `true`/`false` bool literals.) +- [ ] **Step 4 (GREEN):** `cargo test -p edgezero-macros`; `cargo fmt --all`; `cargo clippy -p edgezero-macros --all-targets -- -D warnings`. - [ ] **Step 5 — commit:** `#[action]: accept atomic (manifest|routes) params; emit capability-carrying handler struct` -### Task 10c: router gating + accessor (edgezero-core/src/router.rs, context.rs) +### Task 10c: opt the handlers in (edgezero-core/src/introspection.rs) — stays green under the still-unconditional path + +- [ ] **Step 1 — opt in.** `manifest` → `#[action(manifest)]`; `routes` → `#[action(routes)]`; `config` stays `#[action]`. (The extractors still read `ctx.introspection()` and injection is still unconditional at this point, so behavior is unchanged — the handlers merely become structs reporting `introspection_needs`, which the router does not yet consult.) +- [ ] **Step 2 — verify:** `cargo test -p edgezero-core introspection` (all pass unchanged); `cargo fmt`; `cargo clippy -p edgezero-core --all-targets -- -D warnings`. +- [ ] **Step 3 — commit:** `Opt manifest/routes into introspection via #[action(manifest|routes)]` + +### Task 10d: the atomic gating swap (edgezero-core/src/{router,context,introspection}.rs) — all in one commit so it compiles -- [ ] **Step 1 (RED) — router tests.** In `router.rs` tests: - - Rewrite `dispatch_injects_introspection_data` and `middleware_sees_introspection_data` to register a LOCAL probe struct whose `DynHandler::needs_introspection()` returns `true` (a closure can't opt in), asserting `ctx.introspection().is_some()` from the handler and from a middleware respectively. - - Add `plain_route_gets_no_introspection`: a `.get("/x", |_ctx| async { Ok::<_,EdgeError>("ok") })` route asserts `ctx.introspection().is_none()`. - Example probe: +- [ ] **Step 1 (RED) — router tests.** Rewrite `dispatch_injects_introspection_data` and `middleware_sees_introspection_data` to register a LOCAL probe struct (a closure can not report `introspection_needs`), and add a negative test. Probe builds its response with `response_builder` (no `IntoResponse` import needed): ```rust -struct IntrospectiveProbe(std::sync::Arc>>); -impl crate::handler::DynHandler for IntrospectiveProbe { +struct ManifestProbe(std::sync::Arc>>); +impl crate::handler::DynHandler for ManifestProbe { fn call(&self, ctx: RequestContext) -> crate::http::HandlerFuture { let cell = std::sync::Arc::clone(&self.0); Box::pin(async move { - *cell.lock().unwrap() = Some(ctx.introspection().is_some()); - Ok("ok".into_response()?) // match the crate's response idiom + *cell.lock().unwrap() = + Some(ctx.extension::().is_some()); + crate::http::response_builder() + .status(crate::http::StatusCode::OK) + .body(crate::body::Body::empty()) + .map_err(EdgeError::internal) }) } - fn needs_introspection(&self) -> bool { true } + fn introspection_needs(&self) -> crate::handler::IntrospectionNeeds { + crate::handler::IntrospectionNeeds { manifest: true, routes: false } + } } ``` - Run `cargo test -p edgezero-core router introspection_data` → fails (flag/gating not present; `introspection()` type mismatch). -- [ ] **Step 2 — `RouteEntry` flag.** Add `needs_introspection: bool` and copy it in the manual `Clone`/`clone_from`. -- [ ] **Step 3 — read the flag in `add_route`:** + - flagged route: `.with_manifest_json("{}").get("/", ManifestProbe(cell))` → cell records `true`. + - middleware test: same flagged route + a probe middleware asserting `ctx.extension::().is_some()` (injection happens before middleware). + - negative test `plain_route_gets_no_manifest`: `.get("/x", |_ctx: RequestContext| async { … })` (a plain closure) → the handler records `ctx.extension::().is_none()`. + Run (single filter per command — Cargo takes ONE positional filter): + `cargo test -p edgezero-core router` (fails: `introspection_needs`/`extension`/gating absent). +- [ ] **Step 2 — `RouteEntry` flag.** Add `introspection_needs: IntrospectionNeeds` (`Copy`); copy it in the manual `Clone`/`clone_from`. +- [ ] **Step 3 — read it in `add_route`:** ```rust let boxed = handler.into_handler(); -let needs_introspection = boxed.needs_introspection(); -router.insert(path, RouteEntry { handler: boxed, needs_introspection }) +let introspection_needs = boxed.introspection_needs(); +router.insert(path, RouteEntry { handler: boxed, introspection_needs }) .unwrap_or_else(|err| panic!("duplicate route definition for {path}: {err}")); ``` -- [ ] **Step 4 — precompute the payload.** In `build()`: -```rust -let route_index: Arc<[RouteInfo]> = Arc::from(self.route_info); -let introspection = Arc::new(IntrospectionData { - manifest_json: self.manifest_json, - routes: Arc::clone(&route_index), -}); -RouterService::new(self.routes, self.middlewares, route_index, introspection) -``` - Change `RouterInner`'s `manifest_json: Option>` field to `introspection: Arc`, and `RouterService::new`'s last param likewise. -- [ ] **Step 5 — gate `dispatch`.** Remove the unconditional insert at the top; inside `RouteMatch::Found(entry, params)`: +- [ ] **Step 4 — remove the bundle; per-capability inject in `dispatch`.** Delete the `IntrospectionData` struct and the unconditional insert at the top of `dispatch`. `RouterInner` keeps `manifest_json: Option>` + `route_index`. Inside `RouteMatch::Found(entry, params)`: ```rust +let needs = entry.introspection_needs; let mut request = request; -if entry.needs_introspection { - request.extensions_mut().insert(::std::sync::Arc::clone(&self.introspection)); +if needs.manifest { + if let Some(json) = &self.manifest_json { + request.extensions_mut().insert(crate::introspection::ManifestJson(Arc::clone(json))); + } +} +if needs.routes { + request.extensions_mut().insert(crate::introspection::RouteTable(Arc::clone(&self.route_index))); } let ctx = RequestContext::new(request, params); ``` - (`dispatch` reads `method`/`path` from `request` before the match, as today; make the bound `request` mutable in the arm.) -- [ ] **Step 6 — accessor.** `context.rs`: +- [ ] **Step 5 — context accessor.** In `context.rs`, remove `introspection()`; add: +```rust +pub(crate) fn extension(&self) -> Option +where + T: Clone + Send + Sync + 'static, +{ + self.request.extensions().get::().cloned() +} +``` +- [ ] **Step 6 — extractors as payloads.** In `introspection.rs`: add `#[derive(Clone)]` to `ManifestJson` and `RouteTable`; rewrite their `from_request` to clone their own type out of the request: ```rust -pub fn introspection(&self) -> Option<&crate::router::IntrospectionData> { - self.request - .extensions() - .get::>() - .map(|arc| arc.as_ref()) +async fn from_request(ctx: &RequestContext) -> Result { + ctx.extension::() // (RouteTable in the other impl) + .ok_or_else(|| EdgeError::internal(anyhow::anyhow!("manifest introspection data not available"))) } ``` -- [ ] **Step 7 (GREEN):** `cargo test -p edgezero-core` (all pass). `cargo fmt`; `cargo clippy -p edgezero-core --all-targets -- -D warnings`. -- [ ] **Step 8 — commit:** `Gate IntrospectionData injection on RouteEntry.needs_introspection` - -### Task 10d: opt the handlers in + compile-level proof (edgezero-core/src/introspection.rs) - -- [ ] **Step 1 — opt in.** Change `manifest` from `#[action]` to `#[action(manifest)]`, and `routes` to `#[action(routes)]`. `config` stays `#[action]`. -- [ ] **Step 2 — compile/behavioral proof (this is the key test the reviewer asked for).** The existing `manifest_returns_injected_json` test already does `RouterService::builder().with_manifest_json("…").get("/m", manifest).build()` then `oneshot` → asserts 200 + body. Because `manifest` is now a UNIT STRUCT value, this test compiling and passing proves `.get("/m", manifest)` accepts the struct-as-handler and that `add_route` auto-flags it (else `oneshot` would 500 on the `ManifestJson` extractor). Keep it. Confirm `manifest_without_baked_json_is_500` still holds (route flagged → `IntrospectionData` injected, but `manifest_json` is `None` → extractor 500). Add `routes_returns_registered_routes` similarly if not already covered. -- [ ] **Step 3 — verify.** `cargo test -p edgezero-core introspection` (all pass, incl. body-shape assertions). -- [ ] **Step 4 — commit:** `Opt manifest/routes into gated injection via #[action(manifest|routes)]` +- [ ] **Step 7 (GREEN):** `cargo test -p edgezero-core router`; `cargo test -p edgezero-core introspection`; `cargo test -p edgezero-core`; `cargo fmt`; `cargo clippy -p edgezero-core --all-targets -- -D warnings`. Confirm `manifest_without_baked_json_is_500` still holds (route opted into `manifest`, but `manifest_json` is `None` → nothing injected → extractor 500). +- [ ] **Step 8 — commit:** `Gate introspection injection per capability via IntrospectionNeeds` ### Task 11: full verification + whole-branch review -- [ ] `cargo fmt --all -- --check`; `cargo clippy --workspace --all-targets --all-features -- -D warnings`; `cargo test --workspace --all-targets`. -- [ ] `cd examples/app-demo && cargo test --workspace --all-targets` (all other handlers unchanged → app-demo unaffected; `introspection_routes_are_wired` still passes: `manifest`/`routes` flagged, `config` via store). -- [ ] `cargo test -p edgezero-cli --test generated_project_builds -- --ignored` (template handlers are plain `#[action]` → still fns → generated project unaffected). -- [ ] `cargo run -q --bin check_no_nested_app_config --features nested-app-config-check -- examples/app-demo crates/edgezero-cli/src/templates`. -- [ ] `cargo check --workspace --all-targets --features "fastly cloudflare spin"`; `cargo check -p edgezero-adapter-spin --target wasm32-wasip2 --features spin`. +Run each on its own line (single positional filter per `cargo test`): +- [ ] `cargo fmt --all -- --check` +- [ ] `cargo clippy --workspace --all-targets --all-features -- -D warnings` +- [ ] `cargo test --workspace --all-targets` +- [ ] `cd examples/app-demo && cargo test --workspace --all-targets` (other handlers unchanged; `introspection_routes_are_wired` still passes: `manifest`/`routes` opted in, `config` via store) +- [ ] `cargo test -p edgezero-cli --test generated_project_builds -- --ignored` (template handlers are plain `#[action]` → still fns) +- [ ] `cargo run -q --bin check_no_nested_app_config --features nested-app-config-check -- examples/app-demo crates/edgezero-cli/src/templates` +- [ ] `cargo check --workspace --all-targets --features "fastly cloudflare spin"` +- [ ] `cargo check -p edgezero-adapter-spin --target wasm32-wasip2 --features spin` - [ ] Whole-branch review of the addendum commits, then push to #300. diff --git a/docs/superpowers/specs/2026-07-01-introspection-routes-design.md b/docs/superpowers/specs/2026-07-01-introspection-routes-design.md index 490ddd52..92e9d9f4 100644 --- a/docs/superpowers/specs/2026-07-01-introspection-routes-design.md +++ b/docs/superpowers/specs/2026-07-01-introspection-routes-design.md @@ -4,14 +4,12 @@ **Status:** Approved — implementation in progress **Scope:** `edgezero-core`, `edgezero-macros`, `examples/app-demo`, `edgezero-cli` templates -> **Note on history.** This spec was rewritten on 2026-07-02 to describe the -> **final** architecture only: **opt-in, per-route gated injection driven by +> **Note on history.** This spec describes the **final** architecture only: +> **opt-in, per-route, per-capability gated injection driven by > `#[action(manifest|routes)]`, with typed extractors for access.** Earlier drafts -> described unconditional per-request injection with handlers reading -> `ctx.introspection()` directly; that approach was superseded (it taxed all -> traffic for endpoints hit rarely). The "Design evolution" section at the end -> records the path taken. Where any older wording survives elsewhere, this -> document governs. +> injected data on every request (or via a single blanket gate / bundle); those +> were superseded. The "Design evolution" section at the end records the path. +> This document governs. ## Summary @@ -50,7 +48,8 @@ Today there is no runtime way to inspect what an app *is*: wired through a bespoke builder method rather than the normal routing path. We want a single, consistent, "bind it yourself" mechanism for all three — that -costs nothing for the ~100% of requests that are not introspection calls. +costs nothing for the ~100% of requests that are not introspection calls, and +that only provisions the *specific* data each handler asks for. ## Key Decisions @@ -66,19 +65,29 @@ costs nothing for the ~100% of requests that are not introspection calls. **not** inspect handler paths. 4. **Paths** — per-app namespace `/_/{manifest,config,routes}` (single underscore); just the default paths written into the templates. -5. **Access via typed extractors** — handlers that need injected data declare it - in their signature, matching the `Json`/`Path`/`AppConfig` idiom: +5. **Access via typed extractors that are also the injected payloads.** Handlers + that need data declare it in their signature, matching the + `Json`/`Path`/`AppConfig` idiom: - `ManifestJson(pub Arc)` — the baked manifest JSON (used by `manifest`). - `RouteTable(pub Arc<[RouteInfo]>)` — the live route index (used by `routes`). - Both implement `FromRequest`, read the injected `IntrospectionData` via - `ctx.introspection()`, and return `500` if it is absent. `config` takes - `RequestContext` and uses neither. -6. **Opt-in, per-route gated injection driven by `#[action(...)]`** — the router - injects `IntrospectionData` **only for routes whose handler opted in**, never - for general traffic. The opt-in is an atomic `#[action]` parameter and the - capability rides the handler to registration (details in Component 2/3). No - process-global state, no unstable specialization, no `app!`/`edgezero.toml` - change. + Each derives `Clone`, is what the router injects into the request, and its + `FromRequest::from_request` clones its own type back out (`500` if absent). + `config` takes `RequestContext` and uses neither. +6. **Opt-in, per-capability gated injection driven by an atomic `#[action(...)]` + parameter.** The router injects each capability's payload **only** for routes + whose handler opted into that specific capability — never for general traffic, + and never more than the handler asked for. The opt-in is atomic all the way + down: + - `#[action(manifest)]` / `#[action(routes)]` / `#[action(manifest, routes)]` + declare exactly which data the handler consumes. `#[action]` (no params) is + unchanged. + - Each param maps 1:1 to a field of `IntrospectionNeeds { manifest, routes }`, + reported by the handler via `DynHandler::introspection_needs()`. + - `dispatch` injects `ManifestJson` iff `needs.manifest`, and `RouteTable` iff + `needs.routes`. A `manifest`-only route never carries the route table, and + vice versa. + No process-global state, no unstable specialization, no bundle struct, and no + `app!`/`edgezero.toml` change. 7. **Remove route listing** — delete the `enable_route_listing` machinery and `/__edgezero/routes`. @@ -97,26 +106,27 @@ build_router() builder.with_manifest_json("{…}") RouterService::oneshot(req) builder.get(path, introspection::routes) └─ RouterInner::dispatch(req) │ │ find_route(req) → RouteEntry - ▼ │ if entry.needs_introspection: -RouterInner { │ req.extensions.insert( - introspection: Arc, ──────┼────► Arc::clone(introspection)) -} (built once) ▼ - handler runs; extractor reads - ctx.introspection(): + ▼ │ needs = entry.introspection_needs +RouterInner { │ if needs.manifest && manifest_json: + manifest_json: Option>, ──────┼────► insert ManifestJson(clone) + route_index: Arc<[RouteInfo]>, ──────┼────► if needs.routes: insert RouteTable(clone) +} ▼ + handler runs; extractor clones its + own type out of the request: manifest → ManifestJson(json) routes → RouteTable(index) config → default config store (no injection) ``` - **The opt-in is on the handler.** `#[action(manifest)]` / `#[action(routes)]` - expand the handler to a capability-carrying struct (below). `add_route` reads - that capability and flags the `RouteEntry`. `dispatch` injects the shared - `Arc` only for flagged routes. -- **manifest**: parsed at compile time, re-serialized to JSON by the macro, - baked into `build_router()`, held on the router's `IntrospectionData`, injected - for `manifest`-flagged routes, returned verbatim. No runtime TOML dependency. -- **routes**: projected at request time from the live route index in - `IntrospectionData`. + expand the handler to a capability-carrying struct whose + `introspection_needs()` sets the matching field(s). `add_route` reads that and + stores `IntrospectionNeeds` on the `RouteEntry`. +- **manifest**: parsed at compile time, re-serialized to JSON by the macro, held + as `Option>` on the router, injected (as `ManifestJson`) only for + routes that asked for `manifest`, returned verbatim. No runtime TOML dependency. +- **routes**: injected (as `RouteTable`) only for routes that asked for `routes`; + projected at request time to `[{method, path}]`. - **config**: read from the default config store; needs no injection. ### Component 1 — `Manifest: Serialize` (`edgezero-core/src/manifest.rs`) *(done)* @@ -129,25 +139,25 @@ never serialized: `environment.secrets` entries omit `value` via a `serialize_with` redactor; `environment.variables` keep it. Internal fields (`root`, `logging_resolved`) stay `#[serde(skip)]`. -### Component 2 — `#[action]` opt-in + capability-carrying handlers (`edgezero-macros/src/action.rs`) +### Component 2 — `#[action]` atomic opt-in + capability-carrying handlers (`edgezero-macros/src/action.rs`) `#[action]` gains an **optional atomic parameter list** naming the introspection -data the handler needs: +data the handler consumes: -- **`#[action]`** (no params) — unchanged. Expands to a handler **fn**, which via - the existing `Fn` blanket `impl DynHandler` reports `needs_introspection() == false`. +- **`#[action]`** (no params) — unchanged. Expands to a handler **fn**; via the + existing `Fn` blanket `impl DynHandler` its `introspection_needs()` returns the + default (all-false). - **`#[action(manifest)]`, `#[action(routes)]`, `#[action(manifest, routes)]`** — - expand the handler to a **unit struct** with its own `impl DynHandler` whose - `needs_introspection()` returns `true`. (A fn can't carry a per-item flag past - type-erasure into `Arc`; a struct can. Only opt-in handlers - become structs; every other handler stays a fn.) - -The macro validates each param against the known set `{ manifest, routes }` and -emits `compile_error!` on an unknown ident. The set is extensible (future atomic -capabilities are new idents). The atomic names are the declarative surface; since -`IntrospectionData` is one cheap `Arc` bundle, all recognized capabilities -currently collapse to the single `needs_introspection()` gate (room to split -payloads later without an attribute change). + expand the handler to a **unit struct** with its own `impl DynHandler`, whose + `introspection_needs()` returns an `IntrospectionNeeds` with exactly the named + fields set. (A fn can't carry per-item data past type-erasure into + `Arc`; a struct can. Only opt-in handlers become structs; every + other handler stays a fn, untouched.) + +The macro parses the params as a comma-separated ident list, validates each +against the known set `{ manifest, routes }`, and emits `compile_error!` on an +unknown ident. The set is extensible (future atomic capabilities are new idents ++ new `IntrospectionNeeds` fields). Generated struct (paths absolute, matching the existing macro): @@ -169,32 +179,76 @@ impl ::edgezero_core::handler::DynHandler for #ident { }) } #[inline] - fn needs_introspection(&self) -> bool { true } + fn introspection_needs(&self) -> ::edgezero_core::handler::IntrospectionNeeds { + ::edgezero_core::handler::IntrospectionNeeds { manifest: #manifest_lit, routes: #routes_lit } + } } ``` -### Component 3 — Router gating + accessor (`edgezero-core/src/{handler,router,context}.rs`) +where `#manifest_lit` / `#routes_lit` are the `bool` literals derived from the +parsed params. + +### Component 3 — Router gating (`edgezero-core/src/{handler,router,context}.rs`) + +**`handler.rs`** — the capability value type + the reporting method: + +```rust +/// Which introspection payloads a route's handler needs injected at dispatch. +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub struct IntrospectionNeeds { + pub manifest: bool, + pub routes: bool, +} + +impl IntrospectionNeeds { + #[must_use] + pub fn any(self) -> bool { + self.manifest || self.routes + } +} + +pub trait DynHandler: Send + Sync { + fn call(&self, ctx: RequestContext) -> HandlerFuture; -- **`DynHandler`** gains `fn needs_introspection(&self) -> bool { false }` - (object-safe; the `Fn` blanket impl inherits the default). -- **`RouteEntry`** gains `needs_introspection: bool`. `add_route` reads it from - the boxed handler at registration: + /// Introspection payloads a route bound to this handler needs. Default is + /// none; `#[action(manifest|routes)]` handlers override it. + fn introspection_needs(&self) -> IntrospectionNeeds { + IntrospectionNeeds::default() + } +} +``` + +The `Fn` blanket `impl DynHandler` needs no change (inherits the default). + +**`router.rs`:** + +- `RouteEntry` gains `introspection_needs: IntrospectionNeeds` (`Copy`; copied in + the manual `Clone`/`clone_from`). +- `add_route` reads it from the boxed handler at registration: ```rust let boxed = handler.into_handler(); - let needs_introspection = boxed.needs_introspection(); - router.insert(path, RouteEntry { handler: boxed, needs_introspection }); + let introspection_needs = boxed.introspection_needs(); + router.insert(path, RouteEntry { handler: boxed, introspection_needs }); ``` -- **`RouterInner`** holds a precomputed `introspection: Arc`, - built once in `build()` from `self.manifest_json` + the route index. +- `RouterInner` keeps `manifest_json: Option>` and + `route_index: Arc<[RouteInfo]>` (no bundle struct). `RouterBuilder::with_manifest_json(impl Into>)` (set by the `app!` macro) supplies the JSON. -- **`RouterInner::dispatch`** injects only for flagged routes, after matching: +- `dispatch` injects per capability, after matching: ```rust match self.find_route(&method, &path) { RouteMatch::Found(entry, params) => { + let needs = entry.introspection_needs; let mut request = request; - if entry.needs_introspection { - request.extensions_mut().insert(Arc::clone(&self.introspection)); + if needs.manifest { + if let Some(json) = &self.manifest_json { + request.extensions_mut() + .insert(crate::introspection::ManifestJson(Arc::clone(json))); + } + } + if needs.routes { + request.extensions_mut() + .insert(crate::introspection::RouteTable(Arc::clone(&self.route_index))); } let ctx = RequestContext::new(request, params); let next = Next::new(&self.middlewares, entry.handler.as_ref()); @@ -203,42 +257,42 @@ impl ::edgezero_core::handler::DynHandler for #ident { // MethodNotAllowed / NotFound unchanged } ``` -- **`RequestContext::introspection()`** reads the `Arc`: - ```rust - pub fn introspection(&self) -> Option<&crate::router::IntrospectionData> { - self.request.extensions().get::>() - .map(|arc| arc.as_ref()) - } - ``` -`IntrospectionData` is the injected payload: +**`context.rs`** — a single `pub(crate)` accessor the extractors share (there is +no public `introspection()` accessor and no `IntrospectionData` type): + ```rust -#[derive(Clone)] -pub struct IntrospectionData { - pub manifest_json: Option>, - pub routes: Arc<[RouteInfo]>, +pub(crate) fn extension(&self) -> Option +where + T: Clone + Send + Sync + 'static, +{ + self.request.extensions().get::().cloned() } ``` ### Component 4 — `edgezero_core::introspection` module (`edgezero-core/src/introspection.rs`) -The extractors and three handlers: +The extractors (which are also the injected payloads) and three handlers: ```rust +#[derive(Clone)] pub struct ManifestJson(pub Arc); + #[async_trait(?Send)] impl FromRequest for ManifestJson { async fn from_request(ctx: &RequestContext) -> Result { - ctx.introspection().and_then(|d| d.manifest_json.clone()).map(ManifestJson) + ctx.extension::() .ok_or_else(|| EdgeError::internal(anyhow::anyhow!("manifest introspection data not available"))) } } +#[derive(Clone)] pub struct RouteTable(pub Arc<[RouteInfo]>); + #[async_trait(?Send)] impl FromRequest for RouteTable { async fn from_request(ctx: &RequestContext) -> Result { - ctx.introspection().map(|d| RouteTable(Arc::clone(&d.routes))) + ctx.extension::() .ok_or_else(|| EdgeError::internal(anyhow::anyhow!("route-table introspection data not available"))) } } @@ -271,7 +325,9 @@ pub async fn config(ctx: RequestContext) -> Result { ``` `RouteView { method: String, path: String }` (derives `Serialize`) is the JSON -shape for `routes`. `Response` is imported from `crate::http`. +shape for `routes`. `Response` is imported from `crate::http`. Because `dispatch` +constructs `ManifestJson`/`RouteTable`, `router.rs` imports them from +`crate::introspection` (a same-crate module reference — no crate-level cycle). ### Component 5 — `app!` macro (`edgezero-macros/src/app.rs`) *(done, unchanged by the gating work)* @@ -279,10 +335,10 @@ shape for `routes`. `Response` is imported from `crate::http`. failure) and emit `builder = builder.with_manifest_json()` as the first builder mutation in `build_router()`. - Emit `const _: &[u8] = include_bytes!();` so Cargo treats - `edgezero.toml` as a build input (rebuild on manifest change). + `edgezero.toml` as a build input. - Route registration is ordinary `builder.get(path, handler)` / `route(...)`; the macro does **not** inspect handler paths. Gating comes entirely from the - handler's `needs_introspection()`. + handler's `introspection_needs()`. ### Component 6 — Removals *(done)* @@ -300,22 +356,24 @@ No template handler code is generated — the handlers live in core. ## Interfaces (summary) -| Unit | Public surface | -| -------------------------- | --------------------------------------------------------------- | -| `IntrospectionData` | `{ manifest_json: Option>, routes: Arc<[RouteInfo]> }` | -| `DynHandler` | `fn needs_introspection(&self) -> bool { false }` | -| `RouterBuilder` | `with_manifest_json(impl Into>)` | -| `RequestContext` | `introspection() -> Option<&IntrospectionData>` | -| `introspection::ManifestJson` | `FromRequest`; `pub Arc` | -| `introspection::RouteTable` | `FromRequest`; `pub Arc<[RouteInfo]>` | -| `introspection::{manifest,routes}` | `#[action(manifest)]` / `#[action(routes)]` GET → JSON | -| `introspection::config` | `#[action]` GET → JSON (default config store) | +| Unit | Public surface | +| ----------------------------- | ----------------------------------------------------------------- | +| `IntrospectionNeeds` | `{ manifest: bool, routes: bool }`, `Copy`, `Default`, `any()` | +| `DynHandler` | `fn introspection_needs(&self) -> IntrospectionNeeds { default }` | +| `RouterBuilder` | `with_manifest_json(impl Into>)` | +| `RequestContext` | `pub(crate) extension::() -> Option` (no public accessor) | +| `introspection::ManifestJson` | `pub Arc`; `Clone`; `FromRequest` | +| `introspection::RouteTable` | `pub Arc<[RouteInfo]>`; `Clone`; `FromRequest` | +| `introspection::{manifest,routes}` | `#[action(manifest)]` / `#[action(routes)]` GET → JSON | +| `introspection::config` | `#[action]` GET → JSON (default config store) | ## Error Handling -- **manifest** / **routes**: extractor returns `500 internal` if - `IntrospectionData` is absent (route not opted in) or, for `manifest`, if - `manifest_json` is `None` (no `with_manifest_json`). +- **manifest** / **routes**: the extractor returns `500 internal` if its payload + is absent from the request — i.e. the route did not opt into that capability + (`#[action(manifest)]` / `#[action(routes)]` missing), or, for `manifest`, the + app never called `with_manifest_json` (`manifest_json` is `None`, so `dispatch` + injects nothing). - **config**: no default config store → `404`; no blob → `404`; `ConfigStoreError` mapped via `EdgeError::from` (503 unavailable / 400 invalid-key / 500 internal); malformed or unverifiable envelope → `500`. @@ -325,13 +383,15 @@ No template handler code is generated — the handlers live in core. Colocated `#[cfg(test)]`, `futures::executor::block_on` (no Tokio), no network. - **macros**: `#[action]` (no params) still emits a fn; `#[action(manifest)]` - emits a struct impl'ing `DynHandler` with `needs_introspection() == true`; - `#[action(bogus)]` is a compile error. Plus a **compile/behavioral** test in - core that a struct handler registers and runs: `.get("/m", manifest)` → - `oneshot` → 200 (proves the unit-struct-as-handler-value path works end to end). -- **handler.rs / router.rs**: `needs_introspection()` default is false for fn - handlers; a flagged route injects `IntrospectionData` (handler + middleware see - it); a non-flagged route does **not** (`ctx.introspection().is_none()`). + emits a struct impl'ing `DynHandler` with `introspection_needs()` setting + `manifest: true`; `#[action(bogus)]` is a compile error. Plus a + **compile/behavioral** test in core proving `.get("/m", manifest)` accepts the + unit-struct handler value and runs end to end (`oneshot` → 200). +- **handler.rs / router.rs**: default `introspection_needs()` is all-false for fn + handlers; a `manifest`-flagged route injects `ManifestJson` (handler + + middleware can read it) and does **not** inject `RouteTable`; a `routes`-flagged + route is the mirror; a plain route injects neither + (`ctx.extension::().is_none()`). - **introspection module**: `manifest` returns injected JSON; `routes` returns `[{method,path}]`; `config` returns envelope `data` and the full status matrix (200/404/400/503/500×3); `manifest` with no baked JSON → 500. @@ -356,22 +416,27 @@ Colocated `#[cfg(test)]`, `futures::executor::block_on` (no Tokio), no network. wherever bound; config output is secret-safe, and `/manifest` emits `environment.variables[].value` (not secrets) — documented so operators don't store secrets in `[environment.variables]`. Access control is a follow-up. -- **No process-global state**: `IntrospectionData` is per-`RouterService`, so - tests and multiple apps in one process stay independent. +- **No process-global state**: `manifest_json` / `route_index` are + per-`RouterService`, so tests and multiple apps in one process stay independent. - **No `[introspection]` manifest section, no builder enable-API, no `app!` handler-path inspection** — the opt-in lives on `#[action(...)]`. ## Design evolution (for reviewers) -1. First cut: inject `IntrospectionData` on **every** request; handlers read +1. First cut: inject a bundle on **every** request; handlers read `ctx.introspection()`. Rejected — taxes all traffic for rarely-hit endpoints. 2. Considered a process-global (`OnceLock`) source — rejected: one-manifest- per-process breaks unit tests and adds shared mutable state. 3. Considered `app!` recognizing the `edgezero_core::introspection::` handler namespace to flag routes — rejected as a fragile string-match hack. -4. **Final:** the opt-in is an atomic `#[action(manifest|routes)]` parameter; - the capability rides the handler to registration via - `DynHandler::needs_introspection()`; the router gates injection per route. - Typed extractors (`ManifestJson`/`RouteTable`) are the access mechanism. - No global, no `app!` hack, no unstable specialization, and `#[action]` (no - params) is 100% unchanged so only `manifest`/`routes` become structs. +4. Considered a single `DynHandler::needs_introspection() -> bool` + one + `IntrospectionData` bundle — rejected: inconsistent with the atomic + `#[action(manifest|routes)]` params, and it over-provisions (a `manifest`-only + route would carry the route table). +5. **Final:** fully atomic. `#[action(manifest|routes)]` → `IntrospectionNeeds` + (per-capability bools) reported by `DynHandler::introspection_needs()`; the + router injects each capability's payload independently, only for routes that + asked for it. The extractors `ManifestJson`/`RouteTable` are themselves the + injected payloads. No global, no `app!` hack, no bundle, no unstable + specialization; `#[action]` (no params) is 100% unchanged so only + `manifest`/`routes` become structs. From e7f9d5714b2c26acd977a1c5380613bfbaa2ab76 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Thu, 2 Jul 2026 10:07:04 -0700 Subject: [PATCH 23/74] Plan: prove per-capability atomicity (3-case probe); atomic header line; cargo test in Task 10a --- .../plans/2026-07-02-introspection-routes.md | 60 ++++++++++++++----- 1 file changed, 46 insertions(+), 14 deletions(-) diff --git a/docs/superpowers/plans/2026-07-02-introspection-routes.md b/docs/superpowers/plans/2026-07-02-introspection-routes.md index 683f1a06..50913430 100644 --- a/docs/superpowers/plans/2026-07-02-introspection-routes.md +++ b/docs/superpowers/plans/2026-07-02-introspection-routes.md @@ -20,7 +20,7 @@ **Goal:** Add three reusable core introspection handlers — `edgezero_core::introspection::{manifest, config, routes}` — that any app binds via `[[triggers.http]]`, default-mounted at `/_/{manifest,config,routes}`. -**Architecture (final — see Addendum):** `Manifest` gains `Serialize`; the `app!` macro bakes it to JSON via `RouterService::builder().with_manifest_json(...)`. Handlers opt into introspection data with an atomic `#[action(manifest)]` / `#[action(routes)]` parameter, which expands them to capability-carrying handler structs; `add_route` reads `DynHandler::needs_introspection()` and flags the `RouteEntry`; `RouterInner::dispatch` injects a shared `Arc` **only for flagged routes**. `ManifestJson`/`RouteTable` extractors read it; `config` uses the default config store (no injection). `app!` and `edgezero.toml` never learn about introspection. The legacy `enable_route_listing` machinery and `/__edgezero/routes` are removed. +**Architecture (final — see Addendum):** `Manifest` gains `Serialize`; the `app!` macro bakes it to JSON via `RouterService::builder().with_manifest_json(...)`. Handlers opt into introspection data with an atomic `#[action(manifest)]` / `#[action(routes)]` parameter, which expands them to capability-carrying handler structs whose `DynHandler::introspection_needs()` returns an `IntrospectionNeeds { manifest, routes }`; `add_route` reads it onto the `RouteEntry`; `RouterInner::dispatch` injects each payload **independently and only for routes that asked** — `ManifestJson` iff `needs.manifest`, `RouteTable` iff `needs.routes` (no shared bundle). The `ManifestJson`/`RouteTable` extractors are themselves the injected payloads and clone their own type back out via a `pub(crate) extension::()` accessor; `config` uses the default config store (no injection). `app!` and `edgezero.toml` never learn about introspection. The legacy `enable_route_listing` machinery and `/__edgezero/routes` are removed. **Tech Stack:** Rust 1.95 (edition 2021), `serde`/`serde_json`, `matchit` routing, `#[action]`/`app!` proc-macros, `futures::executor::block_on` for tests. WASM-first: no Tokio, no runtime-specific deps in core. @@ -1258,8 +1258,24 @@ pub trait DynHandler: Send + Sync { } ``` The `Fn` blanket impl needs no change. -- [ ] **Step 2 — verify:** `cargo build -p edgezero-core`; `cargo fmt -p edgezero-core`; `cargo clippy -p edgezero-core --all-targets -- -D warnings`. -- [ ] **Step 3 — commit:** `Add IntrospectionNeeds + DynHandler::introspection_needs` +- [ ] **Step 2 — test that a plain fn/closure handler reports the default.** Add to `handler.rs`'s `#[cfg(test)]` (the blanket `impl DynHandler for F: Fn(...)` must yield all-false): +```rust +#[test] +fn fn_handler_reports_default_introspection_needs() { + // A plain closure handler uses the blanket DynHandler impl. + let handler = |_ctx: crate::context::RequestContext| async { + Ok::<&'static str, crate::error::EdgeError>("ok") + }; + assert_eq!( + crate::handler::DynHandler::introspection_needs(&handler), + IntrospectionNeeds::default() + ); + assert!(!IntrospectionNeeds::default().any()); +} +``` + (Match the crate's test imports; `&str: IntoResponse` satisfies the blanket bound.) +- [ ] **Step 3 — verify (repo rule: `cargo test` after code changes):** `cargo test -p edgezero-core` (all pass, incl. the new test); `cargo fmt -p edgezero-core`; `cargo clippy -p edgezero-core --all-targets -- -D warnings`. +- [ ] **Step 4 — commit:** `Add IntrospectionNeeds + DynHandler::introspection_needs` ### Task 10b: `#[action(manifest|routes)]` param parser + struct codegen (edgezero-macros/src/action.rs) @@ -1328,15 +1344,28 @@ quote! { ### Task 10d: the atomic gating swap (edgezero-core/src/{router,context,introspection}.rs) — all in one commit so it compiles -- [ ] **Step 1 (RED) — router tests.** Rewrite `dispatch_injects_introspection_data` and `middleware_sees_introspection_data` to register a LOCAL probe struct (a closure can not report `introspection_needs`), and add a negative test. Probe builds its response with `response_builder` (no `IntoResponse` import needed): +- [ ] **Step 1 (RED) — router tests that PROVE per-capability atomicity.** The + key guarantee (spec §Key Decision 6): a route injects **exactly** the payloads + its handler asked for — `manifest`-only → `ManifestJson` present AND `RouteTable` + absent; `routes`-only → the mirror; plain → neither. A single-payload probe + would let an over-injecting implementation pass, so the probe records BOTH + payloads' presence and each test asserts the exact `(manifest_present, + routes_present)` pair. The probe carries its own `IntrospectionNeeds` so one + type covers all cases, and builds its response with `response_builder` (no + `IntoResponse` import): ```rust -struct ManifestProbe(std::sync::Arc>>); -impl crate::handler::DynHandler for ManifestProbe { +struct CapProbe { + seen: std::sync::Arc>>, + needs: crate::handler::IntrospectionNeeds, +} +impl crate::handler::DynHandler for CapProbe { fn call(&self, ctx: RequestContext) -> crate::http::HandlerFuture { - let cell = std::sync::Arc::clone(&self.0); + let seen = std::sync::Arc::clone(&self.seen); Box::pin(async move { - *cell.lock().unwrap() = - Some(ctx.extension::().is_some()); + *seen.lock().unwrap() = Some(( + ctx.extension::().is_some(), + ctx.extension::().is_some(), + )); crate::http::response_builder() .status(crate::http::StatusCode::OK) .body(crate::body::Body::empty()) @@ -1344,14 +1373,17 @@ impl crate::handler::DynHandler for ManifestProbe { }) } fn introspection_needs(&self) -> crate::handler::IntrospectionNeeds { - crate::handler::IntrospectionNeeds { manifest: true, routes: false } + self.needs } } ``` - - flagged route: `.with_manifest_json("{}").get("/", ManifestProbe(cell))` → cell records `true`. - - middleware test: same flagged route + a probe middleware asserting `ctx.extension::().is_some()` (injection happens before middleware). - - negative test `plain_route_gets_no_manifest`: `.get("/x", |_ctx: RequestContext| async { … })` (a plain closure) → the handler records `ctx.extension::().is_none()`. - Run (single filter per command — Cargo takes ONE positional filter): + Three explicit atomicity tests (each builds a router, `oneshot`s a GET, then + reads the cell). Use `IntrospectionNeeds { manifest, routes }` literally: + - `manifest_route_injects_only_manifest`: probe `needs { manifest: true, routes: false }`, router `.with_manifest_json("{}").get("/", probe)` → cell == `Some((true, false))`. + - `routes_route_injects_only_routes`: probe `needs { manifest: false, routes: true }`, router `.get("/", probe)` (no `with_manifest_json` needed — `RouteTable` comes from the always-present route index) → cell == `Some((false, true))`. + - `plain_route_injects_neither`: probe `needs IntrospectionNeeds::default()` (or a plain closure), `.with_manifest_json("{}").get("/", probe)` → cell == `Some((false, false))` (manifest is available but NOT injected because the route didn't ask). + Plus keep a **middleware** test: a `manifest`-flagged route + a probe middleware asserting `ctx.extension::().is_some()` before the handler runs (injection precedes the middleware chain). + Run (single positional filter per command — Cargo takes ONE): `cargo test -p edgezero-core router` (fails: `introspection_needs`/`extension`/gating absent). - [ ] **Step 2 — `RouteEntry` flag.** Add `introspection_needs: IntrospectionNeeds` (`Copy`); copy it in the manual `Clone`/`clone_from`. - [ ] **Step 3 — read it in `add_route`:** From be2e9a431951fad83af8f8b1ea243c19fe7f978f Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Thu, 2 Jul 2026 10:57:33 -0700 Subject: [PATCH 24/74] Add IntrospectionNeeds + DynHandler::introspection_needs --- crates/edgezero-core/src/handler.rs | 52 +++++++++++++++++++++++++++++ 1 file changed, 52 insertions(+) diff --git a/crates/edgezero-core/src/handler.rs b/crates/edgezero-core/src/handler.rs index 17fd4831..ea545424 100644 --- a/crates/edgezero-core/src/handler.rs +++ b/crates/edgezero-core/src/handler.rs @@ -6,8 +6,36 @@ use crate::error::EdgeError; use crate::http::HandlerFuture; use crate::response::IntoResponse; +/// Which introspection payloads a route's handler needs injected at dispatch. +/// +/// Reported per handler via [`DynHandler::introspection_needs`]. Handlers written +/// with `#[action(manifest)]` / `#[action(routes)]` set the matching field(s); +/// every other handler reports the default (all-false). +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub struct IntrospectionNeeds { + pub manifest: bool, + pub routes: bool, +} + +impl IntrospectionNeeds { + /// Whether this handler needs any introspection payload injected. + #[must_use] + #[inline] + pub fn any(self) -> bool { + self.manifest || self.routes + } +} + pub trait DynHandler: Send + Sync { fn call(&self, ctx: RequestContext) -> HandlerFuture; + + /// Introspection payloads a route bound to this handler needs injected into + /// the request at dispatch. Defaults to none; `#[action(manifest)]` / + /// `#[action(routes)]` handlers override it. + #[inline] + fn introspection_needs(&self) -> IntrospectionNeeds { + IntrospectionNeeds::default() + } } impl DynHandler for F @@ -21,6 +49,13 @@ where let fut = (self)(ctx); Box::pin(async move { fut.await?.into_response() }) } + + // `missing_trait_methods` (deny) forbids relying on the trait default here; + // spell out the same all-false result that fn/closure handlers report. + #[inline] + fn introspection_needs(&self) -> IntrospectionNeeds { + IntrospectionNeeds::default() + } } pub type BoxHandler = Arc; @@ -38,3 +73,20 @@ where Arc::new(self) } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn fn_handler_reports_default_introspection_needs() { + // A plain closure handler uses the blanket `DynHandler` impl, which must + // report no introspection needs. (`&str: IntoResponse` satisfies the bound.) + let handler = |_ctx: RequestContext| async { Ok::<&'static str, EdgeError>("ok") }; + assert_eq!( + DynHandler::introspection_needs(&handler), + IntrospectionNeeds::default() + ); + assert!(!IntrospectionNeeds::default().any()); + } +} From 8273395d5ab9727b4826819077f94f6dc9caccf3 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Thu, 2 Jul 2026 11:03:33 -0700 Subject: [PATCH 25/74] #[action]: accept atomic (manifest|routes) params; emit capability-carrying handler struct --- crates/edgezero-macros/src/action.rs | 163 ++++++++++++++++++++++----- 1 file changed, 135 insertions(+), 28 deletions(-) diff --git a/crates/edgezero-macros/src/action.rs b/crates/edgezero-macros/src/action.rs index 92a03c63..bd1e8467 100644 --- a/crates/edgezero-macros/src/action.rs +++ b/crates/edgezero-macros/src/action.rs @@ -1,6 +1,12 @@ use proc_macro::TokenStream; use quote::{format_ident, quote}; -use syn::{spanned::Spanned as _, Error, FnArg, ItemFn, Pat, PathArguments, Type}; +use syn::parse::Parser as _; +use syn::punctuated::Punctuated; +use syn::{spanned::Spanned as _, Error, FnArg, ItemFn, Pat, PathArguments, Token, Type}; + +/// `(extract_stmts, arg_idents)` produced from a handler's argument list — the +/// `FromRequest` extraction statements and the idents passed to the inner fn. +type ArgExtractors = (Vec, Vec); pub fn expand_action(attr: TokenStream, item: TokenStream) -> TokenStream { expand_action_impl(&attr.into(), item.into()).into() @@ -10,10 +16,15 @@ fn expand_action_impl( attr: &proc_macro2::TokenStream, item: proc_macro2::TokenStream, ) -> proc_macro2::TokenStream { - if !attr.is_empty() { - return syn::Error::new(attr.span(), "#[action] does not accept arguments") - .to_compile_error(); - } + // `#[action]` takes an optional atomic capability list, e.g. + // `#[action(manifest)]` / `#[action(routes)]` / `#[action(manifest, routes)]`. + // Each names an introspection payload the handler needs injected; a handler + // that opts in is emitted as a capability-carrying struct (see below). + let (manifest_cap, routes_cap) = match parse_action_params(attr) { + Ok(caps) => caps, + Err(err) => return err.to_compile_error(), + }; + let is_capability_handler = manifest_cap || routes_cap; let func: ItemFn = match syn::parse2(item) { Ok(func) => func, @@ -52,6 +63,93 @@ fn expand_action_impl( return err.to_compile_error(); } + let (extract_stmts, arg_idents) = match build_arg_extractors(&func) { + Ok(parts) => parts, + Err(err) => return err.to_compile_error(), + }; + + let output = if is_capability_handler { + // A fn can't carry per-handler data past type-erasure into + // `Arc`, so an opt-in handler becomes a unit struct with + // its own `DynHandler` impl whose `introspection_needs()` reports which + // payloads the router must inject for its route. + quote! { + #inner_fn + + #(#attrs)* + #[allow(non_camel_case_types)] + #vis struct #ident; + + impl ::edgezero_core::handler::DynHandler for #ident { + #[inline] + fn call( + &self, + __ctx: ::edgezero_core::context::RequestContext, + ) -> ::edgezero_core::http::HandlerFuture { + ::std::boxed::Box::pin(async move { + #(#extract_stmts)* + let result = #inner_ident(#(#arg_idents),*).await; + ::edgezero_core::responder::Responder::respond(result) + }) + } + + #[inline] + fn introspection_needs(&self) -> ::edgezero_core::handler::IntrospectionNeeds { + ::edgezero_core::handler::IntrospectionNeeds { + manifest: #manifest_cap, + routes: #routes_cap, + } + } + } + } + } else { + quote! { + #inner_fn + + #(#attrs)* + #vis async fn #ident( + __ctx: ::edgezero_core::context::RequestContext, + ) -> ::std::result::Result<::edgezero_core::http::Response, ::edgezero_core::error::EdgeError> { + #(#extract_stmts)* + let result = #inner_ident(#(#arg_idents),*).await; + ::edgezero_core::responder::Responder::respond(result) + } + } + }; + + output +} + +/// Parse the optional `#[action(...)]` capability list into +/// `(needs_manifest, needs_routes)`. Empty attr → `(false, false)`. Unknown +/// idents are a compile error. Extend the known set as new capabilities land. +fn parse_action_params(attr: &proc_macro2::TokenStream) -> Result<(bool, bool), Error> { + if attr.is_empty() { + return Ok((false, false)); + } + let params = Punctuated::::parse_terminated.parse2(attr.clone())?; + let mut manifest_cap = false; + let mut routes_cap = false; + for param in ¶ms { + if param == "manifest" { + manifest_cap = true; + } else if param == "routes" { + routes_cap = true; + } else { + return Err(Error::new( + param.span(), + format!("unknown #[action] parameter `{param}`; supported: manifest, routes"), + )); + } + } + Ok((manifest_cap, routes_cap)) +} + +/// Build the per-argument extractor statements and the argument idents passed to +/// the inner fn. `RequestContext` arguments map to `__ctx`; every other argument +/// is extracted via `FromRequest`. Returns the `(extract_stmts, arg_idents)` +/// used by both the fn and struct codegen forms. +fn build_arg_extractors(func: &ItemFn) -> Result { let mut extract_stmts = Vec::new(); let mut arg_idents = Vec::new(); let mut has_request_context = false; @@ -60,22 +158,20 @@ fn expand_action_impl( let pat_type = match arg { FnArg::Typed(pat_type) => pat_type, FnArg::Receiver(receiver) => { - return syn::Error::new( + return Err(Error::new( receiver.span(), "#[action] functions cannot have a `self` receiver", - ) - .to_compile_error(); + )); } }; let ty = &pat_type.ty; if is_request_context_type(ty) { if has_request_context { - return syn::Error::new( + return Err(Error::new( ty.span(), "#[action] functions support at most one RequestContext argument", - ) - .to_compile_error(); + )); } has_request_context = true; arg_idents.push(quote! { __ctx }); @@ -89,20 +185,7 @@ fn expand_action_impl( arg_idents.push(quote! { #var_ident }); } - let output = quote! { - #inner_fn - - #(#attrs)* - #vis async fn #ident( - __ctx: ::edgezero_core::context::RequestContext, - ) -> ::std::result::Result<::edgezero_core::http::Response, ::edgezero_core::error::EdgeError> { - #(#extract_stmts)* - let result = #inner_ident(#(#arg_idents),*).await; - ::edgezero_core::responder::Responder::respond(result) - } - }; - - output + Ok((extract_stmts, arg_idents)) } fn is_request_context_type(ty: &Type) -> bool { @@ -209,15 +292,39 @@ mod tests { } #[test] - fn rejects_attribute_arguments() { + fn rejects_unknown_param() { let input = quote! { async fn demo(ctx: ::edgezero_core::context::RequestContext) -> ::edgezero_core::http::Response { unimplemented!() } }; - let output = expand_action_impl("e!(path = "/demo"), input); + let output = expand_action_impl("e!(bogus), input); let rendered = render(&output); - assert!(rendered.contains("does not accept arguments")); + assert!(rendered.contains("unknown #[action] parameter")); + } + + #[test] + fn manifest_param_emits_capability_struct() { + let input = quote! { + async fn manifest( + ManifestJson(json): ManifestJson, + ) -> ::std::result::Result< + ::edgezero_core::http::Response, + ::edgezero_core::error::EdgeError, + > { + let _ = json; + unimplemented!() + } + }; + let output = expand_action_impl("e!(manifest), input); + let collapsed = collapse_whitespace(&render(&output)); + // Opt-in handlers become a capability-carrying struct, not a fn. + assert!(collapsed.contains("structmanifest")); + assert!(collapsed.contains("DynHandlerformanifest")); + assert!(collapsed.contains("fnintrospection_needs")); + // The `manifest` capability field is set true; `routes` false. + assert!(collapsed.contains("manifest:true")); + assert!(collapsed.contains("routes:false")); } #[test] From c30e1e5a09ab90a7269a92fe16932bc87c095978 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Thu, 2 Jul 2026 11:04:49 -0700 Subject: [PATCH 26/74] Opt manifest/routes into introspection via #[action(manifest|routes)] --- crates/edgezero-core/src/introspection.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/edgezero-core/src/introspection.rs b/crates/edgezero-core/src/introspection.rs index 650b17b0..f05133a4 100644 --- a/crates/edgezero-core/src/introspection.rs +++ b/crates/edgezero-core/src/introspection.rs @@ -66,13 +66,13 @@ fn json_response(status: StatusCode, body: Body) -> Result } /// GET — the app manifest as JSON (baked at compile time by `app!`). -#[action] +#[action(manifest)] pub async fn manifest(ManifestJson(json): ManifestJson) -> Result { json_response(StatusCode::OK, Body::text(json.to_string())) } /// GET — `[{ "method", "path" }]` for every registered route. -#[action] +#[action(routes)] pub async fn routes(RouteTable(table): RouteTable) -> Result { let views: Vec = table .iter() From 15949c89c20c507f7eb05ca03fe8a0f6df5c326e Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Thu, 2 Jul 2026 11:35:05 -0700 Subject: [PATCH 27/74] Gate introspection injection per capability via IntrospectionNeeds --- crates/edgezero-core/src/context.rs | 20 +- crates/edgezero-core/src/introspection.rs | 36 ++-- crates/edgezero-core/src/router.rs | 244 ++++++++++++++-------- 3 files changed, 187 insertions(+), 113 deletions(-) diff --git a/crates/edgezero-core/src/context.rs b/crates/edgezero-core/src/context.rs index d3656e9f..24eebf01 100644 --- a/crates/edgezero-core/src/context.rs +++ b/crates/edgezero-core/src/context.rs @@ -3,7 +3,6 @@ use crate::error::EdgeError; use crate::http::Request; use crate::params::PathParams; use crate::proxy::ProxyHandle; -use crate::router::IntrospectionData; use crate::store_registry::{ BoundConfigStore, BoundKvStore, BoundSecretStore, ConfigRegistry, ConfigStoreBinding, KvRegistry, SecretRegistry, StoreRegistry, @@ -70,6 +69,18 @@ impl RequestContext { .and_then(|registry| registry.default_ref()) } + /// Clone a request extension of type `T`, if present. Used by the + /// introspection extractors (`ManifestJson` / `RouteTable`) to read the + /// payload the router injected for their route. + #[must_use] + #[inline] + pub(crate) fn extension(&self) -> Option + where + T: Clone + Send + Sync + 'static, + { + self.request.extensions().get::().cloned() + } + /// # Errors /// Returns [`EdgeError::bad_request`] if the body cannot be deserialized as form-urlencoded data into `T`, or the body is streaming. #[inline] @@ -91,13 +102,6 @@ impl RequestContext { self.request } - /// The per-request [`IntrospectionData`] injected by the router, if any. - #[must_use] - #[inline] - pub fn introspection(&self) -> Option<&IntrospectionData> { - self.request.extensions().get::() - } - /// # Errors /// Returns [`EdgeError::bad_request`] if the body is not valid JSON for `T`. #[inline] diff --git a/crates/edgezero-core/src/introspection.rs b/crates/edgezero-core/src/introspection.rs index f05133a4..706a8676 100644 --- a/crates/edgezero-core/src/introspection.rs +++ b/crates/edgezero-core/src/introspection.rs @@ -21,39 +21,39 @@ struct RouteView { path: String, } -/// Extractor for the baked manifest JSON carried in the request's -/// [`crate::router::IntrospectionData`]. Errors with 500 if the data is -/// absent (i.e. the router did not inject it). +/// Extractor for the baked manifest JSON. It is also the payload the router +/// injects (via `dispatch`) for a route whose handler is `#[action(manifest)]`; +/// `from_request` clones it back out. Errors with 500 if the route did not opt +/// into the `manifest` capability (or no manifest was baked). +#[derive(Clone)] pub struct ManifestJson(pub Arc); #[async_trait(?Send)] impl FromRequest for ManifestJson { #[inline] async fn from_request(ctx: &RequestContext) -> Result { - ctx.introspection() - .and_then(|data| data.manifest_json.clone()) - .map(ManifestJson) - .ok_or_else(|| { - EdgeError::internal(anyhow::anyhow!("manifest introspection data not available")) - }) + ctx.extension::().ok_or_else(|| { + EdgeError::internal(anyhow::anyhow!("manifest introspection data not available")) + }) } } -/// Extractor for the live route index carried in the request's -/// [`crate::router::IntrospectionData`]. Errors with 500 if the data is absent. +/// Extractor for the live route index. It is also the payload the router injects +/// for a route whose handler is `#[action(routes)]`; `from_request` clones it +/// back out. Errors with 500 if the route did not opt into the `routes` +/// capability. +#[derive(Clone)] pub struct RouteTable(pub Arc<[RouteInfo]>); #[async_trait(?Send)] impl FromRequest for RouteTable { #[inline] async fn from_request(ctx: &RequestContext) -> Result { - ctx.introspection() - .map(|data| RouteTable(Arc::clone(&data.routes))) - .ok_or_else(|| { - EdgeError::internal(anyhow::anyhow!( - "route-table introspection data not available" - )) - }) + ctx.extension::().ok_or_else(|| { + EdgeError::internal(anyhow::anyhow!( + "route-table introspection data not available" + )) + }) } } diff --git a/crates/edgezero-core/src/router.rs b/crates/edgezero-core/src/router.rs index d5ba3049..7c3b9a16 100644 --- a/crates/edgezero-core/src/router.rs +++ b/crates/edgezero-core/src/router.rs @@ -7,25 +7,29 @@ use tower_service::Service; use crate::context::RequestContext; use crate::error::EdgeError; -use crate::handler::{BoxHandler, IntoHandler}; +use crate::handler::{BoxHandler, IntoHandler, IntrospectionNeeds}; use crate::http::{HandlerFuture, Method, Request, Response}; +use crate::introspection::{ManifestJson, RouteTable}; use crate::middleware::{BoxMiddleware, Middleware, Next}; use crate::params::PathParams; use crate::response::IntoResponse as _; struct RouteEntry { handler: BoxHandler, + introspection_needs: IntrospectionNeeds, } impl Clone for RouteEntry { fn clone(&self) -> Self { Self { handler: Arc::clone(&self.handler), + introspection_needs: self.introspection_needs, } } fn clone_from(&mut self, source: &Self) { self.handler = Arc::clone(&source.handler); + self.introspection_needs = source.introspection_needs; } } @@ -57,15 +61,6 @@ impl RouteInfo { } } -/// Per-request introspection payload injected by [`RouterInner::dispatch`]. -#[derive(Clone)] -pub struct IntrospectionData { - /// The app manifest serialized to JSON at compile time by `app!`. - pub manifest_json: Option>, - /// Every registered route, in registration order. - pub routes: Arc<[RouteInfo]>, -} - enum RouteMatch<'route> { Found(&'route RouteEntry, PathParams), MethodNotAllowed(Vec), @@ -91,11 +86,17 @@ impl RouterBuilder { { let router = self.routes.entry(method.clone()).or_default(); + // The handler reports which introspection payloads its route needs; the + // flag is read once here and consulted per request in `dispatch`. + let boxed = handler.into_handler(); + let introspection_needs = boxed.introspection_needs(); + router .insert( path, RouteEntry { - handler: handler.into_handler(), + handler: boxed, + introspection_needs, }, ) .unwrap_or_else(|err| panic!("duplicate route definition for {path}: {err}")); @@ -203,16 +204,26 @@ struct RouterInner { impl RouterInner { async fn dispatch(&self, mut request: Request) -> Result { - request.extensions_mut().insert(IntrospectionData { - manifest_json: self.manifest_json.clone(), - routes: Arc::clone(&self.route_index), - }); - let method = request.method().clone(); let path = request.uri().path().to_owned(); match self.find_route(&method, &path) { RouteMatch::Found(entry, params) => { + // Inject only the introspection payloads this route asked for — + // nothing for the vast majority of routes that need none. + let needs = entry.introspection_needs; + if needs.manifest { + if let Some(json) = &self.manifest_json { + request + .extensions_mut() + .insert(ManifestJson(Arc::clone(json))); + } + } + if needs.routes { + request + .extensions_mut() + .insert(RouteTable(Arc::clone(&self.route_index))); + } let ctx = RequestContext::new(request, params); let next = Next::new(&self.middlewares, entry.handler.as_ref()); next.run(ctx).await @@ -320,6 +331,135 @@ impl RouterService { #[cfg(test)] mod tests { + /// Per-capability introspection injection: a route receives exactly the + /// payloads its handler opted into via `#[action(manifest|routes)]`. + mod introspection_gating { + use super::*; + use crate::handler::DynHandler; + + /// A handler that records which introspection payloads its request + /// carried, as `(manifest_present, routes_present)`, and reports `needs`. + struct CapProbe { + needs: IntrospectionNeeds, + seen: Arc>>, + } + + impl DynHandler for CapProbe { + fn call(&self, ctx: RequestContext) -> HandlerFuture { + let seen = Arc::clone(&self.seen); + Box::pin(async move { + *seen.lock().unwrap() = Some(( + ctx.extension::().is_some(), + ctx.extension::().is_some(), + )); + response_with_body(StatusCode::OK, Body::empty()) + }) + } + fn introspection_needs(&self) -> IntrospectionNeeds { + self.needs + } + } + + #[test] + fn manifest_route_injects_only_manifest() { + // Manifest available AND requested → ManifestJson present, RouteTable absent. + let seen = run_probe( + RouterService::builder().with_manifest_json("{\"app\":{\"name\":\"t\"}}"), + IntrospectionNeeds { + manifest: true, + routes: false, + }, + ); + assert_eq!(seen, (true, false)); + } + + #[test] + fn middleware_sees_injected_manifest() { + // Injection happens before the middleware chain, so a middleware on a + // manifest-flagged route sees the payload. + struct Probe(Arc>>); + #[async_trait::async_trait(?Send)] + impl Middleware for Probe { + async fn handle( + &self, + ctx: RequestContext, + next: Next<'_>, + ) -> Result { + *self.0.lock().unwrap() = Some(ctx.extension::().is_some()); + next.run(ctx).await + } + } + + let saw: Arc>> = Arc::new(Mutex::new(None)); + let router = RouterService::builder() + .with_manifest_json("{\"app\":{\"name\":\"t\"}}") + .middleware(Probe(Arc::clone(&saw))) + .get( + "/", + CapProbe { + needs: IntrospectionNeeds { + manifest: true, + routes: false, + }, + seen: Arc::new(Mutex::new(None)), + }, + ) + .build(); + let request = request_builder() + .method(Method::GET) + .uri("/") + .body(Body::empty()) + .unwrap(); + block_on(router.oneshot(request)).unwrap(); + assert_eq!(*saw.lock().unwrap(), Some(true)); + } + + #[test] + fn plain_route_injects_neither() { + // Manifest IS baked but the route requested nothing → neither injected. + let seen = run_probe( + RouterService::builder().with_manifest_json("{\"app\":{\"name\":\"t\"}}"), + IntrospectionNeeds::default(), + ); + assert_eq!(seen, (false, false)); + } + + #[test] + fn routes_route_injects_only_routes() { + // Only `routes` requested → RouteTable present (from the always-available + // route index), ManifestJson absent (not requested; none baked either). + let seen = run_probe( + RouterService::builder(), + IntrospectionNeeds { + manifest: false, + routes: true, + }, + ); + assert_eq!(seen, (false, true)); + } + + fn run_probe(builder: RouterBuilder, needs: IntrospectionNeeds) -> (bool, bool) { + let seen = Arc::new(Mutex::new(None)); + let router = builder + .get( + "/", + CapProbe { + needs, + seen: Arc::clone(&seen), + }, + ) + .build(); + let request = request_builder() + .method(Method::GET) + .uri("/") + .body(Body::empty()) + .unwrap(); + block_on(router.oneshot(request)).unwrap(); + let observed = *seen.lock().unwrap(); + observed.expect("handler ran") + } + } + use super::*; use crate::body::Body; use crate::context::RequestContext; @@ -533,6 +673,7 @@ mod tests { fn route_entry_clone_copies_handler() { let entry = RouteEntry { handler: ok_handler.into_handler(), + introspection_needs: IntrospectionNeeds::default(), }; let cloned = entry.clone(); @@ -583,77 +724,6 @@ mod tests { assert!(matches!(ready, Poll::Ready(Ok(())))); } - #[test] - fn dispatch_injects_introspection_data() { - let seen: Arc>> = Arc::new(Mutex::new(None)); - let seen_capture = Arc::clone(&seen); - - let handler = move |ctx: RequestContext| { - let seen_inner = Arc::clone(&seen_capture); - async move { - let data = ctx.introspection().expect("introspection data present"); - *seen_inner.lock().unwrap() = - Some((data.manifest_json.is_some(), data.routes.len())); - Ok::<_, EdgeError>("ok") - } - }; - - let router = RouterService::builder() - .with_manifest_json("{\"app\":{\"name\":\"t\"}}") - .get("/", handler) - .build(); - - let request = request_builder() - .method(Method::GET) - .uri("/") - .body(Body::empty()) - .unwrap(); - block_on(router.oneshot(request)).unwrap(); - - let (had_manifest, route_count) = seen.lock().unwrap().expect("handler ran"); - assert!(had_manifest, "manifest_json should be injected"); - assert_eq!(route_count, 1); - } - - #[test] - fn middleware_sees_introspection_data() { - struct Probe(Arc>>); - #[async_trait::async_trait(?Send)] - impl Middleware for Probe { - async fn handle( - &self, - ctx: RequestContext, - next: Next<'_>, - ) -> Result { - *self.0.lock().unwrap() = ctx - .introspection() - .map(|data| (data.manifest_json.is_some(), data.routes.len())); - next.run(ctx).await - } - } - - let saw: Arc>> = Arc::new(Mutex::new(None)); - let router = RouterService::builder() - .with_manifest_json("{\"app\":{\"name\":\"t\"}}") - .middleware(Probe(Arc::clone(&saw))) - .get("/", |_ctx: RequestContext| async { - Ok::<_, EdgeError>("ok") - }) - .build(); - let request = request_builder() - .method(Method::GET) - .uri("/") - .body(Body::empty()) - .unwrap(); - block_on(router.oneshot(request)).unwrap(); - let (had_manifest, route_count) = saw.lock().unwrap().expect("middleware ran"); - assert!(had_manifest, "middleware should see manifest_json"); - assert!( - route_count > 0, - "middleware should see non-empty route list" - ); - } - #[test] fn streams_body_through_router() { use bytes::Bytes; From e68df70b1a856e20c20434472dc22d68769ce18e Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Thu, 2 Jul 2026 12:00:29 -0700 Subject: [PATCH 28/74] Review fixes: combined-caps atomicity test, no-params fn assertion, stale comment --- crates/edgezero-core/src/introspection.rs | 5 +++-- crates/edgezero-core/src/router.rs | 13 +++++++++++++ crates/edgezero-macros/src/action.rs | 6 +++++- 3 files changed, 21 insertions(+), 3 deletions(-) diff --git a/crates/edgezero-core/src/introspection.rs b/crates/edgezero-core/src/introspection.rs index 706a8676..2c176016 100644 --- a/crates/edgezero-core/src/introspection.rs +++ b/crates/edgezero-core/src/introspection.rs @@ -204,8 +204,9 @@ mod tests { #[test] fn manifest_without_baked_json_is_500() { - // No `with_manifest_json`: IntrospectionData is still injected, but - // `manifest_json` is None, so the `ManifestJson` extractor errors 500. + // The route opts into `manifest`, but no manifest was baked + // (`with_manifest_json` not called), so `dispatch` injects nothing and + // the `ManifestJson` extractor errors 500. let router = RouterService::builder().get("/m", manifest).build(); let req = request_builder() .method(Method::GET) diff --git a/crates/edgezero-core/src/router.rs b/crates/edgezero-core/src/router.rs index 7c3b9a16..eae25a9f 100644 --- a/crates/edgezero-core/src/router.rs +++ b/crates/edgezero-core/src/router.rs @@ -360,6 +360,19 @@ mod tests { } } + #[test] + fn manifest_and_routes_route_injects_both() { + // Combined `#[action(manifest, routes)]` → both payloads injected. + let seen = run_probe( + RouterService::builder().with_manifest_json("{\"app\":{\"name\":\"t\"}}"), + IntrospectionNeeds { + manifest: true, + routes: true, + }, + ); + assert_eq!(seen, (true, true)); + } + #[test] fn manifest_route_injects_only_manifest() { // Manifest available AND requested → ManifestJson present, RouteTable absent. diff --git a/crates/edgezero-macros/src/action.rs b/crates/edgezero-macros/src/action.rs index bd1e8467..4a9e02d9 100644 --- a/crates/edgezero-macros/src/action.rs +++ b/crates/edgezero-macros/src/action.rs @@ -277,8 +277,12 @@ mod tests { let output = expand_action_impl(&TokenStream::new(), input); let rendered = render(&output); assert!(rendered.contains("__demo_inner")); - assert!(rendered.contains("fn demo")); assert!(rendered.contains("responder :: Responder :: respond")); + // No params → a plain `async fn`, NOT a capability-carrying struct. + let collapsed = collapse_whitespace(&rendered); + assert!(collapsed.contains("asyncfndemo")); + assert!(!collapsed.contains("structdemo")); + assert!(!collapsed.contains("introspection_needs")); } #[test] From 2efa2da69d4bd1d4f20b7d9595440e6bbc416279 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Thu, 2 Jul 2026 12:11:37 -0700 Subject: [PATCH 29/74] Add macro-level test for combined #[action(manifest, routes)] codegen --- crates/edgezero-macros/src/action.rs | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/crates/edgezero-macros/src/action.rs b/crates/edgezero-macros/src/action.rs index 4a9e02d9..064a90fe 100644 --- a/crates/edgezero-macros/src/action.rs +++ b/crates/edgezero-macros/src/action.rs @@ -331,6 +331,28 @@ mod tests { assert!(collapsed.contains("routes:false")); } + #[test] + fn manifest_and_routes_params_set_both_capabilities() { + let input = quote! { + async fn both( + ManifestJson(json): ManifestJson, + RouteTable(table): RouteTable, + ) -> ::std::result::Result< + ::edgezero_core::http::Response, + ::edgezero_core::error::EdgeError, + > { + let _ = (json, table); + unimplemented!() + } + }; + let output = expand_action_impl("e!(manifest, routes), input); + let collapsed = collapse_whitespace(&render(&output)); + // The combined form emits a struct whose `introspection_needs` sets both. + assert!(collapsed.contains("structboth")); + assert!(collapsed.contains("manifest:true")); + assert!(collapsed.contains("routes:true")); + } + #[test] fn rejects_self_receivers() { let input = quote! { From 585cbc54b3c9f79d829e064089e2bf19ae459def Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Thu, 2 Jul 2026 15:32:11 -0700 Subject: [PATCH 30/74] docs: add State + nested #[secret] design spec MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Design spec for two upstream edgezero-core/edgezero-macros primitives: - State extractor + RouterBuilder::with_state for app-owned shared state - nested/array #[secret] support via path-qualified SecretField metadata Filed under docs/superpowers/specs/. Includes a maintainer-review appendix (§8) verifying every current-mechanics claim against origin/main @ 42843b1 and folding in the corrections found: http-facade use in the router plumbing, the inaccurate lib.rs re-export step, the omitted validate_excluding_secrets consumer (needs nested-ValidationErrors navigation, not a rename), the per-struct guard-enforcement rewording, and B-3 being forced to the secret_fields() fn lowering. --- ...dgezero-state-and-nested-secrets-design.md | 416 ++++++++++++++++++ 1 file changed, 416 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-02-edgezero-state-and-nested-secrets-design.md diff --git a/docs/superpowers/specs/2026-07-02-edgezero-state-and-nested-secrets-design.md b/docs/superpowers/specs/2026-07-02-edgezero-state-and-nested-secrets-design.md new file mode 100644 index 00000000..1abfe7b2 --- /dev/null +++ b/docs/superpowers/specs/2026-07-02-edgezero-state-and-nested-secrets-design.md @@ -0,0 +1,416 @@ +# EdgeZero — `State` extractor + nested/array `#[secret]` support + +- **Status:** Draft for edgezero maintainer review +- **Date:** 2026-07-02 +- **Author:** trusted-server team (spec), to be implemented by an **edgezero** developer +- **Target repo:** `github.com/stackpop/edgezero` (crates `edgezero-core`, `edgezero-macros`) +- **Consumed by:** trusted-server "move completely to EdgeZero" migration; nothing in trusted-server can start until these two upstream primitives land + +--- + +## 1. Why this spec exists + +trusted-server is migrating fully onto EdgeZero. Two later phases of that migration are **blocked on primitives that EdgeZero does not yet expose**: + +1. **Handlers → extractors** (trusted-server Phase 4) needs a way to pass **app-owned shared state** (`Arc`: `Settings`, `AuctionOrchestrator`, `IntegrationRegistry`) into `#[action]` extractor-style handlers. EdgeZero's extractors are all **request-derived** — there is no `State`/`Extension` extractor and no `RequestContext` accessor for app state. + +2. **Secret externalization** (trusted-server Phase 3) needs `#[derive(AppConfig)]` + the runtime secret walk to resolve `#[secret]` fields that live **nested inside sub-structs and arrays**. Today both are restricted to **top-level scalar `String`** fields. trusted-server's `Settings` is deeply nested (per-integration secret fields, arrays of partners), so the current model cannot express its secrets. The existing `TrustedServerAppConfig` wrapper documents this explicitly with `SECRET_FIELDS = &[]` and a comment that nested/array extraction "needs support tracked separately." + +Both are small, self-contained additions to `edgezero-core` + `edgezero-macros`. They are **independent of each other** and can land in either order or in parallel. + +### Goals + +- Add a `State` extractor (and the router plumbing to populate it) so any EdgeZero app can hand app-owned state to extractor handlers. +- Extend `#[derive(AppConfig)]` and the runtime `secret_walk` to resolve `#[secret]` fields nested in sub-structs and (optionally) arrays, keyed by a **field path** rather than a single top-level name. + +### Non-goals + +- No changes to trusted-server in this phase (that is Phases 1–5 of the umbrella plan). +- No change to the blob-envelope format, canonical-form hashing, or the push/validate CLI flow beyond what nested secret metadata requires. +- No change to the `app!` macro's manifest-driven routing (trusted-server builds its router imperatively via `Hooks::routes()`, so `State` is wired through the `RouterBuilder`, not the manifest). + +--- + +## 2. Current mechanics (verified against `main`) + +### 2.1 Request extensions are the transport for everything per-request + +`RequestContext` wraps an `http`-style `Request`; every per-request handle is read out of `request.extensions()`: + +```rust +// crates/edgezero-core/src/context.rs +pub fn kv_store_default(&self) -> Option { + self.request.extensions().get::().and_then(StoreRegistry::default) +} +``` + +Extractors follow the same shape (`crates/edgezero-core/src/extractor.rs`): + +```rust +#[async_trait(?Send)] +impl FromRequest for Kv { + async fn from_request(ctx: &RequestContext) -> Result { + ctx.request().extensions().get::().cloned().map(Kv) + .ok_or_else(|| EdgeError::internal(anyhow::anyhow!("no kv store configured ..."))) + } +} +``` + +Registries get **inserted into `request.extensions_mut()` by the adapter** just before dispatch (reference: `edgezero-adapter-axum/src/service.rs` → `router.oneshot(core_request)`; `edgezero-adapter-fastly/src/request.rs::dispatch_with_registries`). + +### 2.2 The router owns dispatch and builds the context + +```rust +// crates/edgezero-core/src/router.rs +impl RouterInner { + async fn dispatch(&self, request: Request) -> Result { + match self.find_route(&method, &path) { + RouteMatch::Found(entry, params) => { + let ctx = RequestContext::new(request, params); // <-- request is owned here + let next = Next::new(&self.middlewares, entry.handler.as_ref()); + next.run(ctx).await + } + ... + } + } +} +``` + +`RouterBuilder` (also in `router.rs`) already holds `middlewares`, `routes`, `route_info`. It is the natural owner of app state because **trusted-server builds its router imperatively inside `Hooks::routes()`**, where `Arc` is in scope. Adapters are generic over `A: Hooks` and never see the concrete state type, so state cannot be injected adapter-side. + +### 2.3 `#[derive(AppConfig)]` is top-level + scalar-`String`-only + +```rust +// crates/edgezero-macros/src/app_config.rs +fn is_scalar_string_type(ty: &Type) -> bool { /* accepts bare `String` only */ } +// scan_field() calls enforce_scalar_string_type() -> rejects Option, Vec<_>, nested structs. +// The emitted SECRET_FIELDS is a flat array of top-level Rust field names: +const SECRET_FIELDS: &'static [SecretField] = &[SecretField { name: "api_token", kind: KeyInDefault }]; +``` + +`SecretField` (`crates/edgezero-core/src/app_config.rs`) is `{ kind: SecretKind, name: &'static str }`, where `name` is a single top-level key. + +### 2.4 The runtime `secret_walk` only navigates the top level + +```rust +// crates/edgezero-core/src/extractor.rs +async fn secret_walk(ctx: &RequestContext, data: &mut serde_json::Value) -> Result<(), EdgeError> { + let data_obj = data.as_object_mut().ok_or(...)?; // top-level object only + for field in C::SECRET_FIELDS { + let key_name = data_obj.get(field.name)...; // top-level get by flat name + // ... resolve secret value, then: + data_obj.insert(field.name, resolved_value); // top-level insert + } +} +``` + +Called from `extract_from_handle::()`, which does fetch → `BlobEnvelope::verify()` → `secret_walk` → `serde_path_to_error::deserialize` → `cfg.validate()`. The split between push-time (`validate_excluding_secrets`, values are key names) and runtime (`cfg.validate()`, values resolved) must be preserved. + +--- + +## 3. Workstream A — `State` extractor + +### 3.1 Public API additions + +**`crates/edgezero-core/src/router.rs` — `RouterBuilder`:** + +```rust +impl RouterBuilder { + /// Register a value that is cloned into every request's extensions + /// before dispatch, making it available to the `State` extractor + /// and to `RequestContext`-based handlers. + /// + /// Typically `T = Arc`. Last write wins for a given `T`. + #[must_use] + pub fn with_state(mut self, value: T) -> Self + where + T: Clone + Send + Sync + 'static, + { /* push a type-erased inserter (see 3.2) */ self } +} +``` + +**`crates/edgezero-core/src/extractor.rs` — new extractor:** + +```rust +/// Extractor for app-owned shared state registered via +/// `RouterBuilder::with_state`. Resolves by type from request extensions. +pub struct State(pub T); + +#[async_trait(?Send)] +impl FromRequest for State +where + T: Clone + Send + Sync + 'static, +{ + async fn from_request(ctx: &RequestContext) -> Result { + ctx.request() + .extensions() + .get::() + .cloned() + .map(State) + .ok_or_else(|| EdgeError::internal(anyhow::anyhow!( + "no `State<{}>` registered — call RouterBuilder::with_state(..)", + core::any::type_name::() + ))) + } +} +// + Deref/DerefMut/into_inner to mirror the other extractors. +``` + +Because `State: FromRequest`, it works inside `#[action]` with **no change to `edgezero-macros/src/action.rs`** (the macro already emits `::from_request(&ctx).await?` for every non-`RequestContext` argument). + +Handler ergonomics (trusted-server side, illustrative only): + +```rust +#[action] +pub async fn handle_auction( + State(state): State>, + Json(req): Json, +) -> Result { /* state.settings, state.orchestrator, ... */ } +``` + +### 3.2 Router plumbing + +Add to `RouterBuilder` a list of type-erased inserters and thread them into `RouterInner`: + +```rust +type StateInserter = Arc; + +#[derive(Default)] +pub struct RouterBuilder { + // ... existing fields ... + state_inserters: Vec, +} + +pub fn with_state(mut self, value: T) -> Self +where T: Clone + Send + Sync + 'static { + self.state_inserters.push(Arc::new(move |ext: &mut http::Extensions| { + ext.insert(value.clone()); + })); + self +} +``` + +In `RouterInner::dispatch`, apply inserters to the owned request **before** building the context: + +```rust +RouteMatch::Found(entry, params) => { + let mut request = request; + for inserter in &self.state_inserters { + inserter(request.extensions_mut()); + } + let ctx = RequestContext::new(request, params); + let next = Next::new(&self.middlewares, entry.handler.as_ref()); + next.run(ctx).await +} +``` + +Notes: +- `RouterInner` gains a `state_inserters: Vec` field; `RouterService::new` takes it from the builder. +- Insertion happens **after** the adapter has already inserted the store registries into the same extensions map (different `TypeId`s, no collision). If an app ever registers a `T` that an adapter also inserts, last-write-wins and the router runs last — document this; it is not expected in practice. +- Cost is one `Arc` clone (or one `T::clone`) per registered state per request — negligible for `Arc`. +- The route-listing internal handler and middleware are unaffected. + +### 3.3 Naming decision (needs maintainer sign-off) + +Two reasonable names for the same mechanism: + +- **`State`** — matches the app-state use case and reads naturally at call sites. This is what trusted-server asked for. **Recommended.** +- **`Extension`** — matches axum's *runtime-resolved* semantics exactly (this mechanism is axum's `Extension`, not axum's compile-time-typed `State`). More honest about behavior; more familiar to axum users. + +**Recommendation:** ship `State` + `with_state` as the primary API. Optionally add `Extension` + `with_extension` as thin aliases if the maintainer wants the axum-accurate name available too. Avoid shipping both as first-class with divergent behavior. + +### 3.4 Tests (edgezero) + +- Unit (`extractor.rs`): `State` resolves a registered `Arc`; returns `EdgeError::internal` (500) when unregistered; `Deref` works. +- Unit (`router.rs`): a handler taking `State>` sees the value after `with_state`; two different `T`s coexist; re-registering the same `T` is last-write-wins. +- Integration: `#[action] fn h(State(s): State>, Query(q): Query)` compiles and runs (proves macro composition with an existing extractor). +- Concurrency: two in-flight requests each get an independent clone (no cross-request bleed). + +### 3.5 Docs + +- `docs/guide/handlers.md`: add a "Sharing app state" section showing `RouterBuilder::with_state` + `State`. +- Rustdoc on `State`, `with_state` with the `Arc` example and the last-write-wins note. + +--- + +## 4. Workstream B — nested/array `#[secret]` support + +### 4.1 Problem statement + +`#[secret]` must be expressible on fields **below the root**, e.g.: + +```rust +#[derive(AppConfig, Deserialize, Validate)] +struct Settings { + #[validate(nested)] integrations: IntegrationSettings, + #[validate(nested)] partners: Vec, +} +struct IntegrationSettings { #[validate(nested)] datadome: DataDome } +struct DataDome { #[secret] server_side_key: String } // path: integrations.datadome.server_side_key +struct Partner { #[secret] api_key: String } // path: partners[*].api_key +``` + +Today this fails to compile (`enforce_scalar_string_type` rejects the containing types at the root; the derive never recurses). + +### 4.2 Metadata model: path-qualified secret fields + +Extend `SecretField` (`crates/edgezero-core/src/app_config.rs`) to carry a **path** instead of a single `name`. The path is a sequence of segments; each segment is either a named field or an array wildcard: + +```rust +pub enum SecretPathSegment { + Field(&'static str), // object key (Rust field name, verbatim) + ArrayEach, // every element of an array +} + +pub struct SecretField { + pub kind: SecretKind, + pub path: &'static [SecretPathSegment], // was: name: &'static str +} +``` + +- A top-level scalar keeps working: its path is `&[Field("api_token")]` (length 1) — **backward compatible in behavior**, though the struct field changes shape (see 4.6 for the compat call-out). +- `store_ref` siblings referenced by `SecretKind::KeyInNamedStore { store_ref_field }` are resolved **relative to the same parent object** as the secret field (i.e. sibling within the innermost containing object). Document this scoping rule explicitly. + +**Open design question (B-1):** array support (`ArrayEach`) adds real complexity to both the derive and the walk. If trusted-server's Phase 3 audit finds **no secrets inside arrays** (only nested objects), we can ship object-only nesting first (`SecretPathSegment::Field` only) and defer `ArrayEach`. Recommendation: **confirm the secret inventory against `Settings` before implementing arrays**; design the enum to allow `ArrayEach` from day one but gate its implementation on a real need. + +### 4.3 Derive changes (`crates/edgezero-macros/src/app_config.rs`) + +Recurse into fields whose type is itself an `AppConfig`-derived struct (or a `Vec<_>`/`[_]` of one), accumulating the path: + +1. Keep the current scan for direct `#[secret]` fields, but emit `path = [Field(name)]` instead of `name`. +2. Add recursion: for a field annotated to recurse (see B-2 below), descend into the referenced type and prefix every `SecretField` it produces with `Field(field_name)` (or `Field(field_name), ArrayEach` for a `Vec`). +3. Preserve all existing compile-time guards (no `#[serde(rename/flatten/skip*)]` on the path, no container `rename_all` when any secret exists) **along the entire path**, since a rename anywhere desyncs the JSON key from the emitted path segment. + +**Open design question (B-2): how does the derive know which fields to recurse into?** The macro sees only syntax, not resolved types, so it cannot know a field's type also derives `AppConfig`. Two options: + +- **(Recommended) Explicit opt-in attribute**, e.g. `#[app_config(nested)]` on the containing field (and `#[app_config(nested)]` on a `Vec` field for array recursion). Mirrors `#[validate(nested)]`, is unambiguous, and keeps the derive purely syntactic. The sub-struct must itself derive `AppConfig` (enforced at runtime via the `AppConfigRoot` marker / a generated const assertion). +- **(Alternative) Type-name heuristic** — recurse into any field whose type path "looks like" a struct. Rejected: brittle, silently wrong for third-party types, and can't see through aliases. + +With explicit opt-in, the derive emits, for each nested field, code that references the sub-struct's own `SECRET_FIELDS` and prefixes the path — so recursion composes without the parent macro needing the child's fields: + +```rust +// sketch of emitted metadata for `integrations: IntegrationSettings` (#[app_config(nested)]) +// concatenate, at const-eval where possible, or via a generated fn: +// prefix [Field("integrations")] onto each of ::SECRET_FIELDS +``` + +> Implementation note: `&'static [SecretField]` concatenation across crates is not trivial at `const` if paths must be `&'static [SecretPathSegment]`. Two viable lowerings: (a) generate a `const` block per struct that hand-builds the full flattened array literal by inlining child segments the macro can see through the opt-in type path; or (b) change `AppConfigMeta` from an associated `const` to an associated `fn secret_fields() -> Vec` (owned, path segments `Vec<...>`), letting recursion build owned vectors at runtime. **Recommendation:** prefer (b) — a `fn secret_fields() -> Cow<'static, [SecretField]>` — because it makes cross-crate recursion straightforward and the walk runs once per request anyway (allocation cost negligible vs. the network fetch). Flag this as **B-3** for maintainer decision, since it changes the `AppConfigMeta` trait shape. + +Also relax `enforce_scalar_string_type` to additionally accept `Option` on `#[secret]` fields (optional secrets are common in real config); an absent/`None` optional secret is skipped by the walk rather than erroring. Keep rejecting non-string scalar types. + +### 4.4 Runtime `secret_walk` changes (`crates/edgezero-core/src/extractor.rs`) + +Replace the top-level-only loop with a **path navigator**: + +```rust +// For each SecretField, walk `data` along field.path: +// Field(name) -> descend into object key `name` +// ArrayEach -> iterate every element of the current array, applying the +// remainder of the path to each +// At the leaf: the string value is a secret KEY NAME; resolve it via the +// existing SecretKind logic (KeyInDefault / KeyInNamedStore / StoreRef) and +// replace in place. `store_ref_field` is looked up in the leaf's PARENT object. +``` + +- Preserve exact error semantics: missing/non-string leaf → `EdgeError::config_out_of_date` with the **dotted path** (e.g. `integrations.datadome.server_side_key`, `partners[3].api_key`) as the field hint. This improves on today's single-name hint. +- `Option` secret that is absent → skip (no error). +- `StoreRef` leaves are still skipped (value is a store id). +- Keep the push/runtime validation split intact (`validate_excluding_secrets` at push; `cfg.validate()` at runtime after resolution). Push-time secret detection also reflects over the new path metadata. + +### 4.5 CLI touchpoints (`edgezero-cli`) + +`run_config_validate_typed` / `run_config_push_typed` / `run_config_diff_typed` reflect over `SECRET_FIELDS` (now paths) to know which fields hold key-names vs. values. Update those reflections to walk paths. `build_config_envelope` is unchanged (it serializes the typed struct verbatim; secret leaves already hold key names at push time). Verify the Spin lowercase-secret-name collision check still operates over the new path metadata. + +### 4.6 Backward compatibility + +- **Behavioral:** existing top-level `#[secret]` configs (e.g. `app-demo`'s `api_token`) resolve identically — their path is length 1. +- **Source-level (breaking within edgezero):** `SecretField.name: &'static str` → `SecretField.path: &'static [SecretPathSegment]` (and possibly `AppConfigMeta::SECRET_FIELDS` const → `secret_fields()` fn, per B-3). Every in-tree consumer (`secret_walk`, the CLI reflections, tests, `app-demo`) updates in the same PR. No external consumers exist yet besides `app-demo`. Provide a helper `SecretField::dotted_path() -> String` for error messages and CLI output. + +### 4.7 Tests (edgezero) + +- Derive UI tests (trybuild, matching the existing `crates/edgezero-macros/tests/ui/` style): + - nested `#[app_config(nested)]` object with a `#[secret]` leaf compiles; emits the expected path. + - `#[secret]` on `Option` compiles; on `Vec`/non-string still errors. + - `#[serde(rename)]` anywhere along a secret path errors. + - nested field annotated `#[app_config(nested)]` whose type does not derive `AppConfig` errors clearly. + - (if arrays land) `Vec` with `#[app_config(nested)]` emits `ArrayEach`. +- Runtime `secret_walk` tests: nested object leaf resolves from default store; nested `KeyInNamedStore` resolves `store_ref` sibling in the same parent; absent `Option` secret is skipped; missing required nested leaf errors with the dotted path; (if arrays) each element resolved independently. +- End-to-end `AppConfig` extractor test with a 2-level nested secret over an `InMemorySecretStore`. + +### 4.8 Docs + +- `docs/guide/configuration.md` (and the blob-app-config spec/guide): document nested/array `#[secret]`, the `#[app_config(nested)]` opt-in, the `store_ref` sibling scoping rule, and the dotted-path error format. + +--- + +## 5. Sequencing, dependencies, acceptance + +- **A and B are independent.** Either can land first; both are prerequisites for trusted-server work (A → TS Phase 4 extractors; B → TS Phase 3 secret externalization). +- **Suggested order:** B first (it is the higher-risk design and gates the operator-facing secret migration), A alongside or after. Not a hard requirement. + +**Acceptance criteria (edgezero CI gates apply):** + +1. `cargo fmt` / clippy clean across `edgezero-core`, `edgezero-macros`, all adapters. +2. New unit + UI + integration tests (3.4, 4.7) pass. +3. `app-demo` still builds and serves on all four adapters; its top-level `#[secret]` still resolves. +4. `edgezero-cli` `config validate/push/diff` operate correctly over a config with a nested secret. +5. Rustdoc + guide updates (3.5, 4.8) merged. + +--- + +## 6. Risks & open questions + +| ID | Question | Recommendation | +|----|----------|----------------| +| B-1 | Are there secrets inside **arrays** in `Settings`, or only nested objects? | Audit `Settings` in TS Phase 3 scoping; design `SecretPathSegment::ArrayEach` in from day one but implement only if needed. | +| B-2 | How does the derive decide which fields to recurse into? | Explicit `#[app_config(nested)]` opt-in on the field (mirrors `#[validate(nested)]`); reject the type-heuristic alternative. | +| B-3 | Keep `AppConfigMeta::SECRET_FIELDS` as an associated `const`, or switch to `fn secret_fields()`? | Switch to a `fn` returning owned/`Cow` path segments — makes cross-crate recursion tractable; per-request cost is negligible. Maintainer decision as it reshapes the public trait. | +| A-1 | Name the extractor `State` or `Extension`? | `State` + `with_state` primary; optionally `Extension` alias. | +| A-2 | Should `State` also be exposed via a `RequestContext` accessor (not just the extractor)? | Optional; add `ctx.state::()` only if a non-`#[action]` call site needs it. Extractor is sufficient for TS. | +| GEN | Should this spec live in the edgezero repo instead of trusted-server? | It is filed here (trusted-server) as part of the umbrella migration; **hand a copy to the edgezero maintainer** to implement upstream, or relocate to `edgezero/docs/superpowers/specs/` if preferred. | + +--- + +## 7. Files to touch (edgezero repo) + +**Workstream A** +- `crates/edgezero-core/src/router.rs` — `RouterBuilder::with_state`, `RouterInner.state_inserters`, dispatch insertion. +- `crates/edgezero-core/src/extractor.rs` — `State` extractor (+ `Deref`/`into_inner`). +- `crates/edgezero-core/src/lib.rs` — re-export `State`. +- `docs/guide/handlers.md`. + +**Workstream B** +- `crates/edgezero-core/src/app_config.rs` — `SecretPathSegment`, reshaped `SecretField`, `AppConfigMeta` (const→fn per B-3), `dotted_path()` helper. +- `crates/edgezero-macros/src/app_config.rs` — recursion, `#[app_config(nested)]` parsing, relaxed scalar rule for `Option`, path-aware guards. +- `crates/edgezero-core/src/extractor.rs` — path-navigating `secret_walk`. +- `crates/edgezero-cli/src/config.rs` — path-aware secret reflection in validate/push/diff. +- `crates/edgezero-macros/tests/ui/*` + core/CLI tests. +- `docs/guide/configuration.md`, blob-app-config guide. + +--- + +## 8. Maintainer-review corrections (verified against `origin/main` @ `42843b1`, 2026-07-02) + +A line-by-line review of every claim in §2–§7 against the actual code. **Both workstreams are implementable as designed; no blockers.** The current-mechanics claims (§2.1–§2.4) are all accurate. The items below are corrections and design call-outs to fold in before implementation. + +### Verified accurate + +- **A:** `RequestContext` wraps `Request` and reads handles from `request.extensions()` (`context.rs:13`, `kv_store_default` at `context.rs:123`); `FromRequest for Kv` matches the §2.1 quote (`extractor.rs:480`); `RequestContext::new(request, params)` (`context.rs:131`) and the owned-`request` dispatch site (`router.rs:267`, `RouteMatch::Found` at `router.rs:73`) make the "insert into `extensions_mut()` before `RequestContext::new`" plan sound. `RouterBuilder` derives `Default` (`router.rs:79`), so adding `state_inserters: Vec<_>` is non-breaking. `http` is a direct dep; `Extensions::insert`'s bound really is `Clone + Send + Sync + 'static`, so the spec's `T` bound is exactly right. `#[action]` emits `::from_request(&__ctx).await?` (`action.rs:85`) — so `State` needs **zero** macro change. `EdgeError::internal` → 500 (`error.rs:101,190`). +- **B:** `SecretField { kind, name: &'static str }` (`app_config.rs:41`); `SecretKind::{KeyInDefault, KeyInNamedStore{store_ref_field}, StoreRef}` (`app_config.rs:53`); `SECRET_FIELDS` is an associated `const` on `AppConfigMeta` (`app_config.rs:34`); `is_scalar_string_type` accepts bare `String` only and rejects `Option`/`Vec`/nested (`macros/app_config.rs:275`); the serde `rename`/`flatten`/`skip*` and container `rename_all` guards exist (`macros/app_config.rs:336,363`); `secret_walk` is top-level `as_object_mut()` + per-field get/insert (`extractor.rs:827`) inside the `extract_from_handle` chain fetch → `verify()` → `secret_walk` → `serde_path_to_error` → `cfg.validate()` (`extractor.rs:766`); `EdgeError::config_out_of_date` is the real error used at the missing-leaf path (`extractor.rs:838`); push/runtime split via `validate_excluding_secrets` (`app_config.rs:204`) vs runtime `cfg.validate()` holds; app-demo's top-level `#[secret] api_token` (`app-demo-core/src/config.rs:24`) makes the length-1-path compat claim testable. + +### Corrections to fold in + +- **[A, minor] §3.2 use the `http` facade, not bare `http::Extensions`.** Core routes `http` through `crate::http` (`router.rs:13` already does `use crate::http::…`; alias at `http.rs:25`). Write `type StateInserter = Arc` and the closure param as `&mut crate::http::Extensions`. Bare `http::Extensions` compiles but violates the crate's facade rule / CLAUDE.md. +- **[A, minor] §3.1/§7 the "re-export `State` in `lib.rs`" step is inaccurate.** No extractor is re-exported at the crate root today (`lib.rs:23` is just `pub mod extractor;`); consumers use `edgezero_core::extractor::State`. Making `State` `pub` in `extractor.rs` is sufficient. A crate-root re-export is optional and, if added, must sit under the existing `#![expect(clippy::pub_use, …)]` at `lib.rs:7`. Drop it from the required file-touch list. +- **[A, nit] §3.2 completeness:** also add the field to `RouterInner` (`router.rs:260`), a param to the private `RouterService::new` (`router.rs:343`), and pass `self.state_inserters` from `build()` (`router.rs:160`). +- **[B, IMPORTANT] §4.6 consumer list omits `validate_excluding_secrets`, and it is not a mechanical rename.** `validate_excluding_secrets` (`app_config.rs:204`) removes secret-field validators via `bag.remove(field.name)` on the **top-level** error map (line ~220). For a *nested* secret the failing validator lives under the parent inside a `ValidationErrorsKind::Struct`/`List`, so a flat remove-by-key will not find it → the nested secret's push-time validator would not be excluded, breaking the push/runtime split for exactly the new case. This needs nested-`ValidationErrors` navigation (the `first_violating_field` walk at `extractor.rs:926` is the pattern to reuse), not a `.name`→`.path` swap. Add to the consumer list **and** flag as real work. +- **[B, IMPORTANT] §4.3 reword point 3.** The derive is purely syntactic and cannot see a child struct's fields, so guards cannot be enforced "along the entire path" from the parent. They hold because **each struct on the path independently derives `AppConfig` and self-enforces** — which the B-2 `#[app_config(nested)]` opt-in + `AppConfigRoot` bound is what guarantees. Reword accordingly. +- **[B, IMPORTANT] B-3 is effectively forced to option (b).** Cross-crate `const` concatenation of `&'static [SecretPathSegment]` with a parent-prepended prefix is not expressible in stable `const` (the macro cannot see the child's segments — same syntactic limit as above), so option (a) is not viable. Go with `fn secret_fields() -> Cow<'static, [SecretField]>`. Note the churn is wider than §4.6 states: **every** `impl AppConfigMeta`/`SECRET_FIELDS` site flips, including ~10 hand-rolled test impls in `app_config.rs`/`extractor.rs`/`config.rs` tests. +- **[B, minor] §4.5 point at the real reflection helpers.** The per-field top-level `raw_table.get(field.name)` lookups are in `run_adapter_typed_checks` (`config.rs:1296`) and `typed_secret_checks` (`config.rs:1338`), reached *through* `run_config_{validate,push,diff}_typed`. The Spin lowercase-collision check is `validate_typed_secrets` in `adapter-spin/src/cli.rs:514` (keys on the secret **value**, so it survives the path reshape; only the printed field name becomes a dotted path). +- **[B, minor] §4.7 the nested `KeyInNamedStore` "innermost parent" scoping is new behavior with no existing fixture.** app-demo has only `KeyInDefault` (`api_token`) and `StoreRef` (`vault`) — no `KeyInNamedStore` field — so that test needs a purpose-built fixture; it cannot lean on app-demo. +- **[B, nit] §4.2/§4.4 dotted-path rendering:** the spec uses both `partners[*]` (static metadata) and `partners[3]` (runtime error). Have `SecretField::dotted_path()` render `ArrayEach` as `[*]` for the static path and per-index `[n]` at runtime; the existing `[{idx}]` convention (`extractor.rs:959`) already matches. +- **[GEN, resolved] §6 GEN row** ("should this spec live in edgezero or trusted-server?") is now answered — it lives at `docs/superpowers/specs/` in the edgezero repo. + +### Baseline note + +Local `main` (`b298bc1`, "Extract run_typed_preflight…") is a **broken divergent tip**: it deletes `config.rs` (3365 lines) while leaving `mod config;`/`pub use config::…` in `lib.rs`, so `edgezero-cli` does not compile there, and it is not an ancestor of `origin/main`. `run_typed_preflight` exists nowhere in the tree — that refactor's new files were never committed. All §4.5 claims were therefore verified against `origin/main` (`42843b1`), where `config.rs` is intact; the spec's function names are correct against that tree. From 304e236a20c0d4d4522cbfa9212c818209d6e23f Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Thu, 2 Jul 2026 15:50:41 -0700 Subject: [PATCH 31/74] =?UTF-8?q?docs:=20fold=20second-pass=20review=20blo?= =?UTF-8?q?ckers=20into=20spec=20=C2=A78?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the blockers a follow-up review found (verified against origin/main @ 42843b1) and a Go/No-Go split: - nested-AppConfig CI guard (check_no_nested_app_config.rs) must be inverted - optional-secret metadata (optional: bool) is missing from SecretField - path model must commit to owned segments (Vec/Cow), not &'static - register the app_config helper attribute in the derive - TypedSecretEntry.field_name must be owned for dotted/array paths - enforce container rename_all on nested-only parents - settle array scope before implementing Workstream A is plan-ready now; B waits on the above. --- ...dgezero-state-and-nested-secrets-design.md | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/docs/superpowers/specs/2026-07-02-edgezero-state-and-nested-secrets-design.md b/docs/superpowers/specs/2026-07-02-edgezero-state-and-nested-secrets-design.md index 1abfe7b2..cc48e80a 100644 --- a/docs/superpowers/specs/2026-07-02-edgezero-state-and-nested-secrets-design.md +++ b/docs/superpowers/specs/2026-07-02-edgezero-state-and-nested-secrets-design.md @@ -411,6 +411,25 @@ A line-by-line review of every claim in §2–§7 against the actual code. **Bot - **[B, nit] §4.2/§4.4 dotted-path rendering:** the spec uses both `partners[*]` (static metadata) and `partners[3]` (runtime error). Have `SecretField::dotted_path()` render `ArrayEach` as `[*]` for the static path and per-index `[n]` at runtime; the existing `[{idx}]` convention (`extractor.rs:959`) already matches. - **[GEN, resolved] §6 GEN row** ("should this spec live in edgezero or trusted-server?") is now answered — it lives at `docs/superpowers/specs/` in the edgezero repo. +### Second-pass blockers (must fold into Workstream B before it is plan-ready) + +A follow-up review found additional issues the first pass missed. All verified against `origin/main` @ `42843b1`. + +- **[B, BLOCKER] An active CI guard forbids the exact nesting this spec introduces.** `crates/edgezero-cli/src/bin/check_no_nested_app_config.rs` (spec 10.2.1) detects any `AppConfig`-derived struct used as a field inside another `AppConfig`-derived struct — unwrapping `Option`/`Vec`/`Box`/`Rc`/`Arc`/tuples/arrays — and CI runs it as "Nested AppConfig audit" (`.github/workflows/test.yml:58`, scanning `examples/app-demo` + `crates/edgezero-cli/src/templates`). Workstream B's whole premise (a sub-struct that itself derives `AppConfig`, opted-in via `#[app_config(nested)]`) is a violation today. The guard must be **inverted**, not deleted: "nested `AppConfig` is allowed **iff** the containing field carries `#[app_config(nested)]`." This is a required plan item, and it changes the audit binary + its tests. +- **[B, BLOCKER] Optional secrets need metadata.** §4.3 accepts `Option` and §4.4 skips an absent optional, but §4.2's `SecretField` carries only `{ kind, path }`. The runtime walk and the CLI reflections cannot distinguish "required leaf missing → error" from "optional leaf absent → skip" without an explicit flag. Add `optional: bool` (or fold it into `SecretKind`). +- **[B, BLOCKER] The path model is internally inconsistent — commit to owned segments.** §4.2 shows `path: &'static [SecretPathSegment]` while §4.3/B-3 conclude nested recursion must build owned/`Cow` paths (the only viable cross-crate lowering). These contradict. Resolve by making paths owned: `secret_fields() -> Vec` with `path: Vec` (or `Cow<'static, _>`). Update §4.2 to match B-3 rather than leaving a `&'static` shape it cannot satisfy. +- **[B, HIGH] Register the helper attribute.** `crates/edgezero-macros/src/lib.rs:20` is `#[proc_macro_derive(AppConfig, attributes(secret))]` — it must become `attributes(secret, app_config)` or the `#[app_config(nested)]` opt-in fails to parse. +- **[B, HIGH] `TypedSecretEntry.field_name` is a borrowed `&'static`/`&'entry str`.** `crates/edgezero-adapter/src/registry.rs:178` borrows the field name; dotted/array paths (`partners[3].api_key`) are computed strings with no `'static` backing. Make `field_name` owned (`String`/`Cow<'static, str>`) or carry an owned label alongside the borrowed entry, and thread that through the Spin collision check. +- **[B, HIGH] Enforce container `rename_all` on nested-only parents.** `crates/edgezero-macros/src/app_config.rs:75` gates the `rename_all` guard on `!annotations.is_empty()` (direct `#[secret]` fields only). A parent whose secrets live entirely in `#[app_config(nested)]` children has empty direct annotations, so a container `#[serde(rename_all=…)]` there would silently desync the emitted Rust field-path segment from the serialized key. Extend the guard to also fire when the struct has any `#[app_config(nested)]` field. +- **[B, HIGH] Decide array scope now, not later.** The problem statement and examples (§4.1, `partners[*].api_key`) include array secrets, yet B-1 defers `ArrayEach`. Do not write an object-only plan unless trusted-server's `Settings` audit confirms **no** secret leaves inside arrays; otherwise include `ArrayEach` from the start. + +### Go / No-Go + +Split into **two independent implementation plans** (matching §5's "A and B are independent"): + +- **Workstream A (`State`) is plan-ready now** — no blockers; router insertion before `RequestContext::new` (`router.rs:267`) and the generic `#[action]` `FromRequest` call (`action.rs:87`) both fit as designed. +- **Workstream B (nested/array secrets) should start only after** folding in the second-pass blockers above — especially: inverting the nested-AppConfig CI guard, `optional` metadata, owned path segments, helper-attribute registration, owned `TypedSecretEntry` labels, nested-only `rename_all` enforcement, and a settled array scope. + ### Baseline note Local `main` (`b298bc1`, "Extract run_typed_preflight…") is a **broken divergent tip**: it deletes `config.rs` (3365 lines) while leaving `mod config;`/`pub use config::…` in `lib.rs`, so `edgezero-cli` does not compile there, and it is not an ancestor of `origin/main`. `run_typed_preflight` exists nowhere in the tree — that refactor's new files were never committed. All §4.5 claims were therefore verified against `origin/main` (`42843b1`), where `config.rs` is intact; the spec's function names are correct against that tree. From c208524842ab1ac87dbbb81837d11443e8be75cf Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Thu, 2 Jul 2026 22:12:19 -0700 Subject: [PATCH 32/74] =?UTF-8?q?docs:=20implementation=20plans=20for=20St?= =?UTF-8?q?ate=20+=20nested=20secrets;=20reconcile=20spec=20with=20?= =?UTF-8?q?=C2=A78?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../2026-07-02-edgezero-nested-secrets.md | 1871 +++++++++++++++++ .../2026-07-02-edgezero-state-extractor.md | 789 +++++++ ...dgezero-state-and-nested-secrets-design.md | 20 +- 3 files changed, 2671 insertions(+), 9 deletions(-) create mode 100644 docs/superpowers/plans/2026-07-02-edgezero-nested-secrets.md create mode 100644 docs/superpowers/plans/2026-07-02-edgezero-state-extractor.md diff --git a/docs/superpowers/plans/2026-07-02-edgezero-nested-secrets.md b/docs/superpowers/plans/2026-07-02-edgezero-nested-secrets.md new file mode 100644 index 00000000..4f9cec7f --- /dev/null +++ b/docs/superpowers/plans/2026-07-02-edgezero-nested-secrets.md @@ -0,0 +1,1871 @@ +# EdgeZero Nested / Array `#[secret]` Support 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:** Let `#[secret]` fields live below the config root — nested inside sub-structs and inside `Vec<_>` elements — resolved at runtime by a **field path** instead of a single top-level name. + +**Architecture:** Reshape secret metadata from a flat `SecretField { kind, name: &'static str }` into path-qualified, **owned** `SecretField { kind, path: Vec, optional: bool }`, and change `AppConfigMeta` from an associated `const SECRET_FIELDS` to `fn secret_fields() -> Vec` so the derive can recurse across crates (a parent prepends its field/`ArrayEach` segment onto each child's `secret_fields()`). The runtime `secret_walk`, the push-time `validate_excluding_secrets`, and the CLI reflections all become path navigators. A new `#[app_config(nested)]` field opt-in drives recursion, and the existing "no nested AppConfig" CI guard is **inverted** to allow nesting only on opted-in fields. Arrays (`ArrayEach`) are included from day one. + +**Tech Stack:** Rust 1.95, edition 2021. `edgezero-core` (metadata + runtime walk + push validation), `edgezero-macros` (derive recursion + attribute parsing), `edgezero-cli` (path-aware validate/push/diff + inverted CI guard binary), `edgezero-adapter` (owned secret-entry label), `edgezero-adapter-spin` (collision check over paths). `serde_json` / `toml` / `validator` 0.20. + +## Base branch + +- **Implementation branch:** `worktree-state-nested-secrets-spec-review`, with **PR #300 already merged** (merge commit `051a9ad`). PR #300 touches none of this plan's files, so every line number below (verified live on the merged tree) is identical to `origin/main @ 42843b1`. +- Shares its branch with the sibling **`State`** plan (`2026-07-02-edgezero-state-extractor.md`). The only shared file is `crates/edgezero-core/src/extractor.rs`, edited in a disjoint region (that plan appends an extractor; this plan rewrites `secret_walk` at `extractor.rs:827`). Either order is safe. +- **This plan is the source of truth and supersedes the spec's pre-correction shapes.** The spec's §2–§7 still show stale forms that §8 (and its second-pass blockers) overrode: borrowed `&'static` path segments without `optional` (spec §4.2 / line 257), array scope framed as "open/defer" (spec B-1 / line 274), and a `State` crate-root re-export (spec §7 / line 379). Where the spec body and this plan disagree, follow the plan. (The spec body should be reconciled with §8 separately so implementers aren't handed contradictory instructions.) + +## Global Constraints + +- **Rust 1.95.0**, edition 2021, resolver 2. +- **WASM-compat:** no Tokio; `#[async_trait(?Send)]`; async tests use `futures::executor::block_on`. +- **HTTP facade:** never import `http` directly (not relevant to most of this plan, but holds). +- **Colocate tests** in `#[cfg(test)] mod tests`. +- **`validator` is 0.20**: `ValidationErrors::errors()` → `&HashMap, ValidationErrorsKind>`; `errors_mut()` → `&mut` of the same. `ValidationErrorsKind::{Field(Vec), Struct(Box), List(BTreeMap>)}`. Keys are `Cow<'static, str>` and `.as_ref()` gives `&str`. +- **Push/runtime validation split is sacred:** push time uses `validate_excluding_secrets` (secret leaves hold key NAMES, so their per-field validators are skipped); runtime uses `cfg.validate()` after `secret_walk` has resolved values. Nesting must preserve this for nested/array secrets too. +- **`#[secret(store_ref)]` (`StoreRef` kind) leaves are always skipped** by the walk and kept by push validation (their value is a store id, identical at push and runtime). +- **`store_ref` sibling scoping rule:** a `KeyInNamedStore { store_ref_field }` leaf resolves its `store_ref_field` sibling **within the same innermost parent object** as the secret leaf. +- **Array metadata is `[*]`; runtime errors are `[n]`.** `SecretField::dotted_path()` renders `ArrayEach` as `[*]` (static form); `secret_walk` builds `[n]` per element at runtime. Matches the existing `format!("{path}[{idx}]")` convention at `extractor.rs:959`. +- **CI gates (all must pass):** + 1. `cargo fmt --all -- --check` + 2. `cargo clippy --workspace --all-targets --all-features -- -D warnings` + 3. `cargo test --workspace --all-targets` + 4. `cargo check --workspace --all-targets --features "fastly cloudflare spin"` + 5. `cargo check -p edgezero-adapter-spin --target wasm32-wasip2 --features spin` + 6. **Nested AppConfig audit** (`.github/workflows/test.yml:58`): `cargo run -q --bin check_no_nested_app_config --features nested-app-config-check -- examples/app-demo crates/edgezero-cli/src/templates` + +--- + +## File Structure + +| File | Responsibility | Change | +| ---- | -------------- | ------ | +| `crates/edgezero-core/src/app_config.rs` | `SecretPathSegment` enum, reshaped owned `SecretField { kind, path, optional }`, `AppConfigMeta::secret_fields()` fn (was const), `SecretField::dotted_path()`, nested/list-aware `validate_excluding_secrets` | Modify | +| `crates/edgezero-core/src/extractor.rs` | Path-navigating `secret_walk` (Field descent + `ArrayEach` + optional skip + `KeyInNamedStore` sibling-in-parent + dotted runtime error path) | Modify (`secret_walk` region) | +| `crates/edgezero-macros/src/lib.rs` | Register the `app_config` helper attribute | Modify (1 line) | +| `crates/edgezero-macros/src/app_config.rs` | Emit `fn secret_fields()` with owned path segments + `optional`; parse `#[app_config(nested)]`; recurse (object → `Field`, `Vec` → `Field` + `ArrayEach`); relax to accept `Option`; extend `rename_all` guard to nested-only parents; assert nested types derive `AppConfig` | Modify | +| `crates/edgezero-macros/tests/ui/*` + `tests/app_config_derive.rs` | UI + happy-path derive coverage for nesting/arrays/optional | Modify / create | +| `crates/edgezero-cli/src/config.rs` | Path-aware secret reflection in `run_adapter_typed_checks` + `typed_secret_checks` (TOML path navigator); flip all test `impl AppConfigMeta` const → fn | Modify | +| `crates/edgezero-adapter/src/registry.rs` | `TypedSecretEntry.field_name` → owned `String` (dotted label) | Modify | +| `crates/edgezero-adapter-spin/src/cli.rs` | Collision check consumes owned label (logic unchanged — keys on value) | Modify | +| `crates/edgezero-cli/src/bin/check_no_nested_app_config.rs` | Invert: nested `AppConfig` allowed **iff** the field carries `#[app_config(nested)]`; add tests | Modify | +| `docs/guide/configuration.md` | Document nested/array `#[secret]`, the opt-in, sibling scoping, dotted-path errors | Modify | + +**Task ordering rationale:** Task 1 is the atomic metadata reshape — it changes the trait shape and every in-tree consumer in one green step, but only ever produces **length-1** paths, so behavior is byte-identical to today. It also builds the *full* Field+`ArrayEach` navigators up front, so once the macro (Task 4) emits longer paths, nesting "just works." Tasks 2–3 add runtime + push path-awareness (still exercised only by hand-written multi-segment test fixtures). Task 4 makes the derive emit nested/array metadata. Task 5 inverts the CI guard. Task 6 makes the CLI path-aware. Task 7 is the end-to-end proof + docs. + +--- + +## Task 1: Reshape secret metadata to owned, path-qualified fields + +This is the foundation: new types, `const`→`fn` trait, `dotted_path()`, and updates to **every** in-tree consumer so the workspace stays green with identical top-level behavior (all paths length 1). + +**Files:** +- Modify: `crates/edgezero-core/src/app_config.rs` (types, trait, `dotted_path`, and `validate_excluding_secrets` — flat behavior for now; nested pruning lands in Task 3) +- Modify: `crates/edgezero-core/src/extractor.rs` (`secret_walk` signature stays; body reads `C::secret_fields()` — full navigator lands in Task 2; for Task 1 keep top-level behavior but via the new shape) +- Modify: `crates/edgezero-cli/src/config.rs` (test `impl AppConfigMeta` const→fn; consumers read `field.path`/`dotted_path()` at length 1) +- Modify: `crates/edgezero-macros/src/app_config.rs` (emit `fn secret_fields()` with length-1 `Field` paths + `optional: false`) +- Modify: `crates/edgezero-adapter/src/registry.rs` (`field_name: String`) +- Modify: `crates/edgezero-adapter-spin/src/cli.rs` (consume owned label) + +**Interfaces produced (relied on by all later tasks):** +```rust +// crates/edgezero-core/src/app_config.rs +pub enum SecretPathSegment { Field(std::borrow::Cow<'static, str>), ArrayEach } +pub struct SecretField { pub kind: SecretKind, pub path: Vec, pub optional: bool } +impl SecretField { pub fn dotted_path(&self) -> String; } // Field→"a.b", ArrayEach→"[*]" +pub trait AppConfigMeta { fn secret_fields() -> Vec; } // was: const SECRET_FIELDS +// SecretKind is UNCHANGED (still Copy, store_ref_field: &'static str). +``` + +- [ ] **Step 1: Write the failing metadata unit tests** + +Append to `crates/edgezero-core/src/app_config.rs`'s `#[cfg(test)] mod tests` (module starts near `app_config.rs:599`; it already imports `SecretField`, `SecretKind`): + +```rust + #[test] + fn dotted_path_renders_nested_and_array_segments() { + use super::{SecretField, SecretKind, SecretPathSegment::*}; + use std::borrow::Cow; + + let top = SecretField { + kind: SecretKind::KeyInDefault, + path: vec![Field(Cow::Borrowed("api_token"))], + optional: false, + }; + assert_eq!(top.dotted_path(), "api_token"); + + let nested = SecretField { + kind: SecretKind::KeyInDefault, + path: vec![ + Field(Cow::Borrowed("integrations")), + Field(Cow::Borrowed("datadome")), + Field(Cow::Borrowed("server_side_key")), + ], + optional: false, + }; + assert_eq!(nested.dotted_path(), "integrations.datadome.server_side_key"); + + let array = SecretField { + kind: SecretKind::KeyInDefault, + path: vec![ + Field(Cow::Borrowed("partners")), + ArrayEach, + Field(Cow::Borrowed("api_key")), + ], + optional: false, + }; + assert_eq!(array.dotted_path(), "partners[*].api_key"); + } +``` + +- [ ] **Step 2: Run it (fails to compile)** + +Run: `cargo test -p edgezero-core --lib dotted_path_renders 2>&1 | tail -15` +Expected: FAIL — `SecretPathSegment` / `SecretField.path` / `dotted_path` do not exist. + +- [ ] **Step 3: Reshape the metadata types + trait in `app_config.rs`** + +Add `use std::borrow::Cow;` near the top of `crates/edgezero-core/src/app_config.rs` (with the other `use`s). Replace the `AppConfigMeta` trait (`app_config.rs:34-37`), the `SecretField` struct (`app_config.rs:41-48`), and add `SecretPathSegment` + `dotted_path`. `SecretKind` (`app_config.rs:53-69`) is **unchanged**. + +```rust +/// One segment of a [`SecretField`] path. +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum SecretPathSegment { + /// An object key — a Rust field name, verbatim (no `serde(rename)`). + Field(Cow<'static, str>), + /// Every element of an array/`Vec` at this position. + ArrayEach, +} + +/// One field's worth of secret-annotation metadata. +/// +/// The `path` locates the secret leaf from the config root. A top-level +/// scalar has a length-1 path `[Field("api_token")]`. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct SecretField { + /// Which secret-store resolution this field participates in. + pub kind: SecretKind, + /// Path from the config root to the secret leaf. + pub path: Vec, + /// `true` for `#[secret]` on `Option`: an absent leaf is + /// skipped by the runtime walk instead of erroring. + pub optional: bool, +} + +impl SecretField { + /// Human-readable dotted path for error messages and CLI output. + /// `ArrayEach` renders as `[*]` (the static form); the runtime walk + /// renders per-index `[n]` as it descends. + #[must_use] + pub fn dotted_path(&self) -> String { + let mut out = String::new(); + for segment in &self.path { + match segment { + SecretPathSegment::Field(name) => { + if !out.is_empty() { + out.push('.'); + } + out.push_str(name); + } + SecretPathSegment::ArrayEach => out.push_str("[*]"), + } + } + out + } +} + +/// Per-field metadata emitted by `#[derive(AppConfig)]`. `config validate` +/// / `config push` and the runtime secret walk reflect over this to gate +/// secret-aware behaviour. +pub trait AppConfigMeta { + /// Every `#[secret]` / `#[secret(store_ref)]` leaf on the struct, + /// including those reached through `#[app_config(nested)]` children, + /// each carrying its full path from this struct's root. + fn secret_fields() -> Vec; +} +``` + +Note: `SecretField` and `SecretPathSegment` are **no longer `Copy`** (they own a `Vec`/`Cow`). This is intentional per §8 [B, BLOCKER]. `SecretKind` stays `Copy`. + +- [ ] **Step 4: Run the metadata test (passes)** + +Run: `cargo test -p edgezero-core --lib dotted_path_renders 2>&1 | tail -15` +Expected: PASS. (The crate will not fully build yet — consumers still reference the old shape. Fix them in the next steps.) + +- [ ] **Step 5: Update `validate_excluding_secrets` to the new shape (flat behavior preserved)** + +In `crates/edgezero-core/src/app_config.rs:204-226`, the loop currently does `bag.remove(field.name)`. For Task 1, keep flat removal but source the key from the length-1 path. (Task 3 replaces this with nested/list-aware pruning.) Change the loop body: + +```rust + let bag = errors.errors_mut(); + for field in C::secret_fields() { + if matches!(field.kind, SecretKind::StoreRef) { + continue; // store_id field; validator stays + } + // Task 1: flat removal by the first path segment (length-1 paths only + // exist until the derive emits nesting). Task 3 makes this nested-aware. + if let Some(SecretPathSegment::Field(name)) = field.path.first() { + bag.remove(name.as_ref()); + } + } +``` + +Add `use SecretPathSegment` access (it's in the same module, reference as `SecretPathSegment::Field`). + +- [ ] **Step 6: Update `secret_walk` to the new shape (top-level behavior preserved)** + +In `crates/edgezero-core/src/extractor.rs:827-894`, change the import at `extractor.rs:8` to also bring in the path segment, and change the loop to source the key/field name from the length-1 path. (Task 2 replaces the whole body with a recursive navigator.) Minimal Task-1 change: replace `for field in C::SECRET_FIELDS` with `for field in C::secret_fields()`, and replace each `field.name` use with a locally computed `let leaf = field.dotted_path();` for error hints and `let leaf_key = match field.path.last() { Some(SecretPathSegment::Field(n)) => n.as_ref(), _ => /* length-1 guaranteed in Task 1 */ };` for the `data_obj.get(...)`/`insert(...)` calls. Concretely, at the top of the loop: + +```rust + for field in C::secret_fields() { + // Task 1: top-level only — the leaf is the single Field segment. + let leaf_key = match field.path.last() { + Some(SecretPathSegment::Field(name)) => name.clone().into_owned(), + _ => { + return Err(EdgeError::internal(anyhow::anyhow!( + "secret field `{}` has no field leaf", + field.dotted_path() + ))) + } + }; + let hint = field.dotted_path(); + // ... below, replace `field.name` (get/insert key) with `leaf_key.as_str()` + // and `field.name.to_owned()` (error path arg) with `hint.clone()`. +``` + +Update `crates/edgezero-core/src/extractor.rs:8` from `use crate::app_config::{AppConfigMeta, SecretKind};` to `use crate::app_config::{AppConfigMeta, SecretKind, SecretPathSegment};`. Apply the `leaf_key`/`hint` substitution throughout the existing loop body (the `data_obj.get(field.name)`, the `data_obj.insert(field.name.to_owned(), ...)`, and every `field.name.to_owned()` error arg become `leaf_key.as_str()` / `hint.clone()` respectively; `store_ref_field` handling is unchanged — it's still a top-level sibling in Task 1). + +- [ ] **Step 7: Flip the emitter in `edgezero-macros/src/app_config.rs` to `fn` + length-1 paths** + +In `crates/edgezero-macros/src/app_config.rs`, change the per-entry emission (`app_config.rs:128-150`) and the impl block (`app_config.rs:152-166`). The entries currently emit `SecretField { name: #name_lit, kind: #kind_tokens }`; change to owned length-1 paths: + +```rust + let entries = annotations.iter().map(|annotation| { + let name_lit = annotation.name.to_string(); + let kind_tokens = match &annotation.kind { + SecretAnnotation::KeyInDefault => { + quote!(::edgezero_core::app_config::SecretKind::KeyInDefault) + } + SecretAnnotation::StoreRef => { + quote!(::edgezero_core::app_config::SecretKind::StoreRef) + } + SecretAnnotation::KeyInNamedStore { store_ref_field } => { + let lit = syn::LitStr::new(store_ref_field, Span::call_site()); + quote!(::edgezero_core::app_config::SecretKind::KeyInNamedStore { + store_ref_field: #lit + }) + } + }; + // Task 1: length-1 Field path, non-optional. Task 4 sets `optional` + // from Option and prepends nested/array segments. + quote! { + ::edgezero_core::app_config::SecretField { + kind: #kind_tokens, + path: ::std::vec![::edgezero_core::app_config::SecretPathSegment::Field( + ::std::borrow::Cow::Borrowed(#name_lit) + )], + optional: false, + } + } + }); +``` + +And the impl block (`app_config.rs:152-166`) — change the `const` to a `fn`: + +```rust + Ok(quote! { + #[automatically_derived] + impl #impl_generics ::edgezero_core::app_config::AppConfigMeta + for #struct_ident #type_generics #where_clause + { + fn secret_fields() -> ::std::vec::Vec<::edgezero_core::app_config::SecretField> { + ::std::vec![#(#entries),*] + } + } + + #[automatically_derived] + impl #impl_generics ::edgezero_core::app_config::AppConfigRoot + for #struct_ident #type_generics #where_clause + {} + }) +} +``` + +- [ ] **Step 8: Make `TypedSecretEntry.field_name` owned** + +In `crates/edgezero-adapter/src/registry.rs:174-198`, change `field_name: &'entry str` to owned `String`. **Keep `new`'s param generic as `impl Into`** so the 7 existing `&str`-literal call sites in the Spin tests (`adapter-spin/src/cli.rs:1292/1307/1327/1328/1344/1345/1357/1389/1390`) keep compiling unchanged, while the CLI callers pass owned dotted labels: + +```rust +#[non_exhaustive] +pub struct TypedSecretEntry<'entry> { + /// Dotted secret-field path label (e.g. `"partners[3].api_key"`). + pub field_name: String, + /// Blob value — i.e. the secret-store KEY NAME. + pub key_value: &'entry str, + /// Logical secret-store id this key targets. + pub store_id: &'entry str, +} + +impl<'entry> TypedSecretEntry<'entry> { + #[must_use] + #[inline] + pub fn new( + store_id: &'entry str, + field_name: impl Into, + key_value: &'entry str, + ) -> Self { + Self { + field_name: field_name.into(), + key_value, + store_id, + } + } +} +``` + +Because `&str: Into` and `String: Into`, no `TypedSecretEntry::new` call site needs editing for the signature change (only the CLI callers change *what* they pass — a dotted label — in Task 6). + +- [ ] **Step 9: Update the Spin collision check to the owned label** + +In `crates/edgezero-adapter-spin/src/cli.rs:514-552`, the logic keys on `entry.key_value` (the secret value) — unchanged. Only the `seen` map value type shifts from `&str` (borrowing a `&'static` name) to a borrow of the owned `String`. Change the map value binding so it borrows `entry.field_name`: + +```rust + let mut seen: HashMap = HashMap::with_capacity(entries.len()); + for entry in entries { + let spin_var = entry.key_value.to_ascii_lowercase(); + if !is_valid_spin_key(&spin_var) { + let reason = spin_key_rule_violation(&spin_var); + return Err(format!( + "`#[secret]` field `{field}` value `{value}` translates to Spin variable `{spin_var}`, which is not a valid Spin variable name. {reason}. Pick a `#[secret]` value that conforms.", + field = entry.field_name, + value = entry.key_value, + )); + } + if let Some(prev_field) = seen.insert(spin_var.clone(), entry.field_name.as_str()) { + return Err(format!( + "Spin variable `{spin_var}` would receive values from BOTH `#[secret]` field `{prev_field}` AND `#[secret]` field `{this_field}`; Spin's flat variable namespace cannot disambiguate them. Pick distinct `#[secret]` values whose lowercased forms differ.", + this_field = entry.field_name, + )); + } + } + Ok(()) +``` + +(Only two edits vs. today: `entry.field_name` is now a `String` so it interpolates the same in `format!`, and the `seen.insert(..., entry.field_name)` becomes `entry.field_name.as_str()`.) + +- [ ] **Step 10: Update the CLI consumers + all test `impl AppConfigMeta` sites** + +In `crates/edgezero-cli/src/config.rs`, update the two runtime consumers to the new shape (still flat/length-1 in Task 1 — full path navigation lands in Task 6): + +- `run_adapter_typed_checks` (`config.rs:1295-1333`): change `for field in C::SECRET_FIELDS` → `for field in C::secret_fields()`; compute `let leaf = field.dotted_path();` and a flat lookup key `let key = match field.path.last() { Some(SecretPathSegment::Field(n)) => n.as_ref(), _ => continue };`; replace `raw_table.get(field.name)` with `raw_table.get(key)`; replace `TypedSecretEntry::new(store_id, field.name, key_value)` with `TypedSecretEntry::new(store_id, leaf.clone(), key_value)`. `store_ref_field` lookups are unchanged (still `raw_table.get(store_ref_field)`, top-level in Task 1). +- `typed_secret_checks` (`config.rs:1339-1412`): same `for field in C::secret_fields()`; compute `let leaf = field.dotted_path();` and `let key = /* leaf field name as above */;`; replace `raw_table.get(field.name)` with `raw_table.get(key)`; replace every `field.name` in error messages with `leaf`. + +Add `SecretPathSegment` to the import at `config.rs:28` (`use edgezero_core::app_config::{AppConfigMeta, SecretKind, SecretPathSegment};`). + +Then flip **every hand-written `impl AppConfigMeta`** from `const SECRET_FIELDS: &'static [SecretField] = &[ ... ];` to `fn secret_fields() -> Vec { vec![ ... ] }`, converting each `SecretField { name: "x", kind: K }` literal to `SecretField { kind: K, path: vec![SecretPathSegment::Field(Cow::Borrowed("x"))], optional: false }`. Sites (all in `#[cfg(test)]`), verified line numbers: + + - `crates/edgezero-core/src/app_config.rs`: `:620`, `:1106`, `:1138`, `:1156`, `:1181`, `:1201` + - `crates/edgezero-core/src/extractor.rs`: `:1049`, `:1062`, `:2329`, `:2370` + - `crates/edgezero-cli/src/config.rs`: `:1649`, `:1866`, `:2204`, `:2746`, `:3315` + +Worked example — `app_config.rs:1156-1161` (empty→one-field) currently: + +```rust + impl AppConfigMeta for Fixture { + const SECRET_FIELDS: &'static [SecretField] = + &[SecretField { name: "api_token", kind: SecretKind::KeyInDefault }]; + } +``` + +becomes: + +```rust + impl AppConfigMeta for Fixture { + fn secret_fields() -> Vec { + vec![SecretField { + kind: SecretKind::KeyInDefault, + path: vec![SecretPathSegment::Field(std::borrow::Cow::Borrowed("api_token"))], + optional: false, + }] + } + } +``` + +For each such test module, add `use edgezero_core::app_config::SecretPathSegment;` (or `use super::SecretPathSegment;` inside core) and `use std::borrow::Cow;` if not already present. Empty-array impls become `fn secret_fields() -> Vec { vec![] }`. Assertions that read `Type::SECRET_FIELDS` (e.g. the derive test in `crates/edgezero-macros/tests/app_config_derive.rs:71-126` and app-demo `config.rs:126`) change to `Type::secret_fields()` and compare against the new shape — update those in this step too (app-demo assertion detail below). + +App-demo assertion at `examples/app-demo/crates/app-demo-core/src/config.rs:124-138` currently maps `AppDemoConfig::SECRET_FIELDS` `.map(|f| (f.name, f.kind))`. Change to: + +```rust + let by_path: Vec<(String, SecretKind)> = AppDemoConfig::secret_fields() + .into_iter() + .map(|f| (f.dotted_path(), f.kind)) + .collect(); + assert_eq!( + by_path, + vec![ + ("api_token".to_owned(), SecretKind::KeyInDefault), + ("vault".to_owned(), SecretKind::StoreRef), + ], + ); +``` + +The derive assertions in `app_config_derive.rs:71-126` change from comparing `SECRET_FIELDS` slices to comparing `secret_fields()` `Vec`s against `SecretField { kind, path: vec![SecretPathSegment::Field(Cow::Borrowed("..."))], optional: false }` literals (or, more simply, assert `dotted_path()` + `kind` + `optional` per entry). + +- [ ] **Step 11: Build + test the whole workspace (green, behavior identical)** + +Run: `cargo build --workspace --all-targets 2>&1 | tail -20` +Expected: compiles. + +Run: `cargo test --workspace --all-targets 2>&1 | tail -25` +Expected: PASS — all existing secret tests still green (top-level behavior unchanged). Also run app-demo: + +Run: `(cd examples/app-demo && cargo test 2>&1 | tail -15)` +Expected: PASS (`secret_fields_metadata_matches_declarations`, round-trip, config-flow). + +- [ ] **Step 12: Lint + commit** + +Run: `cargo fmt --all && cargo clippy --workspace --all-targets --all-features -- -D warnings 2>&1 | tail -15` +Expected: clean. + +```bash +git add crates/edgezero-core/src/app_config.rs crates/edgezero-core/src/extractor.rs \ + crates/edgezero-macros/src/app_config.rs crates/edgezero-cli/src/config.rs \ + crates/edgezero-adapter/src/registry.rs crates/edgezero-adapter-spin/src/cli.rs \ + examples/app-demo/crates/app-demo-core/src/config.rs \ + crates/edgezero-macros/tests/app_config_derive.rs +git commit -m "refactor(secrets): owned path-qualified SecretField + AppConfigMeta::secret_fields()" +``` + +--- + +## Task 2: Path-navigating runtime `secret_walk` (nesting + arrays + optional) + +Replace `secret_walk`'s top-level loop with a recursive navigator that descends `Field`/`ArrayEach` segments, resolves the leaf, skips absent optionals, and reports the dotted runtime path (`[n]` per index) on error. Exercised via hand-written multi-segment `impl AppConfigMeta` fixtures. + +**Files:** +- Modify: `crates/edgezero-core/src/extractor.rs` (`secret_walk` at `:827`; add tests to the `#[cfg(test)] mod tests`) + +**Interfaces:** +- Consumes: `SecretField { kind, path, optional }`, `SecretPathSegment` (Task 1); `SecretKind` (unchanged); `ctx.secret_store_default()` / `ctx.secret_store(id)` / `bound.require_str(key)` / `map_secret_error` (existing, `extractor.rs:896`); `first_violating_field`'s `[{idx}]` convention. +- Produces: a `secret_walk::` that resolves nested/array leaves. Consumed by Task 7 (E2E). + +- [ ] **Step 1: Write failing nested/array `secret_walk` tests** + +Append to `crates/edgezero-core/src/extractor.rs` `#[cfg(test)] mod tests`. Mirror the existing `app_config_secret_walk_resolves_key_in_default_store` test (`extractor.rs:2170`) for store setup (`InMemorySecretStore`, `StoreRegistry`, inserting the secret registry into request extensions, building `ctx`). Add three fixtures + tests: + +```rust + // Nested object leaf: integrations.datadome.server_side_key + struct NestedCfg; + impl AppConfigMeta for NestedCfg { + fn secret_fields() -> Vec { + vec![SecretField { + kind: SecretKind::KeyInDefault, + path: vec![ + SecretPathSegment::Field(std::borrow::Cow::Borrowed("integrations")), + SecretPathSegment::Field(std::borrow::Cow::Borrowed("datadome")), + SecretPathSegment::Field(std::borrow::Cow::Borrowed("server_side_key")), + ], + optional: false, + }] + } + } + + #[test] + fn secret_walk_resolves_nested_object_leaf() { + let ctx = ctx_with_default_secret_store("dd_key", "resolved-dd"); // helper: see below + let mut data = serde_json::json!({ + "integrations": { "datadome": { "server_side_key": "dd_key" } } + }); + block_on(secret_walk::(&ctx, &mut data)).expect("walk"); + assert_eq!( + data["integrations"]["datadome"]["server_side_key"], + serde_json::json!("resolved-dd") + ); + } + + // Array leaf: partners[*].api_key + struct ArrayCfg; + impl AppConfigMeta for ArrayCfg { + fn secret_fields() -> Vec { + vec![SecretField { + kind: SecretKind::KeyInDefault, + path: vec![ + SecretPathSegment::Field(std::borrow::Cow::Borrowed("partners")), + SecretPathSegment::ArrayEach, + SecretPathSegment::Field(std::borrow::Cow::Borrowed("api_key")), + ], + optional: false, + }] + } + } + + #[test] + fn secret_walk_resolves_each_array_element() { + let ctx = ctx_with_default_secret_store_map(&[("k0", "v0"), ("k1", "v1")]); + let mut data = serde_json::json!({ + "partners": [ { "api_key": "k0" }, { "api_key": "k1" } ] + }); + block_on(secret_walk::(&ctx, &mut data)).expect("walk"); + assert_eq!(data["partners"][0]["api_key"], serde_json::json!("v0")); + assert_eq!(data["partners"][1]["api_key"], serde_json::json!("v1")); + } + + // Nested KeyInNamedStore: vaulted.token resolves against the store named by + // its SIBLING `vaulted.vault` (the sibling-in-innermost-parent scoping rule). + struct NamedStoreCfg; + impl AppConfigMeta for NamedStoreCfg { + fn secret_fields() -> Vec { + vec![SecretField { + kind: SecretKind::KeyInNamedStore { store_ref_field: "vault" }, + path: vec![ + SecretPathSegment::Field(std::borrow::Cow::Borrowed("vaulted")), + SecretPathSegment::Field(std::borrow::Cow::Borrowed("token")), + ], + optional: false, + }] + } + } + + #[test] + fn secret_walk_resolves_nested_named_store_via_sibling_in_parent() { + // A registry whose store id "named" maps key "tok_key" -> "TOK". + let ctx = ctx_with_named_secret_store("named", "tok_key", "TOK"); + let mut data = serde_json::json!({ + "vaulted": { "token": "tok_key", "vault": "named" } + }); + block_on(secret_walk::(&ctx, &mut data)).expect("walk"); + assert_eq!(data["vaulted"]["token"], serde_json::json!("TOK")); + // The store_ref sibling is left intact (it names a store, not a secret). + assert_eq!(data["vaulted"]["vault"], serde_json::json!("named")); + } + + #[test] + fn secret_walk_nested_named_store_missing_sibling_errors_with_dotted_path() { + let ctx = ctx_with_named_secret_store("named", "tok_key", "TOK"); + let mut data = serde_json::json!({ "vaulted": { "token": "tok_key" } }); // no `vault` + let err = block_on(secret_walk::(&ctx, &mut data)) + .expect_err("missing store_ref sibling"); + assert_eq!(err.status(), StatusCode::SERVICE_UNAVAILABLE); + assert!(err.to_string().contains("vaulted.token")); + } + + // Optional secret absent -> skipped (no error) + struct OptionalCfg; + impl AppConfigMeta for OptionalCfg { + fn secret_fields() -> Vec { + vec![SecretField { + kind: SecretKind::KeyInDefault, + path: vec![SecretPathSegment::Field(std::borrow::Cow::Borrowed("maybe_key"))], + optional: true, + }] + } + } + + #[test] + fn secret_walk_skips_absent_optional_leaf() { + let ctx = ctx_with_default_secret_store("unused", "unused"); + let mut data = serde_json::json!({ "greeting": "hi" }); // no maybe_key + block_on(secret_walk::(&ctx, &mut data)).expect("absent optional is fine"); + assert!(data.get("maybe_key").is_none()); + } + + #[test] + fn secret_walk_skips_null_optional_leaf() { + // serde serializes `Option::None` as JSON `null` (the key is present, + // not omitted). The walk must skip a null optional leaf, not error it. + let ctx = ctx_with_default_secret_store("unused", "unused"); + let mut data = serde_json::json!({ "maybe_key": null }); + block_on(secret_walk::(&ctx, &mut data)) + .expect("null optional is skipped, not treated as non-string"); + assert_eq!(data["maybe_key"], serde_json::json!(null)); // left untouched + } + + #[test] + fn secret_walk_missing_required_nested_leaf_errors_with_dotted_path() { + let ctx = ctx_with_default_secret_store("dd_key", "resolved-dd"); + let mut data = serde_json::json!({ "integrations": { "datadome": {} } }); + let err = block_on(secret_walk::(&ctx, &mut data)) + .expect_err("missing required nested leaf"); + assert_eq!(err.status(), StatusCode::SERVICE_UNAVAILABLE); // config_out_of_date -> 503 (error.rs:183) + assert!(err.to_string().contains("integrations.datadome.server_side_key")); + } +``` + +Add the small test helpers near the existing secret-walk test scaffolding (mirror `extractor.rs:2170`'s store construction). `ctx_with_default_secret_store(key, value)` builds an `InMemorySecretStore` mapping `default/{key}` → `value`, wraps it in a `StoreRegistry` with default id `"default"`, inserts the registry into a request's extensions, and returns the `RequestContext`. `ctx_with_default_secret_store_map(&[(k, v), ...])` is the multi-entry variant. `ctx_with_named_secret_store(store_id, key, value)` registers an `InMemorySecretStore` under `store_id` (mapping `{store_id}/{key}` → `value`) in the registry so `ctx.secret_store(store_id)` resolves — used by the `KeyInNamedStore` tests. (`EdgeError::config_out_of_date` → `StatusCode::SERVICE_UNAVAILABLE` per `error.rs:183`, confirmed.) + +- [ ] **Step 2: Run (fails)** + +Run: `cargo test -p edgezero-core --lib secret_walk_ 2>&1 | tail -25` +Expected: FAIL — nested/array data is not navigated (current walk only reads/writes top-level keys); missing-leaf message lacks the dotted path. + +- [ ] **Step 3: Rewrite `secret_walk` as a recursive navigator** + +Replace the body of `secret_walk` (`crates/edgezero-core/src/extractor.rs:827-894`) with a path navigator. Keep the signature (`async fn secret_walk(ctx: &RequestContext, data: &mut serde_json::Value) -> Result<(), EdgeError> where C: AppConfigMeta`). New body: + +```rust + for field in C::secret_fields() { + resolve_secret_field(ctx, data, &field, &field.path, String::new()).await?; + } + Ok(()) +} + +/// Recursively descend `remaining` path segments from `node`, resolving the +/// secret leaf(s). `rendered` is the dotted path so far (with concrete `[n]` +/// indices) for error hints. +fn resolve_secret_field<'a>( + ctx: &'a RequestContext, + node: &'a mut serde_json::Value, + field: &'a SecretField, + remaining: &'a [SecretPathSegment], + rendered: String, +) -> std::pin::Pin> + 'a>> { + Box::pin(async move { + match remaining.split_first() { + // Leaf reached: `node` is the PARENT object; the last Field is the key. + Some((SecretPathSegment::Field(name), rest)) if rest.is_empty() => { + resolve_leaf(ctx, node, field, name.as_ref(), &rendered).await + } + // Descend into an object key. + Some((SecretPathSegment::Field(name), rest)) => { + let next_rendered = join_field(&rendered, name.as_ref()); + match node.get_mut(name.as_ref()) { + // Absent optional subtree: key missing OR serialized as null. + None | Some(serde_json::Value::Null) if field.optional => Ok(()), + Some(child) => { + resolve_secret_field(ctx, child, field, rest, next_rendered).await + } + None => Err(EdgeError::config_out_of_date( + format!("missing or non-object value at `{next_rendered}`"), + next_rendered, + )), + } + } + // Iterate every array element. + Some((SecretPathSegment::ArrayEach, rest)) => { + let Some(items) = node.as_array_mut() else { + if field.optional { + return Ok(()); + } + return Err(EdgeError::config_out_of_date( + format!("expected an array at `{rendered}`"), + rendered, + )); + }; + for (idx, item) in items.iter_mut().enumerate() { + let indexed = format!("{rendered}[{idx}]"); + resolve_secret_field(ctx, item, field, rest, indexed).await?; + } + Ok(()) + } + None => Ok(()), + } + }) +} + +fn join_field(prefix: &str, name: &str) -> String { + if prefix.is_empty() { + name.to_owned() + } else { + format!("{prefix}.{name}") + } +} + +/// Resolve one leaf: `parent` is the innermost containing object; `key` is the +/// secret field name; `store_ref_field` (for `KeyInNamedStore`) is a sibling +/// within `parent`. +async fn resolve_leaf( + ctx: &RequestContext, + parent: &mut serde_json::Value, + field: &SecretField, + key: &str, + rendered_parent: &str, +) -> Result<(), EdgeError> { + if matches!(field.kind, SecretKind::StoreRef) { + return Ok(()); // store id, not a secret key + } + let leaf_path = join_field(rendered_parent, key); + + let Some(parent_obj) = parent.as_object_mut() else { + if field.optional { + return Ok(()); + } + return Err(EdgeError::config_out_of_date( + format!("expected an object containing `{key}` at `{rendered_parent}`"), + leaf_path, + )); + }; + + let key_name = match parent_obj.get(key) { + Some(serde_json::Value::String(k)) => k.clone(), + // An optional secret is absent when the key is MISSING *or* serialized + // as JSON `null`. serde emits `Option::None` as `null` (and `#[secret]` + // bans `skip_serializing_if`, so the key is never omitted), so both + // cases must skip — not just the missing-key case. + None | Some(serde_json::Value::Null) if field.optional => return Ok(()), + _ => { + return Err(EdgeError::config_out_of_date( + format!("missing or non-string value at `{leaf_path}`"), + leaf_path, + )) + } + }; + + let (bound, resolved_store_id) = match field.kind { + SecretKind::KeyInDefault => { + let bound = ctx.secret_store_default().ok_or_else(|| { + EdgeError::config_out_of_date( + format!("secret field `{leaf_path}` has kind KeyInDefault but no default secret store is registered"), + leaf_path.clone(), + ) + })?; + let id = bound.store_name().to_owned(); + (bound, id) + } + SecretKind::StoreRef => return Ok(()), + SecretKind::KeyInNamedStore { store_ref_field } => { + let store_id_str = parent_obj + .get(store_ref_field) + .and_then(|v| v.as_str()) + .ok_or_else(|| { + EdgeError::config_out_of_date( + format!("missing store_ref `{store_ref_field}` for secret field `{leaf_path}`"), + leaf_path.clone(), + ) + })? + .to_owned(); + let bound = ctx.secret_store(&store_id_str).ok_or_else(|| { + EdgeError::config_out_of_date( + format!("blob declared store_ref `{store_id_str}` but [stores.secrets] has no such id"), + leaf_path.clone(), + ) + })?; + (bound, store_id_str) + } + }; + + let secret = bound + .require_str(&key_name) + .await + .map_err(|err| map_secret_error(err, &leaf_path, &resolved_store_id, &key_name))?; + parent_obj.insert(key.to_owned(), serde_json::Value::String(secret)); + Ok(()) +} +``` + +Notes: +- `map_secret_error` (`extractor.rs:896`) takes `field_name: &str` — pass `&leaf_path`; no signature change needed. +- The recursion uses a boxed future (WASM-safe; matches the crate's `?Send` async style) because async fns can't recurse directly. +- `KeyInNamedStore` resolves `store_ref_field` in `parent_obj` — the **innermost** parent, satisfying the sibling scoping rule for nested secrets. + +- [ ] **Step 4: Run (passes)** + +Run: `cargo test -p edgezero-core --lib secret_walk_ 2>&1 | tail -25` +Expected: PASS (nested object, array-each, absent-optional-skip, missing-nested-dotted-error). Also confirm the pre-existing top-level walk tests (`extractor.rs:2170`, `:2198`) still pass: + +Run: `cargo test -p edgezero-core --lib app_config_secret_walk 2>&1 | tail -15` +Expected: PASS. + +- [ ] **Step 5: Lint + commit** + +Run: `cargo clippy -p edgezero-core --all-targets --all-features -- -D warnings 2>&1 | tail -15` + +```bash +git add crates/edgezero-core/src/extractor.rs +git commit -m "feat(secrets): path-navigating secret_walk (nested objects, arrays, optionals)" +``` + +--- + +## Task 3: Nested/list-aware `validate_excluding_secrets` + +Push time must skip the per-field validator of a nested/array secret leaf, whose failure lives under the parent inside `ValidationErrorsKind::Struct`/`List` — a flat `bag.remove(name)` cannot reach it (§8 [B, IMPORTANT]). Reuse the `first_violating_field` walk pattern (`extractor.rs:926`). + +**Files:** +- Modify: `crates/edgezero-core/src/app_config.rs` (`validate_excluding_secrets` at `:204`; add tests) + +**Interfaces:** +- Consumes: `C::secret_fields()`, `SecretField.path`, `SecretPathSegment`, validator 0.20 `ValidationErrors`/`ValidationErrorsKind`. +- Produces: nested-aware pruning. Consumed by Task 6 (CLI push over nested config) + Task 7. + +- [ ] **Step 1: Write failing nested-pruning tests** + +Append to `app_config.rs` `#[cfg(test)] mod tests`. Model on `validate_excluding_secrets_skips_secret_field_rules` (`app_config.rs:1148`) but with a nested struct fixture whose nested secret leaf has a failing validator (e.g. `#[validate(length(min = 100))]` on the key-name string, which is short at push time). Assert `validate_excluding_secrets` returns `Ok(())` (the nested secret's validator was pruned) while a **non-secret** nested failure still surfaces `Err`. + +```rust + #[test] + fn validate_excluding_secrets_prunes_nested_secret_leaf_validator() { + use validator::Validate; + + #[derive(Validate)] + struct Inner { + #[validate(length(min = 100))] + server_side_key: String, // holds a short KEY NAME at push time + } + #[derive(Validate)] + struct Outer { + #[validate(nested)] + integrations: Inner, + } + impl AppConfigMeta for Outer { + fn secret_fields() -> Vec { + vec![SecretField { + kind: SecretKind::KeyInDefault, + path: vec![ + SecretPathSegment::Field(std::borrow::Cow::Borrowed("integrations")), + SecretPathSegment::Field(std::borrow::Cow::Borrowed("server_side_key")), + ], + optional: false, + }] + } + } + + let cfg = Outer { + integrations: Inner { + server_side_key: "dd_key".to_owned(), // 6 chars < 100 + }, + }; + // The only failure is the nested secret leaf's validator -> pruned -> Ok. + assert!(validate_excluding_secrets(&cfg).is_ok()); + } + + #[test] + fn validate_excluding_secrets_keeps_nested_non_secret_failures() { + use validator::Validate; + + #[derive(Validate)] + struct Inner { + #[validate(length(min = 100))] + server_side_key: String, + #[validate(length(min = 100))] + note: String, // NON-secret, must still fail + } + #[derive(Validate)] + struct Outer { + #[validate(nested)] + integrations: Inner, + } + impl AppConfigMeta for Outer { + fn secret_fields() -> Vec { + vec![SecretField { + kind: SecretKind::KeyInDefault, + path: vec![ + SecretPathSegment::Field(std::borrow::Cow::Borrowed("integrations")), + SecretPathSegment::Field(std::borrow::Cow::Borrowed("server_side_key")), + ], + optional: false, + }] + } + } + + let cfg = Outer { + integrations: Inner { + server_side_key: "dd_key".to_owned(), + note: "short".to_owned(), + }, + }; + assert!(validate_excluding_secrets(&cfg).is_err()); // `note` still fails + } + + #[test] + fn validate_excluding_secrets_prunes_array_secret_leaf_keeps_siblings() { + use validator::Validate; + + #[derive(Validate)] + struct Partner { + #[validate(length(min = 100))] + api_key: String, // secret leaf (a key NAME at push time) + #[validate(length(min = 100))] + label: String, // NON-secret sibling + } + #[derive(Validate)] + struct Outer { + #[validate(nested)] + partners: Vec, + } + impl AppConfigMeta for Outer { + fn secret_fields() -> Vec { + vec![SecretField { + kind: SecretKind::KeyInDefault, + path: vec![ + SecretPathSegment::Field(std::borrow::Cow::Borrowed("partners")), + SecretPathSegment::ArrayEach, + SecretPathSegment::Field(std::borrow::Cow::Borrowed("api_key")), + ], + optional: false, + }] + } + } + + // Every element fails BOTH validators at push time. + let cfg = Outer { + partners: vec![ + Partner { api_key: "k0".to_owned(), label: "s".to_owned() }, + Partner { api_key: "k1".to_owned(), label: "s".to_owned() }, + ], + }; + // `api_key` (secret) pruned from every List element; `label` + // (non-secret) survives in every element -> overall Err. + let err = validate_excluding_secrets(&cfg).expect_err("non-secret siblings still fail"); + let rendered = format!("{err:?}"); + assert!(rendered.contains("label"), "non-secret sibling must survive"); + assert!( + !rendered.contains("api_key"), + "secret leaf must be pruned from every array element" + ); + } + + #[test] + fn validate_excluding_secrets_prunes_array_all_secret_failures_to_ok() { + use validator::Validate; + + #[derive(Validate)] + struct Partner { + #[validate(length(min = 100))] + api_key: String, // the ONLY validated field, and it's the secret leaf + } + #[derive(Validate)] + struct Outer { + #[validate(nested)] + partners: Vec, + } + impl AppConfigMeta for Outer { + fn secret_fields() -> Vec { + vec![SecretField { + kind: SecretKind::KeyInDefault, + path: vec![ + SecretPathSegment::Field(std::borrow::Cow::Borrowed("partners")), + SecretPathSegment::ArrayEach, + SecretPathSegment::Field(std::borrow::Cow::Borrowed("api_key")), + ], + optional: false, + }] + } + } + + // Every element's only failure is the secret leaf -> each List element + // clears -> the empty List is retained-out -> `partners` removed -> Ok. + let cfg = Outer { + partners: vec![ + Partner { api_key: "k0".to_owned() }, + Partner { api_key: "k1".to_owned() }, + ], + }; + assert!( + validate_excluding_secrets(&cfg).is_ok(), + "an array branch whose only failures are secret leaves must fully prune to Ok(())" + ); + } +``` + +Note on the array tests: together they prove the `ValidationErrorsKind::List` branch of `prune_secret_leaf` (Step 3) both (a) removes the secret leaf from **each** indexed element while leaving non-secret siblings, and (b) fully collapses to `Ok(())` when every element's only failure is the secret leaf (the `items.retain(..)` + `clear = items.is_empty()` path) — the `#[secret]`-in-`Vec` case the plan commits to from day one. + +- [ ] **Step 2: Run (fails)** + +Run: `cargo test -p edgezero-core --lib validate_excluding_secrets_prunes_nested 2>&1 | tail -20` +Expected: FAIL — the flat `bag.remove(first_segment)` removes the top-level `integrations` entry entirely (over-pruning) or fails to prune the nested leaf, so the assertion is wrong. (Either way the Task-1 flat impl is incorrect for nesting.) + +- [ ] **Step 3: Implement nested-aware pruning** + +Replace `validate_excluding_secrets`'s loop (`app_config.rs:204-226`) with a path-aware pruner that navigates `ValidationErrorsKind::Struct`/`List` down each secret field's path, removes the leaf validator, and prunes now-empty containers so a fully-cleared branch disappears (rather than leaving an empty `Struct`/`List` marker that would keep `errors` non-empty). The loop: + +```rust + let Err(mut errors) = result else { + return Ok(()); + }; + for field in C::secret_fields() { + if matches!(field.kind, SecretKind::StoreRef) { + continue; // store_id field; validator stays + } + prune_secret_leaf(&mut errors, &field.path); + } + if errors.errors().is_empty() { + return Ok(()); + } + Err(errors) +} +``` + +The pruner peeks the segment after each `Field` so a `Field` immediately followed by `ArrayEach` is handled as one step — validator nests a `List` under the array field's key, not as a bare top-level kind. (Mirrors the navigation in `first_violating_field` at `extractor.rs:926`.) Add `use validator::ValidationErrorsKind;` locally. + +```rust +fn prune_secret_leaf(errors: &mut validator::ValidationErrors, path: &[SecretPathSegment]) { + use validator::ValidationErrorsKind; + + let Some((head, rest)) = path.split_first() else { return; }; + let SecretPathSegment::Field(name) = head else { + // ArrayEach only appears immediately after a Field (a root is always a + // struct), so it is consumed by the peek below, never as a head. + return; + }; + + // Leaf. + if rest.is_empty() { + errors.errors_mut().remove(name.as_ref()); + return; + } + + // Does the next segment iterate an array? If so consume it and target a List. + let (kind_is_array, tail) = match rest.split_first() { + Some((SecretPathSegment::ArrayEach, tail)) => (true, tail), + _ => (false, rest), + }; + + let mut clear = false; + match errors.errors_mut().get_mut(name.as_ref()) { + Some(ValidationErrorsKind::Struct(inner)) if !kind_is_array => { + prune_secret_leaf(inner, tail); + clear = inner.errors().is_empty(); + } + Some(ValidationErrorsKind::List(items)) if kind_is_array => { + for inner in items.values_mut() { + prune_secret_leaf(inner, tail); + } + items.retain(|_, inner| !inner.errors().is_empty()); + clear = items.is_empty(); + } + _ => {} + } + if clear { + errors.errors_mut().remove(name.as_ref()); + } +} +``` + +- [ ] **Step 4: Run (passes)** + +Run: `cargo test -p edgezero-core --lib validate_excluding_secrets 2>&1 | tail -20` +Expected: PASS — both new tests plus the four pre-existing `validate_excluding_secrets_*` tests (`app_config.rs:1132/1148/1173/1195`). + +- [ ] **Step 5: Lint + commit** + +Run: `cargo clippy -p edgezero-core --all-targets --all-features -- -D warnings 2>&1 | tail -15` + +```bash +git add crates/edgezero-core/src/app_config.rs +git commit -m "feat(secrets): nested/list-aware validate_excluding_secrets pruning" +``` + +--- + +## Task 4: Derive recursion — `#[app_config(nested)]`, `Option`, path emission + +Make the derive actually emit nested/array/optional metadata: register the `app_config` attribute, parse `#[app_config(nested)]`, recurse into child `secret_fields()` prepending `Field` (object) or `Field` + `ArrayEach` (`Vec`), accept `Option` on `#[secret]` (→ `optional: true`), extend the `rename_all` guard to nested-only parents, and assert nested types derive `AppConfig`. + +**Files:** +- Modify: `crates/edgezero-macros/src/lib.rs` (`:20`) +- Modify: `crates/edgezero-macros/src/app_config.rs` (parsing, recursion, guards, optional) +- Modify/Create: `crates/edgezero-macros/tests/app_config_derive.rs` + `crates/edgezero-macros/tests/ui/*` + +**Interfaces:** +- Consumes: the Task-1 emitter shape (`fn secret_fields()` returning `Vec` with owned paths + `optional`). +- Produces: nested/array/optional metadata for real derived structs. Consumed by Tasks 6 & 7 and app-demo (unchanged top-level app-demo still emits length-1). + +- [ ] **Step 1: Register the `app_config` helper attribute** + +In `crates/edgezero-macros/src/lib.rs:20`, change: + +```rust +#[proc_macro_derive(AppConfig, attributes(secret))] +``` + +to: + +```rust +#[proc_macro_derive(AppConfig, attributes(secret, app_config))] +``` + +- [ ] **Step 2: Write failing derive/UI tests** + +Add happy-path assertions to `crates/edgezero-macros/tests/app_config_derive.rs` (a nested object emits the expected 3-segment path; a `Vec` nested field emits `Field`+`ArrayEach`; `Option` sets `optional: true`). Example: + +```rust + #[derive(serde::Deserialize, validator::Validate, edgezero_core::AppConfig)] + #[serde(deny_unknown_fields)] + struct DataDome { + #[secret] + server_side_key: String, + } + #[derive(serde::Deserialize, validator::Validate, edgezero_core::AppConfig)] + #[serde(deny_unknown_fields)] + struct Integrations { + #[app_config(nested)] + #[validate(nested)] + datadome: DataDome, + } + #[derive(serde::Deserialize, validator::Validate, edgezero_core::AppConfig)] + #[serde(deny_unknown_fields)] + struct Partner { + #[secret] + api_key: String, + #[secret] + maybe: Option, + } + #[derive(serde::Deserialize, validator::Validate, edgezero_core::AppConfig)] + #[serde(deny_unknown_fields)] + struct Settings { + #[app_config(nested)] + #[validate(nested)] + integrations: Integrations, + #[app_config(nested)] + #[validate(nested)] + partners: Vec, + } + + #[test] + fn nested_and_array_paths_are_emitted() { + use edgezero_core::app_config::{AppConfigMeta as _, SecretKind}; + + let mut paths: Vec<(String, SecretKind, bool)> = Settings::secret_fields() + .into_iter() + .map(|f| (f.dotted_path(), f.kind, f.optional)) + .collect(); + paths.sort_by(|a, b| a.0.cmp(&b.0)); + assert_eq!( + paths, + vec![ + ("integrations.datadome.server_side_key".to_owned(), SecretKind::KeyInDefault, false), + ("partners[*].api_key".to_owned(), SecretKind::KeyInDefault, false), + ("partners[*].maybe".to_owned(), SecretKind::KeyInDefault, true), + ], + ); + } +``` + +Add UI compile-fail fixtures under `crates/edgezero-macros/tests/ui/` and register them in `crates/edgezero-macros/tests/app_config_derive.rs`'s `trybuild_compile_fail_fixtures` test (`:144-159`). New fixtures (each with a `.stderr`): + - `app_config_nested_on_non_appconfig.rs` — `#[app_config(nested)]` on a field whose type does not derive `AppConfig` → clear `AppConfigRoot`/`AppConfigMeta` trait-bound error. + - `app_config_unknown_option.rs` — `#[app_config(bogus)]` (or a `nested` typo) errors instead of being silently ignored (proves `nested_optin` returns `Err`). + - `secret_on_option_non_string.rs` — `#[secret]` on `Option` still errors. + - `secret_store_ref_optional.rs` — `#[secret(store_ref)]` on `Option` errors (a store id is structural; optional is disallowed — Step 6). + - `nested_secret_serde_rename.rs` — `#[serde(rename)]` on a `#[secret]` leaf inside a nested struct still errors (guard self-enforced per struct). + - `nested_field_serde_rename.rs` — `#[serde(rename = "...")] #[app_config(nested)] child: Child` errors (the nested *parent* field carries `rename`, which would desync its `Field(field_name)` segment — Step 4 guard). + - `nested_parent_rename_all.rs` — a parent with only `#[app_config(nested)]` children (no direct `#[secret]`) carrying `#[serde(rename_all="kebab-case")]` errors (Step 7 guard). + +Naming caution: the existing harness globs `compile_fail("tests/ui/secret_*.rs")` (`app_config_derive.rs:147`). Do **not** prefix new fixtures with `secret_` unless they are compile-fail; `secret_on_option_non_string.rs` is compile-fail so the glob covers it (don't double-register). The `app_config_*` and `nested_*` names must be listed explicitly. + +Note: `app_config_derive.rs` runs `trybuild` only in that single `#[test]`; also add an `Option` **pass** assertion (that it compiles + sets `optional: true`) inside the happy-path `mod tests` above — not as a UI fixture. + +- [ ] **Step 3: Run (fails)** + +Run: `cargo test -p edgezero-macros --test app_config_derive 2>&1 | tail -30` +Expected: FAIL — `#[app_config(nested)]` is not parsed (unknown attribute or ignored); `Option` rejected by `is_scalar_string_type`; nested paths not emitted. + +- [ ] **Step 4: Parse `#[app_config(nested)]` and classify fields** + +In `crates/edgezero-macros/src/app_config.rs`, add a helper that detects the opt-in and extracts the recursion child type. A field is a **nested recursion** field iff it carries `#[app_config(nested)]`. Determine object-vs-array from the field type: a `Vec`/`[T]` → array (child `T`, emit `Field(field)` + `ArrayEach`); anything else → object (child = the field type, emit `Field(field)`). + +```rust +/// Whether `field` carries `#[app_config(nested)]`. Returns `Err` (not +/// `false`) on a malformed `#[app_config(...)]` such as `#[app_config(bogus)]` +/// or `#[app_config(nseted)]`, so a typo is a hard compile error rather than a +/// silently-ignored non-recursion (which would drop the child's secrets). +fn nested_optin(field: &Field) -> syn::Result { + let mut found = false; + for attr in &field.attrs { + if !attr.path().is_ident("app_config") { + continue; + } + attr.parse_nested_meta(|meta| { + if meta.path.is_ident("nested") { + found = true; + Ok(()) + } else { + Err(meta.error("`#[app_config(...)]` only accepts `nested`")) + } + })?; + } + Ok(found) +} + +/// The child element type to recurse into and whether it is an array element. +/// `Vec` / `[T]` -> (T, true); otherwise (field_ty, false). +fn nested_child_type(ty: &Type) -> (&Type, bool) { + if let Type::Path(tp) = ty { + if let Some(last) = tp.path.segments.last() { + if last.ident == "Vec" { + if let syn::PathArguments::AngleBracketed(ab) = &last.arguments { + if let Some(syn::GenericArgument::Type(inner)) = ab.args.first() { + return (inner, true); + } + } + } + } + } + if let Type::Slice(s) = ty { + return (&s.elem, true); + } + (ty, false) +} +``` + +Modify the main field loop in `expand` (`app_config.rs:62-66`) so that for each field it either (a) records a direct `#[secret]` annotation (as today) or (b) records a nested-recursion descriptor `{ field_name, child_ty, is_array }` when `nested_optin(field)?` returns `true`. Propagate the `syn::Result` with `?` so a malformed `#[app_config(...)]` becomes a compile error. A field may not be both `#[secret]` and `#[app_config(nested)]` (error if so). + +**Guard the nested parent field's serde attrs.** The emitter writes `Field(Cow::Borrowed(field_name))` using the Rust field name verbatim, so a `#[serde(rename = "...")]` (or `flatten`/`skip*`) on the `#[app_config(nested)]` field itself would desync the emitted path segment from the serialized key — the exact hazard the spec forbids "anywhere on a secret path" (spec §4.3 point 3, line 282). The existing `enforce_no_disallowed_serde_attrs(field)?` (`app_config.rs:363`) already bans `rename`/`flatten`/`skip`/`skip_deserializing`/`skip_serializing`/`skip_serializing_if`. Call it on every nested-recursion field (it currently runs only on `#[secret]` fields via `scan_field`). Each struct on the path self-enforces this for its own fields, so `rename` at any level along the path is rejected by whichever struct declares that field. + +- [ ] **Step 5: Emit recursion into `secret_fields()`** + +Extend the emitter (Task 1's `entries`) to also emit, for each nested descriptor, a loop that prepends segments onto the child's `secret_fields()`. Change the `fn secret_fields()` body emission to build a `Vec` imperatively: + +```rust + // direct #[secret] entries (owned length-1 paths, optional from Option) + let direct_entries = /* Task 1 entries, but `optional: #is_option` per field */; + + // nested recursion pushes + let nested_pushes = nested_descriptors.iter().map(|d| { + let field_lit = d.field_name.to_string(); + let child_ty = &d.child_ty; + if d.is_array { + quote! { + for mut __f in <#child_ty as ::edgezero_core::app_config::AppConfigMeta>::secret_fields() { + let mut __p = ::std::vec![ + ::edgezero_core::app_config::SecretPathSegment::Field(::std::borrow::Cow::Borrowed(#field_lit)), + ::edgezero_core::app_config::SecretPathSegment::ArrayEach, + ]; + __p.append(&mut __f.path); + __f.path = __p; + __out.push(__f); + } + } + } else { + quote! { + for mut __f in <#child_ty as ::edgezero_core::app_config::AppConfigMeta>::secret_fields() { + let mut __p = ::std::vec![ + ::edgezero_core::app_config::SecretPathSegment::Field(::std::borrow::Cow::Borrowed(#field_lit)), + ]; + __p.append(&mut __f.path); + __f.path = __p; + __out.push(__f); + } + } + } + }); + + let secret_fields_body = quote! { + let mut __out: ::std::vec::Vec<::edgezero_core::app_config::SecretField> = + ::std::vec![#(#direct_entries),*]; + #(#nested_pushes)* + __out + }; +``` + +And the impl: + +```rust + impl #impl_generics ::edgezero_core::app_config::AppConfigMeta + for #struct_ident #type_generics #where_clause + { + fn secret_fields() -> ::std::vec::Vec<::edgezero_core::app_config::SecretField> { + #secret_fields_body + } + } +``` + +Additionally, emit an explicit **`AppConfigRoot`** assertion per nested child (spec §4.3 / B-2: the sub-struct must derive `AppConfig`, tracked via the `AppConfigRoot` marker — not merely impl `AppConfigMeta`, which a hand-rolled impl could satisfy without going through the derive). Calling `<#child_ty as AppConfigMeta>::secret_fields()` alone would accept a hand-written `AppConfigMeta` impl; the `AppConfigRoot` bound closes that gap and gives a clear error span: + +```rust + const _: () = { + fn __assert_app_config_root() {} + fn __assert_nested_children() { + #( __assert_app_config_root::<#nested_child_tys>(); )* + } + }; +``` + +where `#nested_child_tys` is the list of the recursion child types (the object field type, or the `Vec`/slice element type). A nested field whose type does not derive `AppConfig` fails with "the trait bound `Child: AppConfigRoot` is not satisfied" — the `app_config_nested_on_non_appconfig.rs` UI fixture pins this message. + +- [ ] **Step 6: Relax scalar rule to accept `Option`; set `optional`** + +Change `is_scalar_string_type`/`enforce_scalar_string_type` (`app_config.rs:265-284`) to also accept `Option`, and have `scan_field` (`app_config.rs:195-219`) report whether the secret type was optional so the emitter sets `optional`. Add: + +```rust +/// `Option` -> Some(true); `String` -> Some(false); else None. +fn secret_string_optionality(ty: &Type) -> Option { + if is_scalar_string_type(ty) { + return Some(false); + } + if let Type::Path(tp) = ty { + if let Some(last) = tp.path.segments.last() { + if last.ident == "Option" { + if let syn::PathArguments::AngleBracketed(ab) = &last.arguments { + if let Some(syn::GenericArgument::Type(inner)) = ab.args.first() { + if is_scalar_string_type(inner) { + return Some(true); + } + } + } + } + } + } + None +} +``` + +Replace `enforce_scalar_string_type(field)?;` (`app_config.rs:215`, called from `scan_field` after `parse_secret_kind` yields `kind`) with the optionality computation **plus a `StoreRef`-cannot-be-optional guard**: + +```rust + let optional = secret_string_optionality(&field.ty).ok_or_else(|| { + syn::Error::new_spanned( + &field.ty, + "`#[secret]` may only annotate `String` or `Option`", + ) + })?; + // A `#[secret(store_ref)]` value is a store id — structural, always + // present. `Option` there is undefined (an absent store cannot + // resolve its dependent `KeyInNamedStore` sibling), so reject it. Optional + // is allowed only on the secret-VALUE kinds (KeyInDefault / KeyInNamedStore). + if optional && matches!(kind, SecretAnnotation::StoreRef) { + return Err(syn::Error::new_spanned( + &field.ty, + "`#[secret(store_ref)]` may not be `Option` — a store id is structural and must always be present", + )); + } +``` + +and thread `optional` into `FieldAnnotation` (add a `bool` field), then into the direct-entry emission (`optional: #optional_lit`). Keep rejecting `Vec`, `Cow<'_, str>`, non-string scalars (they yield `None`). Note the runtime walk already early-returns for `StoreRef` regardless of `optional`; this compile-time guard removes the ambiguity at the source so CLI validation and the walk never see an optional store id. + +- [ ] **Step 7: Extend the `rename_all` guard to nested-only parents** + +The guard fires today only when `!annotations.is_empty()` (`app_config.rs:75-77`, direct `#[secret]` fields present). A parent whose secrets are all in `#[app_config(nested)]` children has no direct annotations but its own `rename_all` would still desync the emitted `Field(parent_field)` segment. Change the gate to also fire when any nested descriptor exists: + +```rust + if !annotations.is_empty() || !nested_descriptors.is_empty() { + enforce_no_container_rename_all(&input.attrs)?; + } +``` + +- [ ] **Step 8: Run (passes)** + +Run: `cargo test -p edgezero-macros --test app_config_derive 2>&1 | tail -30` +Expected: PASS — happy-path nested/array/optional emission + all UI compile-fail fixtures match their `.stderr`. Regenerate `.stderr` with `TRYBUILD=overwrite cargo test -p edgezero-macros --test app_config_derive` if messages differ, then inspect the diffs for correctness before committing. + +- [ ] **Step 9: Lint + commit** + +Run: `cargo clippy -p edgezero-macros --all-targets --all-features -- -D warnings 2>&1 | tail -15` + +```bash +git add crates/edgezero-macros/src/lib.rs crates/edgezero-macros/src/app_config.rs \ + crates/edgezero-macros/tests/app_config_derive.rs crates/edgezero-macros/tests/ui/ +git commit -m "feat(macros): #[app_config(nested)] recursion, Option secrets, path emission" +``` + +--- + +## Task 5: Invert the nested-AppConfig CI guard + +The `check_no_nested_app_config` binary currently rejects **any** `AppConfig` struct used inside another (`.github/workflows/test.yml:58` runs it). Invert it: nesting is allowed **iff** the containing field carries `#[app_config(nested)]`. Add the tests it lacks today. + +**Files:** +- Modify: `crates/edgezero-cli/src/bin/check_no_nested_app_config.rs` + +**Interfaces:** +- Consumes: `syn` field attributes (the binary already parses structs with `syn::visit`). +- Produces: a guard that permits opted-in nesting; still fails on un-opted-in nesting. + +- [ ] **Step 1: Write failing guard tests** + +Add a `#[cfg(test)] mod tests` to `crates/edgezero-cli/src/bin/check_no_nested_app_config.rs`. The binary is behind `#![cfg(feature = "nested-app-config-check")]`, so tests run only with that feature. Test the pure helpers by parsing source snippets with `syn::parse_file` and running the collector + visitor: + +```rust +#[cfg(test)] +mod tests { + use super::*; + + fn violations_in(src: &str) -> usize { + let file = syn::parse_file(src).expect("parse"); + let mut collector = AppConfigStructCollector::default(); + syn::visit::visit_file(&mut collector, &file); + let mut visitor = NestedAppConfigVisitor::new(&collector.app_config_structs, std::path::Path::new("t.rs")); + syn::visit::visit_file(&mut visitor, &file); + visitor.violations + } + + const NESTED_WITHOUT_OPT_IN: &str = r#" + #[derive(edgezero_core::AppConfig)] struct Inner { #[secret] k: String } + #[derive(edgezero_core::AppConfig)] struct Outer { inner: Inner } + "#; + + const NESTED_WITH_OPT_IN: &str = r#" + #[derive(edgezero_core::AppConfig)] struct Inner { #[secret] k: String } + #[derive(edgezero_core::AppConfig)] struct Outer { #[app_config(nested)] inner: Inner } + "#; + + const NESTED_VEC_WITH_OPT_IN: &str = r#" + #[derive(edgezero_core::AppConfig)] struct Inner { #[secret] k: String } + #[derive(edgezero_core::AppConfig)] struct Outer { #[app_config(nested)] inner: Vec } + "#; + + #[test] + fn flags_nesting_without_opt_in() { + assert_eq!(violations_in(NESTED_WITHOUT_OPT_IN), 1); + } + + #[test] + fn allows_nesting_with_opt_in() { + assert_eq!(violations_in(NESTED_WITH_OPT_IN), 0); + } + + #[test] + fn allows_vec_nesting_with_opt_in() { + assert_eq!(violations_in(NESTED_VEC_WITH_OPT_IN), 0); + } +} +``` + +(If the collector/visitor don't currently expose `default()`/`new()`/public fields for construction in tests, add minimal `#[derive(Default)]` / a `new` constructor / `pub(crate)` visibility as part of this task — they're in the same binary crate.) + +- [ ] **Step 2: Run (fails)** + +Run: `cargo test -p edgezero-cli --features nested-app-config-check --bin check_no_nested_app_config 2>&1 | tail -20` +Expected: FAIL — `allows_nesting_with_opt_in` sees 1 violation (the guard flags all nesting today). + +- [ ] **Step 3: Teach the visitor to honor `#[app_config(nested)]`** + +In `NestedAppConfigVisitor::visit_item_struct` (`check_no_nested_app_config.rs:156-181`), before reporting a violation for a field whose type contains an `AppConfig` struct, skip it if the field carries `#[app_config(nested)]`. Add a helper mirroring the macro's detection and guard the report: + +```rust +// Returns true only for a well-formed `#[app_config(nested)]`. A malformed +// `#[app_config(...)]` returns false -> the field is treated as NOT opted in, +// so the guard still FLAGS the nesting (loud CI failure) rather than silently +// waving it through. This is safe here (unlike the derive's `nested_optin`, +// which must hard-error): the guard runs only over already-compiling code, and +// the derive's strict `nested_optin` (Task 4) has already rejected any +// malformed `#[app_config(...)]` before this binary ever runs. +fn field_has_nested_optin(field: &syn::Field) -> bool { + field.attrs.iter().any(|attr| { + attr.path().is_ident("app_config") + && attr + .parse_nested_meta(|meta| { + if meta.path.is_ident("nested") { + Ok(()) + } else { + Err(meta.error("unknown app_config option")) + } + }) + .is_ok() + }) +} +``` + +and in the field loop, wrap the existing `if let Some(inner_name) = type_contains_app_config_struct(...) { self.report(...) }` so it becomes: + +```rust + if let Some(inner_name) = type_contains_app_config_struct(&field.ty, self.app_config_structs) { + if field_has_nested_optin(field) { + continue; // opted in via #[app_config(nested)] — allowed + } + self.report(self.source_path, field, outer_ident, &field_name, &inner_name); + } +``` + +(Adjust the `report` call to match the current signature at `check_no_nested_app_config.rs:138-149`.) + +- [ ] **Step 4: Run (passes) + run the guard over the real trees** + +Run: `cargo test -p edgezero-cli --features nested-app-config-check --bin check_no_nested_app_config 2>&1 | tail -20` +Expected: PASS. + +Run: `cargo run -q -p edgezero-cli --bin check_no_nested_app_config --features nested-app-config-check -- examples/app-demo crates/edgezero-cli/src/templates 2>&1 | tail -5` +Expected: `check_no_nested_app_config: OK` (app-demo has no opted-in nesting yet; still clean). + +- [ ] **Step 5: Lint + commit** + +Run: `cargo clippy -p edgezero-cli --features nested-app-config-check --bin check_no_nested_app_config -- -D warnings 2>&1 | tail -15` + +```bash +git add crates/edgezero-cli/src/bin/check_no_nested_app_config.rs +git commit -m "ci(secrets): allow nested AppConfig when field opts in via #[app_config(nested)]" +``` + +--- + +## Task 6: Path-aware CLI reflection (validate / push / diff over nested config) + +Task 1 made the CLI consumers compile against the new shape but only navigate top-level keys. Now make `run_adapter_typed_checks` and `typed_secret_checks` navigate the raw TOML by path (Field/ArrayEach), emitting one `TypedSecretEntry` per array element with a runtime dotted label, and resolving `store_ref` siblings within the innermost parent. + +**Files:** +- Modify: `crates/edgezero-cli/src/config.rs` (`run_adapter_typed_checks` at `:1295`, `typed_secret_checks` at `:1339`; add a TOML path navigator + tests) + +**Interfaces:** +- Consumes: `SecretField.path`/`optional`, `toml::Value`, `TypedSecretEntry::new(store_id, String, key_value)` (Task 1). +- Produces: path-aware validate/push/diff. Consumed by acceptance (nested config validates/pushes). + +- [ ] **Step 1: Write failing CLI navigation tests** + +Add tests to `crates/edgezero-cli/src/config.rs` `#[cfg(test)] mod tests`, driven through the **public** `run_config_validate_typed::` entry point (which calls both `typed_secret_checks` and `run_adapter_typed_checks`). `ValidationContext` has private fields and a `ManifestLoader` that is impractical to build by hand, so mirror the existing harness: write a manifest + `demo-app.toml` to a tempdir with `setup_project(manifest, app_config)` (`config.rs:1662`, returns `(TempDir, manifest_path, app_config_path)`) and pass `args_for(&manifest_path)` (`config.rs:1671`). The config type is a **real** nested `#[derive(AppConfig)]` (Task 4 is complete by Task 6), which also proves derive→CLI integration. + +```rust + // Real nested derive: integrations.datadome.server_side_key (KeyInDefault), + // partners[*].api_key (KeyInDefault). + #[derive(Debug, Deserialize, Serialize, Validate, edgezero_core::AppConfig)] + #[serde(deny_unknown_fields)] + struct DataDome { + #[secret] + server_side_key: String, + } + #[derive(Debug, Deserialize, Serialize, Validate, edgezero_core::AppConfig)] + #[serde(deny_unknown_fields)] + struct Integrations { + #[app_config(nested)] + #[validate(nested)] + datadome: DataDome, + } + #[derive(Debug, Deserialize, Serialize, Validate, edgezero_core::AppConfig)] + #[serde(deny_unknown_fields)] + struct Partner { + #[secret] + api_key: String, + } + #[derive(Debug, Deserialize, Serialize, Validate, edgezero_core::AppConfig)] + #[serde(deny_unknown_fields)] + struct NestedCliConfig { + #[app_config(nested)] + #[validate(nested)] + integrations: Integrations, + #[app_config(nested)] + #[validate(nested)] + partners: Vec, + } + + const NESTED_MANIFEST: &str = r#" +[app] +name = "demo-app" + +[adapters.axum.adapter] +crate = "crates/demo-axum" +[adapters.axum.commands] +build = "echo" +deploy = "echo" +serve = "echo" + +[stores.config] +ids = ["app_config"] + +[stores.secrets] +ids = ["default"] +"#; + + #[test] + fn validate_typed_accepts_well_formed_nested_and_array_secrets() { + let app_config = r#" +[integrations.datadome] +server_side_key = "dd_key" + +[[partners]] +api_key = "p0" + +[[partners]] +api_key = "p1" +"#; + let (_dir, manifest_path, _) = setup_project(NESTED_MANIFEST, app_config); + run_config_validate_typed::(&args_for(&manifest_path)) + .expect("well-formed nested + array secret config validates"); + } + + #[test] + fn validate_typed_reports_dotted_path_for_empty_array_secret() { + // partners[1].api_key is empty -> typed_secret_checks must reject it and + // name the INDEXED dotted path. + let app_config = r#" +[integrations.datadome] +server_side_key = "dd_key" + +[[partners]] +api_key = "p0" + +[[partners]] +api_key = "" +"#; + let (_dir, manifest_path, _) = setup_project(NESTED_MANIFEST, app_config); + let err = run_config_validate_typed::(&args_for(&manifest_path)) + .expect_err("empty array secret must be rejected"); + assert!( + err.contains("partners[1].api_key"), + "error names the indexed dotted path: {err}" + ); + } + + #[test] + fn validate_typed_reports_dotted_path_for_missing_nested_leaf() { + // integrations.datadome table present but server_side_key missing. + // (Note: serde deny_unknown_fields + required String means this also + // fails deserialization; assert the dotted path appears either way.) + let app_config = r#" +[integrations.datadome] + +[[partners]] +api_key = "p0" +"#; + let (_dir, manifest_path, _) = setup_project(NESTED_MANIFEST, app_config); + let err = run_config_validate_typed::(&args_for(&manifest_path)) + .expect_err("missing nested leaf must be rejected"); + assert!( + err.contains("server_side_key"), + "error names the missing nested leaf: {err}" + ); + } +``` + +Nested `KeyInNamedStore` CLI case (proves the store_ref sibling is resolved within the innermost parent table, and the named store must be declared in `[stores.secrets].ids`): + +```rust + #[derive(Debug, Deserialize, Serialize, Validate, edgezero_core::AppConfig)] + #[serde(deny_unknown_fields)] + struct Vaulted { + #[secret(store_ref = "vault")] + token: String, + #[secret(store_ref)] + vault: String, + } + #[derive(Debug, Deserialize, Serialize, Validate, edgezero_core::AppConfig)] + #[serde(deny_unknown_fields)] + struct NamedStoreCliConfig { + #[app_config(nested)] + #[validate(nested)] + vaulted: Vaulted, + } + + #[test] + fn validate_typed_accepts_nested_named_store_with_sibling() { + let manifest = r#" +[app] +name = "demo-app" + +[adapters.axum.adapter] +crate = "crates/demo-axum" +[adapters.axum.commands] +build = "echo" +deploy = "echo" +serve = "echo" + +[stores.config] +ids = ["app_config"] + +[stores.secrets] +ids = ["default", "named"] +"#; + let app_config = r#" +[vaulted] +token = "tok_key" +vault = "named" +"#; + let (_dir, manifest_path, _) = setup_project(manifest, app_config); + run_config_validate_typed::(&args_for(&manifest_path)) + .expect("nested named-store secret with a declared store validates"); + } +``` + +- [ ] **Step 2: Factor a TOML path leaf-collector** + +Add a helper that, given the raw `&toml::Value` table and a `&SecretField`, yields each resolved leaf as `(label: String, value: &str, store_ref_value: Option<&str>)`, where `label` uses concrete `[n]` indices and `store_ref_value` is resolved from the leaf's innermost parent table (for `KeyInNamedStore`). Absent optional leaves yield nothing; missing required leaves yield an error carrying the dotted label. + +```rust +struct ResolvedTomlLeaf<'a> { + label: String, + value: &'a str, + store_ref_value: Option<&'a str>, +} + +fn collect_secret_leaves<'a>( + root: &'a toml::Value, + field: &SecretField, +) -> Result>, String> { + fn walk<'a>( + node: &'a toml::Value, + field: &SecretField, + remaining: &[SecretPathSegment], + rendered: String, + out: &mut Vec>, + ) -> Result<(), String> { + match remaining.split_first() { + Some((SecretPathSegment::Field(name), rest)) if rest.is_empty() => { + let parent = node.as_table().ok_or_else(|| { + format!("expected a table containing `{name}` at `{rendered}`") + })?; + let leaf_label = if rendered.is_empty() { + name.to_string() + } else { + format!("{rendered}.{name}") + }; + match parent.get(name.as_ref()).and_then(toml::Value::as_str) { + Some(value) => { + let store_ref_value = match field.kind { + SecretKind::KeyInNamedStore { store_ref_field } => { + parent.get(store_ref_field).and_then(toml::Value::as_str) + } + _ => None, + }; + out.push(ResolvedTomlLeaf { label: leaf_label, value, store_ref_value }); + Ok(()) + } + None if field.optional && parent.get(name.as_ref()).is_none() => Ok(()), + None => Err(format!("`#[secret]` field `{leaf_label}` is missing or not a string")), + } + } + Some((SecretPathSegment::Field(name), rest)) => { + let table = node.as_table().ok_or_else(|| format!("expected a table at `{rendered}`"))?; + let next_rendered = if rendered.is_empty() { name.to_string() } else { format!("{rendered}.{name}") }; + match table.get(name.as_ref()) { + Some(child) => walk(child, field, rest, next_rendered, out), + None if field.optional => Ok(()), + None => Err(format!("missing `{next_rendered}`")), + } + } + Some((SecretPathSegment::ArrayEach, rest)) => { + let arr = node.as_array().ok_or_else(|| format!("expected an array at `{rendered}`"))?; + for (idx, item) in arr.iter().enumerate() { + walk(item, field, rest, format!("{rendered}[{idx}]"), out)?; + } + Ok(()) + } + None => Ok(()), + } + } + let mut out = Vec::new(); + walk(root, field, &field.path, String::new(), &mut out)?; + Ok(out) +} +``` + +- [ ] **Step 3: Rewrite the two consumers to use the collector** + +Replace the flat lookups in `run_adapter_typed_checks` (`config.rs:1295-1333`) and `typed_secret_checks` (`config.rs:1339-1412`): + +- `run_adapter_typed_checks`: for each `field in C::secret_fields()`, for each leaf in `collect_secret_leaves(raw_value, &field)?`, build entries. For `KeyInDefault`, use `default_store_id`; for `KeyInNamedStore`, use `leaf.store_ref_value` (error if `None`); push `TypedSecretEntry::new(store_id, leaf.label, leaf.value)`. `StoreRef` still produces no entry. +- `typed_secret_checks`: for each `field`, for each leaf, apply the existing empty-string / `[stores.secrets]`-declared / store-ref-in-ids checks, but keyed on `leaf.label`/`leaf.value`. For `StoreRef`, the leaf value must be in `[stores.secrets].ids` (as today). + +Note the collector takes `&toml::Value` (the whole raw config) — `run_adapter_typed_checks`/`typed_secret_checks` currently start from `raw_table = ctx.raw_config.as_table()`; pass `&ctx.raw_config` to the collector instead (it does the `as_table` internally). + +- [ ] **Step 4: Run (passes) + full CLI tests** + +Run: `cargo test -p edgezero-cli --lib config 2>&1 | tail -25` +Expected: PASS — new nested tests + all pre-existing config tests (top-level fixtures still length-1 paths). + +- [ ] **Step 5: Lint + commit** + +Run: `cargo clippy -p edgezero-cli --all-targets --all-features -- -D warnings 2>&1 | tail -15` + +```bash +git add crates/edgezero-cli/src/config.rs +git commit -m "feat(cli): path-aware secret reflection in config validate/push/diff" +``` + +--- + +## Task 7: End-to-end nested-secret extractor test + `KeyInNamedStore` fixture + docs + +Prove the whole chain with a real `#[derive(AppConfig)]` config that has a 2-level nested secret and a nested `KeyInNamedStore` (sibling-in-parent), resolved through an `InMemorySecretStore` via the `AppConfig` extractor. Then document the feature. + +**Files:** +- Modify: `crates/edgezero-core/src/extractor.rs` (E2E test in `#[cfg(test)] mod tests`) — or a new `crates/edgezero-macros/tests/` integration test if a real `#[derive(AppConfig)]` is easier there (the derive lives in macros; a genuine nested derived struct needs `edgezero_core` as an external crate, so prefer `crates/edgezero-macros/tests/nested_secrets_e2e.rs`). +- Modify: `docs/guide/configuration.md` + +**Interfaces:** +- Consumes: everything from Tasks 1–6. +- Produces: acceptance evidence; docs. + +- [ ] **Step 1: Write the failing E2E test** + +Create `crates/edgezero-macros/tests/nested_secrets_e2e.rs`. Define a real nested config with `#[derive(AppConfig, Deserialize, Validate)]`, including one `KeyInDefault` nested leaf and one `KeyInNamedStore` nested leaf whose `store_ref` sibling lives in the same inner struct. Build a `serde_json::Value` blob holding key NAMES, run `secret_walk` (via the public `AppConfig` extraction path or by calling the crate's extraction entry point), and assert the resolved values. + +Prefer driving it through the same public surface app-demo's `config_flow.rs` uses (`InMemorySecretStore::new([...])`, build a `BlobEnvelope`, extract via the `AppConfig` extractor with the store registry in `ctx`). Model on `examples/app-demo/crates/app-demo-cli/tests/config_flow.rs:210-231`. Assert: + - nested `KeyInDefault` leaf resolves from the default store; + - nested `KeyInNamedStore` leaf resolves from the named store identified by its sibling; + - a nested config with an array of secrets resolves each element. + +```rust +#[derive(serde::Deserialize, validator::Validate, edgezero_core::AppConfig)] +#[serde(deny_unknown_fields)] +struct DataDome { + #[secret] + server_side_key: String, +} +#[derive(serde::Deserialize, validator::Validate, edgezero_core::AppConfig)] +#[serde(deny_unknown_fields)] +struct Vaulted { + #[secret(store_ref = "vault")] + token: String, + #[secret(store_ref)] + vault: String, +} +#[derive(serde::Deserialize, validator::Validate, edgezero_core::AppConfig)] +#[serde(deny_unknown_fields)] +struct Settings { + #[app_config(nested)] + #[validate(nested)] + datadome: DataDome, + #[app_config(nested)] + #[validate(nested)] + vaulted: Vaulted, +} +// ... build data = { "datadome": { "server_side_key": "dd_key" }, +// "vaulted": { "token": "tok_key", "vault": "named" } } +// ... default store: default/dd_key -> "DD"; named store "named": named/tok_key -> "TOK" +// ... run extraction; assert cfg.datadome.server_side_key == "DD" and cfg.vaulted.token == "TOK". +``` + +- [ ] **Step 2: Run (fails, then passes)** + +Run: `cargo test -p edgezero-macros --test nested_secrets_e2e 2>&1 | tail -25` +Expected: FAIL first (fixture/wiring), then PASS once assertions match resolved values. (If any Task 1–6 gap surfaces here, fix in the owning task's file and re-run.) + +- [ ] **Step 3: Docs** + +Append to `docs/guide/configuration.md` a "Nested and array secrets" section documenting: the `#[app_config(nested)]` opt-in (mirrors `#[validate(nested)]`; the nested type must itself derive `AppConfig`), `#[secret]` on `Option` (absent → skipped at runtime), object nesting and `Vec<_>` arrays (`partners[*].api_key`), the `store_ref` sibling scoping rule (resolved within the innermost containing object), and the dotted-path error format (`integrations.datadome.server_side_key`, `partners[3].api_key`). Include a worked `Settings`/`Integrations`/`Partner` example. + +- [ ] **Step 4: Full workspace verification (all CI gates)** + +```bash +cargo fmt --all -- --check +cargo clippy --workspace --all-targets --all-features -- -D warnings +cargo test --workspace --all-targets +cargo check --workspace --all-targets --features "fastly cloudflare spin" +cargo check -p edgezero-adapter-spin --target wasm32-wasip2 --features spin +cargo run -q -p edgezero-cli --bin check_no_nested_app_config --features nested-app-config-check -- examples/app-demo crates/edgezero-cli/src/templates +(cd examples/app-demo && cargo test) +``` +Expected: all green; app-demo top-level `#[secret]` still resolves; the guard prints `OK`. + +- [ ] **Step 5: Commit** + +```bash +git add crates/edgezero-macros/tests/nested_secrets_e2e.rs docs/guide/configuration.md +git commit -m "test(secrets): end-to-end nested + named-store resolution; docs: nested/array secrets" +``` + +--- + +## Acceptance criteria (spec §5) + +1. `cargo fmt` / clippy clean across `edgezero-core`, `edgezero-macros`, all adapters, `edgezero-cli`. +2. New unit + UI + integration tests (Tasks 1–7) pass; the six pre-existing `validate_excluding_secrets_*` / `app_config_secret_walk_*` tests still pass. +3. `app-demo` still builds and serves on all four adapters; its top-level `#[secret] api_token` (`KeyInDefault`) and `vault` (`StoreRef`) resolve identically (length-1 paths). +4. `edgezero-cli` `config validate/push/diff` operate correctly over a config with nested + array secrets. +5. The **Nested AppConfig audit** CI step passes and now permits `#[app_config(nested)]` fields. +6. Rustdoc + `docs/guide/configuration.md` updates merged. + +## Self-review notes (mapping to spec §4 + §8 blockers) + +- §4.2 metadata: owned `SecretField { kind, path: Vec, optional }` + `dotted_path()` → Task 1. **[B, BLOCKER] owned segments** (not `&'static`) — done. **[B, BLOCKER] `optional` flag** — done. +- §4.3 derive: `#[app_config(nested)]` opt-in, recursion, `Option`, path guards; **[B, HIGH] register `app_config` attr** (Task 4 Step 1); **[B, HIGH] nested-only `rename_all`** (Task 4 Step 7) → Task 4. **B-3 forced to `fn secret_fields()`** — Task 1. +- §4.4 runtime walk: Field/ArrayEach navigator, optional skip, sibling-in-parent, dotted `[n]` errors → Task 2. +- §4.5 CLI: path-aware `run_adapter_typed_checks`/`typed_secret_checks`; `build_config_envelope` unchanged (serializes verbatim); Spin collision keys on value (survives reshape, prints dotted label) → Tasks 1 + 6. **[B, HIGH] owned `TypedSecretEntry.field_name`** → Task 1 Step 8. +- §4.6 back-compat: top-level configs behave identically (length-1 paths); all in-tree consumers flip in the same branch → Task 1. +- **[B, IMPORTANT] nested `validate_excluding_secrets`** (not a flat remove) → Task 3. +- **[B, BLOCKER] inverted CI guard** → Task 5. +- **[B, HIGH] array scope decided: arrays-now** (`ArrayEach` implemented throughout) → Tasks 1–7. +- §4.7 tests: derive UI (nested/optional/rename/non-AppConfig), runtime (nested/named-store/optional/missing), E2E 2-level → Tasks 4, 2, 7. `KeyInNamedStore` needs a purpose-built fixture (app-demo has none) → Task 7. + +## Review round 2 — fixes folded in + +- **Optional `None` = JSON `null` (blocker):** `resolve_leaf` and the object-descent arm now skip an optional leaf/subtree that is missing *or* `null` (serde emits `None` as `null`; `#[secret]` bans `skip_serializing_if`). Added `secret_walk_skips_null_optional_leaf` (Task 2). +- **`TypedSecretEntry::new` back-compat (blocker):** constructor takes `field_name: impl Into` so the 7 existing `&str`-literal Spin test call sites compile unchanged (Task 1 Step 8). +- **Malformed `#[app_config(...)]` (high):** derive helper is `nested_optin(field) -> syn::Result` (hard error on unknown option, propagated with `?`); added `app_config_unknown_option.rs` UI fixture. CI-guard helper stays lenient by design (documented: runs only over already-compiling code) (Tasks 4, 5). +- **Array validation pruning untested (high):** added `validate_excluding_secrets_prunes_array_secret_leaf_keeps_siblings` exercising the `ValidationErrorsKind::List` branch (Task 3). +- **`#[secret(store_ref)]` + `Option` (high):** rejected at compile time (a store id is structural); added `secret_store_ref_optional.rs` UI fixture. Optional allowed only on `KeyInDefault`/`KeyInNamedStore` (Task 4 Step 6). +- **Nested-child marker (medium):** emit an explicit `AppConfigRoot` bound assertion per nested child (not just the implicit `AppConfigMeta` call), matching spec §4.3/B-2 (Task 4 Step 5). + +## Review round 3 — fixes folded in + +- **serde `rename` on nested parent fields:** the spec forbids `#[serde(rename)]` *anywhere* on a secret path. The plan now runs the existing `enforce_no_disallowed_serde_attrs` (bans rename/flatten/skip*) on every `#[app_config(nested)]` field too — a nested parent field with `#[serde(rename)]` would desync its `Field(field_name)` segment. Added `nested_field_serde_rename.rs` UI fixture (Task 4 Step 4). +- **Named-store coverage earlier + broader:** added a nested `KeyInNamedStore` sibling-in-parent runtime test (`secret_walk_resolves_nested_named_store_via_sibling_in_parent`) and a missing-sibling error test to Task 2, plus a nested `KeyInNamedStore` CLI validate test to Task 6 — no longer only end-to-end in Task 7. +- **Array pruning all-secret success:** added `validate_excluding_secrets_prunes_array_all_secret_failures_to_ok`, proving an array branch whose every element's only failure is the secret leaf collapses to `Ok(())` (the `items.retain(..)`/`items.is_empty()` path), complementing the sibling-survives test (Task 3). +- **Task 6 CLI tests made concrete:** replaced the pseudo-code with real tests driven through the public `run_config_validate_typed::` entry point using the existing `setup_project`/`args_for` harness (`config.rs:1662/1671`) and real nested `#[derive(AppConfig)]` fixtures — with concrete TOML, `partners[1].api_key` indexed-label assertion, missing-leaf assertion, and the nested `KeyInNamedStore` case (Task 6 Step 1). +- **Removed the obsolete pruning sketch:** Task 3 Step 3 now shows a single `prune_secret_leaf` (the peek-next-segment form); the earlier draft referencing the undefined `list_children_mut` is deleted. diff --git a/docs/superpowers/plans/2026-07-02-edgezero-state-extractor.md b/docs/superpowers/plans/2026-07-02-edgezero-state-extractor.md new file mode 100644 index 00000000..dcab2524 --- /dev/null +++ b/docs/superpowers/plans/2026-07-02-edgezero-state-extractor.md @@ -0,0 +1,789 @@ +# EdgeZero `State` Extractor Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add a `State` extractor plus `RouterBuilder::with_state` so any EdgeZero app can hand app-owned shared state (typically `Arc`) to `#[action]` extractor-style handlers. + +**Architecture:** Mirror PR #300's introspection-injection mechanism. `RouterBuilder::with_state` records a type-erased closure that clones the value into `request.extensions_mut()`. `RouterInner::dispatch` runs those closures on the owned request just before `RequestContext::new`, right after the existing introspection inserts. A new `State: FromRequest` extractor reads the value back by type from request extensions — so `#[action]` composes it with zero macro changes. + +**Tech Stack:** Rust 1.95, edition 2021. `edgezero-core` (WASM-compatible: `async-trait(?Send)`, no Tokio), `edgezero-macros` (proc macros), `http` crate via the `crate::http` facade only. + +## Base branch + +- **Implementation branch:** `worktree-state-nested-secrets-spec-review`. +- **Base:** **PR #300** ("pluggable introspection routes", branch `worktree-feature+introspection-routes`, head `2efa2da`) has already been **merged into this branch** (merge commit `051a9ad`). Every router line number below is from that merged tree and was verified live (`RouterBuilder` at `router.rs:71`, `build(self)` at `:110`, `with_manifest_json` at `:192`, `RouterInner` at `:198`, `dispatch(&self, mut request)` at `:206`, `RequestContext::new(request, params)` at `:227`, `RouterService::new` at `:297`). If the branch is later rebased and these drift, re-confirm before editing. +- This plan shares its branch with the sibling **nested `#[secret]`** plan (`2026-07-02-edgezero-nested-secrets.md`). The only file both touch is `crates/edgezero-core/src/extractor.rs`, in disjoint regions (this plan appends the `State` extractor; the other rewrites `secret_walk`). Either order is safe. + +## Global Constraints + +- **Rust 1.95.0**, edition 2021, resolver 2 (from `.tool-versions` / root `Cargo.toml`). +- **WASM-compat:** no Tokio, no `std::time::Instant`; extractors use `#[async_trait(?Send)]`. Async tests use `futures::executor::block_on`, never Tokio. +- **HTTP facade:** never import from the `http` crate directly. Use `crate::http::{...}` (the `Extensions` alias is `crate::http::Extensions`, defined at `crates/edgezero-core/src/http.rs:25`). +- **Colocate tests** in `#[cfg(test)] mod tests` in the same file as the implementation. +- **CI gates (all must pass):** + 1. `cargo fmt --all -- --check` + 2. `cargo clippy --workspace --all-targets --all-features -- -D warnings` + 3. `cargo test --workspace --all-targets` + 4. `cargo check --workspace --all-targets --features "fastly cloudflare spin"` + 5. `cargo check -p edgezero-adapter-spin --target wasm32-wasip2 --features spin` +- **Naming decision (locked):** ship `State` + `with_state` only. Do **not** add an `Extension` alias or a `RequestContext::state::()` accessor (YAGNI; trusted-server needs neither). No crate-root `pub use` of `State` — consumers reference `edgezero_core::extractor::State` (matches how every other extractor is reached today). + +--- + +## File Structure + +| File | Responsibility | Change | +| ---- | -------------- | ------ | +| `crates/edgezero-core/src/extractor.rs` | The `State` extractor + `Deref`/`DerefMut`/`into_inner` + unit tests | Modify (append) | +| `crates/edgezero-core/src/router.rs` | `StateInserter` alias, `RouterBuilder::with_state`, thread `state_inserters` through `build()` → `RouterService::new` → `RouterInner`, apply in `dispatch` + router tests | Modify | +| `crates/edgezero-macros/tests/action_state.rs` | Integration test proving `#[action]` composes `State` with `Query` end-to-end | Create | +| `crates/edgezero-macros/Cargo.toml` | Add `futures` dev-dependency (for `block_on` in the integration test) | Modify | +| `docs/guide/handlers.md` | "Sharing app state" section | Modify (append) | + +--- + +## Task 1: `State` extractor + +**Files:** +- Modify: `crates/edgezero-core/src/extractor.rs` (append extractor after the existing extractors; append tests inside the existing `#[cfg(test)] mod tests` at the end of the file) + +**Interfaces:** +- Consumes: `crate::context::RequestContext` (has `pub(crate) fn extension(&self) -> Option where T: Clone + Send + Sync + 'static` at `context.rs:77`), `crate::error::EdgeError` (`EdgeError::internal(anyhow::Error) -> 500`; `err.status() -> StatusCode`), the `FromRequest` trait (`extractor.rs:21`), `std::ops::{Deref, DerefMut}` (already imported at `extractor.rs:1`). +- Produces: `pub struct State(pub T)` with `impl FromRequest for State`, plus `Deref`/`DerefMut`/`into_inner`. Consumed by Task 2 (router tests) and Task 3 (macro composition). + +- [ ] **Step 1: Write the failing tests** + +Append to the `#[cfg(test)] mod tests` block at the end of `crates/edgezero-core/src/extractor.rs`. The module already imports `request_builder, Method, StatusCode` (from `crate::http`), `RequestContext`, `PathParams`, `Body`, `block_on`, and `std::sync::Arc`. + +```rust + #[derive(Clone, Debug, PartialEq)] + struct AppStateFixture { + name: String, + } + + #[test] + fn state_extractor_resolves_registered_value() { + let mut request = request_builder() + .method(Method::GET) + .uri("/") + .body(Body::empty()) + .expect("request"); + request.extensions_mut().insert(Arc::new(AppStateFixture { + name: "demo".to_owned(), + })); + let ctx = RequestContext::new(request, PathParams::default()); + + let state = block_on(State::>::from_request(&ctx)) + .expect("state present"); + + // Deref: State> -> Arc -> AppStateFixture + assert_eq!(state.name, "demo"); + } + + #[test] + fn state_extractor_missing_registration_is_internal_error() { + let request = request_builder() + .method(Method::GET) + .uri("/") + .body(Body::empty()) + .expect("request"); + let ctx = RequestContext::new(request, PathParams::default()); + + let err = block_on(State::>::from_request(&ctx)) + .expect_err("missing state must surface as an error, not a default"); + assert_eq!(err.status(), StatusCode::INTERNAL_SERVER_ERROR); + } + + #[test] + fn state_extractor_deref_and_into_inner() { + let mut request = request_builder() + .method(Method::GET) + .uri("/") + .body(Body::empty()) + .expect("request"); + request.extensions_mut().insert(AppStateFixture { + name: "x".to_owned(), + }); + let ctx = RequestContext::new(request, PathParams::default()); + + let state = + block_on(State::::from_request(&ctx)).expect("state present"); + assert_eq!( + *state, + AppStateFixture { + name: "x".to_owned() + } + ); // Deref + assert_eq!( + state.into_inner(), + AppStateFixture { + name: "x".to_owned() + } + ); + } +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `cargo test -p edgezero-core --lib state_extractor 2>&1 | tail -20` +Expected: FAIL — compile error `cannot find type/struct State in this scope` (the extractor does not exist yet). + +- [ ] **Step 3: Write the extractor** + +Insert into `crates/edgezero-core/src/extractor.rs` immediately after the `Kv` extractor block (after the `impl Kv { ... }` that ends around `extractor.rs:529`), before the next extractor. `anyhow` is already used in this file; `core::any::type_name` needs no import. + +```rust +/// Extractor for app-owned shared state registered via +/// [`RouterBuilder::with_state`]. Resolves by type from request extensions. +/// +/// Typically `T = Arc`. The registered value is cloned into every +/// request's extensions before dispatch; registering the same `T` twice is +/// last-write-wins. +/// +/// ```ignore +/// use edgezero_core::extractor::State; +/// use std::sync::Arc; +/// +/// #[edgezero_core::action] +/// async fn handle(State(state): State>) -> Result { +/// Ok(state.greeting.clone()) +/// } +/// ``` +/// +/// [`RouterBuilder::with_state`]: crate::router::RouterBuilder::with_state +pub struct State(pub T); + +#[async_trait(?Send)] +impl FromRequest for State +where + T: Clone + Send + Sync + 'static, +{ + #[inline] + async fn from_request(ctx: &RequestContext) -> Result { + ctx.extension::().map(State).ok_or_else(|| { + EdgeError::internal(anyhow::anyhow!( + "no `State<{}>` registered -- call RouterBuilder::with_state(..) before build()", + core::any::type_name::() + )) + }) + } +} + +impl Deref for State { + type Target = T; + + #[inline] + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl DerefMut for State { + #[inline] + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.0 + } +} + +impl State { + /// Consume the extractor and return the inner value. + #[inline] + pub fn into_inner(self) -> T { + self.0 + } +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `cargo test -p edgezero-core --lib state_extractor 2>&1 | tail -20` +Expected: PASS — 3 tests (`state_extractor_resolves_registered_value`, `state_extractor_missing_registration_is_internal_error`, `state_extractor_deref_and_into_inner`). + +- [ ] **Step 5: Lint** + +Run: `cargo clippy -p edgezero-core --all-targets --all-features -- -D warnings 2>&1 | tail -20` +Expected: no warnings. + +- [ ] **Step 6: Commit** + +```bash +git add crates/edgezero-core/src/extractor.rs +git commit -m "feat(core): add State extractor for app-owned shared state" +``` + +--- + +## Task 2: `RouterBuilder::with_state` + dispatch plumbing + +**Files:** +- Modify: `crates/edgezero-core/src/router.rs` (add `StateInserter` alias, `state_inserters` field on `RouterBuilder` and `RouterInner`, `with_state` method, 5th arg through `build()`/`RouterService::new`, apply in `dispatch`; add router tests in the existing `#[cfg(test)] mod tests`) + +**Interfaces:** +- Consumes: `State` from Task 1 (`crate::extractor::State`), `crate::http::Extensions` (facade alias), `std::sync::Arc` (imported at `router.rs:2`). +- Produces: `RouterBuilder::with_state(self, value: T) -> Self where T: Clone + Send + Sync + 'static`. Consumed by Task 3. + +- [ ] **Step 1: Write the failing router tests** + +Append to `crates/edgezero-core/src/router.rs`'s main `#[cfg(test)] mod tests` (the block whose imports are at `router.rs:476`, which already imports `Arc, Mutex`, `block_on`, `noop_waker_ref`, `Context, Poll`, `request_builder, Method, StatusCode`, `Body`, `RequestContext`, `EdgeError`). + +```rust + #[test] + fn with_state_exposes_value_to_handler() { + use crate::extractor::{FromRequest as _, State}; + + #[derive(Clone)] + struct Counter(u32); + + async fn handler(ctx: RequestContext) -> Result { + let State(counter) = State::::from_request(&ctx).await?; + Ok(format!("count={}", counter.0)) + } + + let service = RouterService::builder() + .with_state(Counter(9)) + .get("/count", handler) + .build(); + + let request = request_builder() + .method(Method::GET) + .uri("/count") + .body(Body::empty()) + .expect("request"); + + let response = block_on(service.oneshot(request)).expect("response"); + assert_eq!(response.status(), StatusCode::OK); + assert_eq!(response.body().as_bytes().expect("buffered"), b"count=9"); + } + + #[test] + fn with_state_supports_multiple_distinct_types() { + use crate::extractor::{FromRequest as _, State}; + + #[derive(Clone)] + struct A(u32); + #[derive(Clone)] + struct B(&'static str); + + async fn handler(ctx: RequestContext) -> Result { + let State(a) = State::::from_request(&ctx).await?; + let State(b) = State::::from_request(&ctx).await?; + Ok(format!("{}-{}", a.0, b.0)) + } + + let service = RouterService::builder() + .with_state(A(7)) + .with_state(B("hi")) + .get("/both", handler) + .build(); + + let request = request_builder() + .method(Method::GET) + .uri("/both") + .body(Body::empty()) + .expect("request"); + + let response = block_on(service.oneshot(request)).expect("response"); + assert_eq!(response.body().as_bytes().expect("buffered"), b"7-hi"); + } + + #[test] + fn with_state_same_type_is_last_write_wins() { + use crate::extractor::{FromRequest as _, State}; + + #[derive(Clone)] + struct Counter(u32); + + async fn handler(ctx: RequestContext) -> Result { + let State(counter) = State::::from_request(&ctx).await?; + Ok(format!("count={}", counter.0)) + } + + let service = RouterService::builder() + .with_state(Counter(1)) + .with_state(Counter(2)) + .get("/c", handler) + .build(); + + let request = request_builder() + .method(Method::GET) + .uri("/c") + .body(Body::empty()) + .expect("request"); + + let response = block_on(service.oneshot(request)).expect("response"); + assert_eq!(response.body().as_bytes().expect("buffered"), b"count=2"); + } + + #[test] + fn with_state_no_cross_request_bleed() { + use crate::extractor::{FromRequest as _, State}; + use std::future::Future as _; + + #[derive(Clone)] + struct Tag(&'static str); + + async fn handler(ctx: RequestContext) -> Result { + let State(tag) = State::::from_request(&ctx).await?; + Ok(tag.0.to_owned()) + } + + let service = RouterService::builder() + .with_state(Tag("shared")) + .get("/t", handler) + .build(); + + let req1 = request_builder() + .method(Method::GET) + .uri("/t") + .body(Body::empty()) + .expect("req1"); + let req2 = request_builder() + .method(Method::GET) + .uri("/t") + .body(Body::empty()) + .expect("req2"); + + // Two independent in-flight requests, polled interleaved on one thread. + let mut f1 = Box::pin(service.oneshot(req1)); + let mut f2 = Box::pin(service.oneshot(req2)); + let mut cx = Context::from_waker(noop_waker_ref()); + + let mut r1 = None; + let mut r2 = None; + while r1.is_none() || r2.is_none() { + if r1.is_none() { + if let Poll::Ready(v) = f1.as_mut().poll(&mut cx) { + r1 = Some(v); + } + } + if r2.is_none() { + if let Poll::Ready(v) = f2.as_mut().poll(&mut cx) { + r2 = Some(v); + } + } + } + + let resp1 = r1.unwrap().expect("resp1"); + let resp2 = r2.unwrap().expect("resp2"); + assert_eq!(resp1.body().as_bytes().expect("buffered"), b"shared"); + assert_eq!(resp2.body().as_bytes().expect("buffered"), b"shared"); + } +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `cargo test -p edgezero-core --lib with_state 2>&1 | tail -20` +Expected: FAIL — `no method named with_state found for struct RouterBuilder`. + +- [ ] **Step 3: Add the `StateInserter` type alias** + +In `crates/edgezero-core/src/router.rs`, add just above `pub struct RouterBuilder` (which is at `router.rs:71`, under its `#[derive(Default)]` at `router.rs:70`): + +```rust +/// Type-erased closure that clones a registered state value into a request's +/// extensions at dispatch. See [`RouterBuilder::with_state`]. +type StateInserter = Arc; +``` + +- [ ] **Step 4: Add the `state_inserters` field to `RouterBuilder`** + +Change the struct at `router.rs:70-76` from: + +```rust +#[derive(Default)] +pub struct RouterBuilder { + manifest_json: Option>, + middlewares: Vec, + route_info: Vec, + routes: HashMap>, +} +``` + +to: + +```rust +#[derive(Default)] +pub struct RouterBuilder { + manifest_json: Option>, + middlewares: Vec, + route_info: Vec, + routes: HashMap>, + state_inserters: Vec, +} +``` + +- [ ] **Step 5: Add the `with_state` method** + +In the `impl RouterBuilder` block, add immediately after `with_manifest_json` (which is at `router.rs:190-195`): + +```rust + /// Register a value cloned into every request's extensions before + /// dispatch, making it available to the [`State`] extractor and to + /// `RequestContext`-based handlers. + /// + /// Typically `T = Arc`. Registering the same `T` twice is + /// last-write-wins. Cost is one `T::clone` (an `Arc` bump for + /// `Arc`) per registered state per request. + /// + /// [`State`]: crate::extractor::State + #[must_use] + #[inline] + pub fn with_state(mut self, value: T) -> Self + where + T: Clone + Send + Sync + 'static, + { + self.state_inserters + .push(Arc::new(move |ext: &mut crate::http::Extensions| { + ext.insert(value.clone()); + })); + self + } +``` + +- [ ] **Step 6: Thread `state_inserters` through `build()`** + +Change `build()` at `router.rs:108-119` from: + +```rust + pub fn build(self) -> RouterService { + let route_index: Arc<[RouteInfo]> = Arc::from(self.route_info); + + RouterService::new( + self.routes, + self.middlewares, + route_index, + self.manifest_json, + ) + } +``` + +to (add the 5th argument): + +```rust + pub fn build(self) -> RouterService { + let route_index: Arc<[RouteInfo]> = Arc::from(self.route_info); + + RouterService::new( + self.routes, + self.middlewares, + route_index, + self.manifest_json, + self.state_inserters, + ) + } +``` + +- [ ] **Step 7: Add the field to `RouterInner` and the param to `RouterService::new`** + +Change `RouterInner` at `router.rs:198-203` from: + +```rust +struct RouterInner { + manifest_json: Option>, + middlewares: Vec, + route_index: Arc<[RouteInfo]>, + routes: HashMap>, +} +``` + +to: + +```rust +struct RouterInner { + manifest_json: Option>, + middlewares: Vec, + route_index: Arc<[RouteInfo]>, + routes: HashMap>, + state_inserters: Vec, +} +``` + +Change `RouterService::new` at `router.rs:297-311` from: + +```rust + fn new( + routes: HashMap>, + middlewares: Vec, + route_index: Arc<[RouteInfo]>, + manifest_json: Option>, + ) -> Self { + Self { + inner: Arc::new(RouterInner { + manifest_json, + middlewares, + route_index, + routes, + }), + } + } +``` + +to: + +```rust + fn new( + routes: HashMap>, + middlewares: Vec, + route_index: Arc<[RouteInfo]>, + manifest_json: Option>, + state_inserters: Vec, + ) -> Self { + Self { + inner: Arc::new(RouterInner { + manifest_json, + middlewares, + route_index, + routes, + state_inserters, + }), + } + } +``` + +- [ ] **Step 8: Apply the inserters in `dispatch`** + +In `RouterInner::dispatch` (`router.rs:206-237`), inside the `RouteMatch::Found(entry, params)` arm, add the state-insertion loop after the `needs.routes` block and before `let ctx = RequestContext::new(request, params);` (currently `router.rs:227`). The arm becomes: + +```rust + RouteMatch::Found(entry, params) => { + // Inject only the introspection payloads this route asked for — + // nothing for the vast majority of routes that need none. + let needs = entry.introspection_needs; + if needs.manifest { + if let Some(json) = &self.manifest_json { + request + .extensions_mut() + .insert(ManifestJson(Arc::clone(json))); + } + } + if needs.routes { + request + .extensions_mut() + .insert(RouteTable(Arc::clone(&self.route_index))); + } + // App-owned state registered via RouterBuilder::with_state. + // Runs after introspection inserts; distinct TypeIds, so no + // collision. Last registered wins for a given `T`. + for inserter in &self.state_inserters { + inserter(request.extensions_mut()); + } + let ctx = RequestContext::new(request, params); + let next = Next::new(&self.middlewares, entry.handler.as_ref()); + next.run(ctx).await + } +``` + +- [ ] **Step 9: Run tests to verify they pass** + +Run: `cargo test -p edgezero-core --lib with_state 2>&1 | tail -20` +Expected: PASS — 4 tests (`with_state_exposes_value_to_handler`, `with_state_supports_multiple_distinct_types`, `with_state_same_type_is_last_write_wins`, `with_state_no_cross_request_bleed`). + +- [ ] **Step 10: Full crate test + lint** + +Run: `cargo test -p edgezero-core 2>&1 | tail -20 && cargo clippy -p edgezero-core --all-targets --all-features -- -D warnings 2>&1 | tail -20` +Expected: all existing + new tests PASS; clippy clean (proves the router restructure did not regress introspection tests). + +- [ ] **Step 11: Commit** + +```bash +git add crates/edgezero-core/src/router.rs +git commit -m "feat(core): RouterBuilder::with_state injects app state into request extensions" +``` + +--- + +## Task 3: `#[action]` composition integration test + docs + +**Files:** +- Create: `crates/edgezero-macros/tests/action_state.rs` +- Modify: `crates/edgezero-macros/Cargo.toml` (add `futures` dev-dependency) +- Modify: `docs/guide/handlers.md` (append "Sharing app state" section) + +**Interfaces:** +- Consumes: `State` (Task 1), `RouterBuilder::with_state` (Task 2), `#[action]` (unchanged — `crates/edgezero-macros/src/action.rs:183` emits `<#ty as ::edgezero_core::extractor::FromRequest>::from_request(&__ctx).await?` for every non-`RequestContext` arg), `RouterService::oneshot` (`router.rs:316`), `Query` extractor (`edgezero_core::extractor::Query`). +- Produces: nothing consumed downstream; this is the acceptance proof that the macro composes `State` with another extractor. + +- [ ] **Step 1: Add the `futures` dev-dependency to the macros crate** + +Confirm `futures` is a workspace dependency: + +Run: `grep -n 'futures = ' Cargo.toml` +Expected: a line like `futures = { version = "0.3", features = ["std", "executor"] }` under `[workspace.dependencies]`. + +Then edit `crates/edgezero-macros/Cargo.toml`'s `[dev-dependencies]` (currently `edgezero-core`, `tempfile`, `trybuild`) to add `futures`: + +```toml +[dev-dependencies] +# `edgezero-core` re-exports `AppConfig`; the derive tests assert +# against the trait/types over the re-export path the way downstream +# users will. Cargo allows dev-dep cycles (only the main dep edge +# matters for build ordering). +edgezero-core = { workspace = true } +futures = { workspace = true } +tempfile = { workspace = true } +trybuild = { workspace = true } +``` + +(If `grep` shows `futures` is not workspace-managed, use `futures = "0.3"` with `features = ["std", "executor"]` instead.) + +- [ ] **Step 2: Write the failing integration test** + +Create `crates/edgezero-macros/tests/action_state.rs`: + +```rust +//! Integration coverage: `#[action]` composes the `State` extractor with a +//! request-derived extractor (`Query`) and runs end-to-end through the +//! router. Lives in `edgezero-macros/tests` because the `#[action]` macro +//! emits absolute `::edgezero_core::…` paths that only resolve when +//! `edgezero_core` is an external crate (as it is here, via the dev-dep). + +#[cfg(test)] +mod tests { + use edgezero_core::action; + use edgezero_core::body::Body; + use edgezero_core::error::EdgeError; + use edgezero_core::extractor::{Query, State}; + use edgezero_core::http::{request_builder, Method, StatusCode}; + use edgezero_core::router::RouterService; + use futures::executor::block_on; + use serde::Deserialize; + use std::sync::Arc; + + #[derive(Clone)] + struct AppState { + greeting: String, + } + + #[derive(Deserialize)] + struct Params { + n: u32, + } + + #[action] + async fn handler( + State(state): State>, + Query(params): Query, + ) -> Result { + Ok(format!("{}:{}", state.greeting, params.n)) + } + + #[test] + fn action_composes_state_and_query() { + let service = RouterService::builder() + .with_state(Arc::new(AppState { + greeting: "hi".to_owned(), + })) + .get("/h", handler) + .build(); + + let request = request_builder() + .method(Method::GET) + .uri("/h?n=5") + .body(Body::empty()) + .expect("request"); + + let response = block_on(service.oneshot(request)).expect("response"); + assert_eq!(response.status(), StatusCode::OK); + assert_eq!(response.body().as_bytes().expect("buffered"), b"hi:5"); + } +} +``` + +- [ ] **Step 3: Run the integration test** + +Run: `cargo test -p edgezero-macros --test action_state 2>&1 | tail -20` +Expected: PASS — `action_composes_state_and_query`. (This simultaneously proves the macro needs no change: `State` is dispatched by the same generic `FromRequest` line as `Query`.) + +- [ ] **Step 4: Add the docs section** + +Append to `docs/guide/handlers.md` a new section (place it after the existing extractor documentation, before any "Next steps"/footer): + +```markdown +## Sharing app state + +Request-derived extractors (`Json`, `Query`, `Path`, …) cover per-request data. +For app-owned state that outlives a single request — a settings object, a +connection registry, an orchestrator — register it once on the router and read +it back with the `State` extractor. + +Register the value with `RouterBuilder::with_state`. It is cloned into every +request's extensions before dispatch, so `T` must be `Clone + Send + Sync + +'static` — typically an `Arc`, where the clone is a cheap refcount +bump: + +```rust +use std::sync::Arc; +use edgezero_core::extractor::State; +use edgezero_core::router::RouterService; + +#[derive(Clone)] +struct AppState { + greeting: String, +} + +let state = Arc::new(AppState { greeting: "hello".into() }); + +let service = RouterService::builder() + .with_state(Arc::clone(&state)) + .get("/greet", greet) + .build(); +``` + +Read it in any `#[action]` handler by adding a `State` argument — it composes +with the other extractors: + +```rust +use edgezero_core::{action, error::EdgeError}; +use edgezero_core::extractor::{Query, State}; +use std::sync::Arc; + +#[action] +async fn greet( + State(state): State>, +) -> Result { + Ok(state.greeting.clone()) +} +``` + +Register different types independently (`with_state(a).with_state(b)`); each is +resolved by its own type. Registering the same `T` twice is last-write-wins. If +a handler asks for a `State` that was never registered, extraction fails with +a `500` — register it before `build()`. +``` + +- [ ] **Step 5: Full verification** + +Run: `cargo test --workspace --all-targets 2>&1 | tail -20` +Expected: PASS. + +Run: `cargo fmt --all -- --check && cargo clippy --workspace --all-targets --all-features -- -D warnings 2>&1 | tail -20` +Expected: formatted; clippy clean. + +Run: `cargo check --workspace --all-targets --features "fastly cloudflare spin" 2>&1 | tail -5 && cargo check -p edgezero-adapter-spin --target wasm32-wasip2 --features spin 2>&1 | tail -5` +Expected: both succeed (proves the core change is WASM-clean across adapters). + +- [ ] **Step 6: Commit** + +```bash +git add crates/edgezero-macros/tests/action_state.rs crates/edgezero-macros/Cargo.toml docs/guide/handlers.md +git commit -m "test(macros): prove #[action] composes State; docs: sharing app state" +``` + +--- + +## Acceptance criteria + +1. `cargo fmt` / clippy clean across `edgezero-core`, `edgezero-macros`, all adapters. +2. New unit tests (Task 1: 3), router tests (Task 2: 4), and the integration test (Task 3: 1) pass. +3. `cargo test --workspace --all-targets` green; PR #300's introspection tests still pass (proves `with_state` is additive to the injection mechanism). +4. WASM checks (`fastly cloudflare spin`; spin `wasm32-wasip2`) succeed. +5. Rustdoc on `State`, `with_state`, and the `docs/guide/handlers.md` section merged. + +## Self-review notes (mapping to spec §3) + +- §3.1 `State` extractor + `Deref`/`into_inner` → Task 1. +- §3.2 router plumbing (`state_inserters` field, `with_state`, dispatch insertion) → Task 2, mirroring PR #300's `manifest_json` column exactly. +- §3.3 naming → `State` only (no `Extension` alias), per locked decision. +- §3.4 tests: resolves registered / 500 unregistered / Deref (Task 1); handler sees value / two `T`s coexist / last-write-wins (Task 2); `#[action]` composition (Task 3); concurrency/no-bleed (Task 2). +- §3.5 docs: `docs/guide/handlers.md` + rustdoc → Task 3. +- §8 corrections folded in: facade `crate::http::Extensions` (not bare `http::Extensions`); no `lib.rs` re-export; `state_inserters` threaded through `RouterInner` + `RouterService::new` + `build()`. diff --git a/docs/superpowers/specs/2026-07-02-edgezero-state-and-nested-secrets-design.md b/docs/superpowers/specs/2026-07-02-edgezero-state-and-nested-secrets-design.md index cc48e80a..0be27549 100644 --- a/docs/superpowers/specs/2026-07-02-edgezero-state-and-nested-secrets-design.md +++ b/docs/superpowers/specs/2026-07-02-edgezero-state-and-nested-secrets-design.md @@ -254,24 +254,27 @@ Today this fails to compile (`enforce_scalar_string_type` rejects the containing ### 4.2 Metadata model: path-qualified secret fields -Extend `SecretField` (`crates/edgezero-core/src/app_config.rs`) to carry a **path** instead of a single `name`. The path is a sequence of segments; each segment is either a named field or an array wildcard: +Extend `SecretField` (`crates/edgezero-core/src/app_config.rs`) to carry a **path** instead of a single `name`. The path is a sequence of segments; each segment is either a named field or an array wildcard. **Segments are owned** (`Cow<'static, str>` / `Vec<_>`), not `&'static`, and the field carries an `optional` flag — both settled by §8 (cross-crate recursion cannot build `&'static` paths, and the runtime must tell "required-missing → error" from "optional-absent → skip"): ```rust pub enum SecretPathSegment { - Field(&'static str), // object key (Rust field name, verbatim) - ArrayEach, // every element of an array + Field(std::borrow::Cow<'static, str>), // object key (Rust field name, verbatim) + ArrayEach, // every element of an array } pub struct SecretField { pub kind: SecretKind, - pub path: &'static [SecretPathSegment], // was: name: &'static str + pub path: Vec, // was: name: &'static str + pub optional: bool, // #[secret] on Option } ``` -- A top-level scalar keeps working: its path is `&[Field("api_token")]` (length 1) — **backward compatible in behavior**, though the struct field changes shape (see 4.6 for the compat call-out). +Because paths are owned and must compose across crates, `AppConfigMeta` changes from an associated `const SECRET_FIELDS` to a method `fn secret_fields() -> Vec` (§8 / B-3): a parent prepends its `Field`/`ArrayEach` segment onto each child's `secret_fields()`. + +- A top-level scalar keeps working: its path is `vec![Field("api_token")]` (length 1) — **backward compatible in behavior**, though the struct/trait shape changes (see 4.6 for the compat call-out). - `store_ref` siblings referenced by `SecretKind::KeyInNamedStore { store_ref_field }` are resolved **relative to the same parent object** as the secret field (i.e. sibling within the innermost containing object). Document this scoping rule explicitly. -**Open design question (B-1):** array support (`ArrayEach`) adds real complexity to both the derive and the walk. If trusted-server's Phase 3 audit finds **no secrets inside arrays** (only nested objects), we can ship object-only nesting first (`SecretPathSegment::Field` only) and defer `ArrayEach`. Recommendation: **confirm the secret inventory against `Settings` before implementing arrays**; design the enum to allow `ArrayEach` from day one but gate its implementation on a real need. +**Array support (B-1) — resolved: implement `ArrayEach` from day one.** The problem statement's own inventory includes an array secret (`partners[*].api_key`), and §8 [B, HIGH] requires an object-only plan *only* if trusted-server's `Settings` audit confirms no secret leaves inside arrays. Absent that confirmation, arrays are in scope from the start; the derive and the runtime walk both handle `ArrayEach` (see the implementation plan `docs/superpowers/plans/2026-07-02-edgezero-nested-secrets.md`). ### 4.3 Derive changes (`crates/edgezero-macros/src/app_config.rs`) @@ -374,9 +377,8 @@ Replace the top-level-only loop with a **path navigator**: ## 7. Files to touch (edgezero repo) **Workstream A** -- `crates/edgezero-core/src/router.rs` — `RouterBuilder::with_state`, `RouterInner.state_inserters`, dispatch insertion. -- `crates/edgezero-core/src/extractor.rs` — `State` extractor (+ `Deref`/`into_inner`). -- `crates/edgezero-core/src/lib.rs` — re-export `State`. +- `crates/edgezero-core/src/router.rs` — `RouterBuilder::with_state`, `RouterInner.state_inserters`, dispatch insertion (before `RequestContext::new`, alongside the introspection injects from PR #300). +- `crates/edgezero-core/src/extractor.rs` — `State` extractor (+ `Deref`/`DerefMut`/`into_inner`). Making `State` `pub` here is sufficient; **no `lib.rs` crate-root re-export** (§8 [A, minor] — no extractor is re-exported at the crate root today; consumers use `edgezero_core::extractor::State`). - `docs/guide/handlers.md`. **Workstream B** From d51e8b900906fd8b6c4ef59ce22c106555825e34 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Thu, 2 Jul 2026 22:17:05 -0700 Subject: [PATCH 33/74] feat(core): add State extractor for app-owned shared state --- crates/edgezero-core/src/extractor.rs | 129 ++++++++++++++++++++++++++ 1 file changed, 129 insertions(+) diff --git a/crates/edgezero-core/src/extractor.rs b/crates/edgezero-core/src/extractor.rs index 9bb4086f..bba2c489 100644 --- a/crates/edgezero-core/src/extractor.rs +++ b/crates/edgezero-core/src/extractor.rs @@ -1,3 +1,4 @@ +use std::any; use std::ops::{Deref, DerefMut}; use async_trait::async_trait; @@ -528,6 +529,66 @@ impl Kv { } } +/// Extractor for app-owned shared state registered via +/// [`RouterBuilder::with_state`]. Resolves by type from request extensions. +/// +/// Typically `T = Arc`. The registered value is cloned into every +/// request's extensions before dispatch; registering the same `T` twice is +/// last-write-wins. +/// +/// ```ignore +/// use edgezero_core::extractor::State; +/// use std::sync::Arc; +/// +/// #[edgezero_core::action] +/// async fn handle(State(state): State>) -> Result { +/// Ok(state.greeting.clone()) +/// } +/// ``` +/// +/// [`RouterBuilder::with_state`]: crate::router::RouterBuilder::with_state +pub struct State(pub T); + +#[async_trait(?Send)] +impl FromRequest for State +where + T: Clone + Send + Sync + 'static, +{ + #[inline] + async fn from_request(ctx: &RequestContext) -> Result { + ctx.extension::().map(State).ok_or_else(|| { + EdgeError::internal(anyhow::anyhow!( + "no `State<{}>` registered -- call RouterBuilder::with_state(..) before build()", + any::type_name::() + )) + }) + } +} + +impl Deref for State { + type Target = T; + + #[inline] + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl DerefMut for State { + #[inline] + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.0 + } +} + +impl State { + /// Consume the extractor and return the inner value. + #[inline] + pub fn into_inner(self) -> T { + self.0 + } +} + /// Extractor that yields the per-request [`SecretRegistry`]. /// /// The returned [`BoundSecretStore`] is pre-bound to a platform store name @@ -991,6 +1052,11 @@ mod tests { use std::sync::Arc; use validator::Validate; + #[derive(Clone, Debug, PartialEq)] + struct AppStateFixture { + name: String, + } + #[derive(Debug, Deserialize, PartialEq)] struct FormData { age: Option, @@ -2400,4 +2466,67 @@ mod tests { ); } } + + #[test] + fn state_extractor_resolves_registered_value() { + let mut request = request_builder() + .method(Method::GET) + .uri("/") + .body(Body::empty()) + .expect("request"); + request.extensions_mut().insert(Arc::new(AppStateFixture { + name: "demo".to_owned(), + })); + let ctx = RequestContext::new(request, PathParams::default()); + + let state = + block_on(State::>::from_request(&ctx)).expect("state present"); + + // Deref: State> -> Arc -> AppStateFixture + assert_eq!(state.name, "demo"); + } + + #[test] + fn state_extractor_missing_registration_is_internal_error() { + let request = request_builder() + .method(Method::GET) + .uri("/") + .body(Body::empty()) + .expect("request"); + let ctx = RequestContext::new(request, PathParams::default()); + + // `.err().expect(..)` (not `expect_err`) so we don't require + // `State: Debug` — extractors here mirror Json/Path and omit it. + let err = block_on(State::>::from_request(&ctx)) + .err() + .expect("missing state must surface as an error, not a default"); + assert_eq!(err.status(), StatusCode::INTERNAL_SERVER_ERROR); + } + + #[test] + fn state_extractor_deref_and_into_inner() { + let mut request = request_builder() + .method(Method::GET) + .uri("/") + .body(Body::empty()) + .expect("request"); + request.extensions_mut().insert(AppStateFixture { + name: "x".to_owned(), + }); + let ctx = RequestContext::new(request, PathParams::default()); + + let state = block_on(State::::from_request(&ctx)).expect("state present"); + assert_eq!( + *state, + AppStateFixture { + name: "x".to_owned() + } + ); // Deref + assert_eq!( + state.into_inner(), + AppStateFixture { + name: "x".to_owned() + } + ); + } } From 44873558259f7de9099a17ef79dd732809c3a4ef Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Thu, 2 Jul 2026 22:21:20 -0700 Subject: [PATCH 34/74] feat(core): RouterBuilder::with_state injects app state into request extensions --- crates/edgezero-core/src/router.rs | 181 ++++++++++++++++++++++++++++- 1 file changed, 180 insertions(+), 1 deletion(-) diff --git a/crates/edgezero-core/src/router.rs b/crates/edgezero-core/src/router.rs index eae25a9f..b29398c5 100644 --- a/crates/edgezero-core/src/router.rs +++ b/crates/edgezero-core/src/router.rs @@ -8,7 +8,7 @@ use tower_service::Service; use crate::context::RequestContext; use crate::error::EdgeError; use crate::handler::{BoxHandler, IntoHandler, IntrospectionNeeds}; -use crate::http::{HandlerFuture, Method, Request, Response}; +use crate::http::{Extensions, HandlerFuture, Method, Request, Response}; use crate::introspection::{ManifestJson, RouteTable}; use crate::middleware::{BoxMiddleware, Middleware, Next}; use crate::params::PathParams; @@ -67,12 +67,17 @@ enum RouteMatch<'route> { NotFound, } +/// Type-erased closure that clones a registered state value into a request's +/// extensions at dispatch. See [`RouterBuilder::with_state`]. +type StateInserter = Arc; + #[derive(Default)] pub struct RouterBuilder { manifest_json: Option>, middlewares: Vec, route_info: Vec, routes: HashMap>, + state_inserters: Vec, } impl RouterBuilder { @@ -115,6 +120,7 @@ impl RouterBuilder { self.middlewares, route_index, self.manifest_json, + self.state_inserters, ) } @@ -193,6 +199,28 @@ impl RouterBuilder { self.manifest_json = Some(json.into()); self } + + /// Register a value cloned into every request's extensions before + /// dispatch, making it available to the [`State`] extractor and to + /// `RequestContext`-based handlers. + /// + /// Typically `T = Arc`. Registering the same `T` twice is + /// last-write-wins. Cost is one `T::clone` (an `Arc` bump for + /// `Arc`) per registered state per request. + /// + /// [`State`]: crate::extractor::State + #[must_use] + #[inline] + pub fn with_state(mut self, value: T) -> Self + where + T: Clone + Send + Sync + 'static, + { + self.state_inserters + .push(Arc::new(move |ext: &mut Extensions| { + ext.insert(value.clone()); + })); + self + } } struct RouterInner { @@ -200,6 +228,7 @@ struct RouterInner { middlewares: Vec, route_index: Arc<[RouteInfo]>, routes: HashMap>, + state_inserters: Vec, } impl RouterInner { @@ -224,6 +253,12 @@ impl RouterInner { .extensions_mut() .insert(RouteTable(Arc::clone(&self.route_index))); } + // App-owned state registered via RouterBuilder::with_state. + // Runs after introspection inserts; distinct TypeIds, so no + // collision. Last registered wins for a given `T`. + for inserter in &self.state_inserters { + inserter(request.extensions_mut()); + } let ctx = RequestContext::new(request, params); let next = Next::new(&self.middlewares, entry.handler.as_ref()); next.run(ctx).await @@ -299,6 +334,7 @@ impl RouterService { middlewares: Vec, route_index: Arc<[RouteInfo]>, manifest_json: Option>, + state_inserters: Vec, ) -> Self { Self { inner: Arc::new(RouterInner { @@ -306,6 +342,7 @@ impl RouterService { middlewares, route_index, routes, + state_inserters, }), } } @@ -772,4 +809,146 @@ mod tests { }); assert_eq!(collected, b"chunk-one\nchunk-two\n"); } + + #[test] + fn with_state_exposes_value_to_handler() { + use crate::extractor::{FromRequest as _, State}; + + #[derive(Clone)] + struct Counter(u32); + + async fn handler(ctx: RequestContext) -> Result { + let State(counter) = State::::from_request(&ctx).await?; + Ok(format!("count={}", counter.0)) + } + + let service = RouterService::builder() + .with_state(Counter(9)) + .get("/count", handler) + .build(); + + let request = request_builder() + .method(Method::GET) + .uri("/count") + .body(Body::empty()) + .expect("request"); + + let response = block_on(service.oneshot(request)).expect("response"); + assert_eq!(response.status(), StatusCode::OK); + assert_eq!(response.body().as_bytes().expect("buffered"), b"count=9"); + } + + #[test] + fn with_state_last_write_wins_for_same_type() { + use crate::extractor::{FromRequest as _, State}; + + #[derive(Clone)] + struct Counter(u32); + + async fn handler(ctx: RequestContext) -> Result { + let State(counter) = State::::from_request(&ctx).await?; + Ok(format!("count={}", counter.0)) + } + + let service = RouterService::builder() + .with_state(Counter(1)) + .with_state(Counter(2)) + .get("/c", handler) + .build(); + + let request = request_builder() + .method(Method::GET) + .uri("/c") + .body(Body::empty()) + .expect("request"); + + let response = block_on(service.oneshot(request)).expect("response"); + assert_eq!(response.body().as_bytes().expect("buffered"), b"count=2"); + } + + #[test] + fn with_state_no_cross_request_bleed() { + use crate::extractor::{FromRequest as _, State}; + use std::future::Future as _; + + #[derive(Clone)] + struct Tag(&'static str); + + async fn handler(ctx: RequestContext) -> Result { + let State(tag) = State::::from_request(&ctx).await?; + Ok(tag.0.to_owned()) + } + + let service = RouterService::builder() + .with_state(Tag("shared")) + .get("/t", handler) + .build(); + + let req1 = request_builder() + .method(Method::GET) + .uri("/t") + .body(Body::empty()) + .expect("req1"); + let req2 = request_builder() + .method(Method::GET) + .uri("/t") + .body(Body::empty()) + .expect("req2"); + + // Two independent in-flight requests, polled interleaved on one thread. + let mut f1 = Box::pin(service.oneshot(req1)); + let mut f2 = Box::pin(service.oneshot(req2)); + let mut cx = Context::from_waker(noop_waker_ref()); + + let mut r1 = None; + let mut r2 = None; + while r1.is_none() || r2.is_none() { + if r1.is_none() { + if let Poll::Ready(value) = f1.as_mut().poll(&mut cx) { + r1 = Some(value); + } + } + if r2.is_none() { + if let Poll::Ready(value) = f2.as_mut().poll(&mut cx) { + r2 = Some(value); + } + } + } + + let resp1 = r1.unwrap().expect("resp1"); + let resp2 = r2.unwrap().expect("resp2"); + assert_eq!(resp1.body().as_bytes().expect("buffered"), b"shared"); + assert_eq!(resp2.body().as_bytes().expect("buffered"), b"shared"); + } + + #[test] + fn with_state_supports_multiple_distinct_types() { + use crate::extractor::{FromRequest as _, State}; + + #[derive(Clone)] + struct First(u32); + #[derive(Clone)] + struct Second(&'static str); + + async fn handler(ctx: RequestContext) -> Result { + let State(first) = State::::from_request(&ctx).await?; + let State(second) = State::::from_request(&ctx).await?; + Ok(format!("{}-{}", first.0, second.0)) + } + + let service = RouterService::builder() + .with_state(First(7)) + .with_state(Second("hi")) + .get("/both", handler) + .build(); + + let request = request_builder() + .method(Method::GET) + .uri("/both") + .body(Body::empty()) + .expect("request"); + + let response = block_on(service.oneshot(request)).expect("response"); + assert_eq!(response.body().as_bytes().expect("buffered"), b"7-hi"); + } } From 21c148863f192644fae6948d03201594b6b9aef2 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Thu, 2 Jul 2026 22:31:54 -0700 Subject: [PATCH 35/74] test(macros): prove #[action] composes State; docs: sharing app state --- crates/edgezero-macros/Cargo.toml | 1 + crates/edgezero-macros/tests/action_state.rs | 56 ++++++++++++++++++++ docs/guide/handlers.md | 52 ++++++++++++++++++ 3 files changed, 109 insertions(+) create mode 100644 crates/edgezero-macros/tests/action_state.rs diff --git a/crates/edgezero-macros/Cargo.toml b/crates/edgezero-macros/Cargo.toml index bb584cd5..81f0b970 100644 --- a/crates/edgezero-macros/Cargo.toml +++ b/crates/edgezero-macros/Cargo.toml @@ -27,5 +27,6 @@ validator = { workspace = true, features = ["derive"] } # users will. Cargo allows dev-dep cycles (only the main dep edge # matters for build ordering). edgezero-core = { workspace = true } +futures = { workspace = true } tempfile = { workspace = true } trybuild = { workspace = true } diff --git a/crates/edgezero-macros/tests/action_state.rs b/crates/edgezero-macros/tests/action_state.rs new file mode 100644 index 00000000..1c999105 --- /dev/null +++ b/crates/edgezero-macros/tests/action_state.rs @@ -0,0 +1,56 @@ +//! Integration coverage: `#[action]` composes the `State` extractor with a +//! request-derived extractor (`Query`) and runs end-to-end through the +//! router. Lives in `edgezero-macros/tests` because the `#[action]` macro +//! emits absolute `::edgezero_core::…` paths that only resolve when +//! `edgezero_core` is an external crate (as it is here, via the dev-dep). + +#[cfg(test)] +mod tests { + use edgezero_core::action; + use edgezero_core::body::Body; + use edgezero_core::error::EdgeError; + use edgezero_core::extractor::{Query, State}; + use edgezero_core::http::{request_builder, Method, StatusCode}; + use edgezero_core::router::RouterService; + use futures::executor::block_on; + use serde::Deserialize; + use std::sync::Arc; + + #[derive(Clone)] + struct AppState { + greeting: String, + } + + #[derive(Deserialize)] + struct Params { + n: u32, + } + + #[action] + async fn handler( + State(state): State>, + Query(params): Query, + ) -> Result { + Ok(format!("{}:{}", state.greeting, params.n)) + } + + #[test] + fn action_composes_state_and_query() { + let service = RouterService::builder() + .with_state(Arc::new(AppState { + greeting: "hi".to_owned(), + })) + .get("/h", handler) + .build(); + + let request = request_builder() + .method(Method::GET) + .uri("/h?n=5") + .body(Body::empty()) + .expect("request"); + + let response = block_on(service.oneshot(request)).expect("response"); + assert_eq!(response.status(), StatusCode::OK); + assert_eq!(response.body().as_bytes().expect("buffered"), b"hi:5"); + } +} diff --git a/docs/guide/handlers.md b/docs/guide/handlers.md index 5f13adc1..90515f93 100644 --- a/docs/guide/handlers.md +++ b/docs/guide/handlers.md @@ -214,6 +214,58 @@ async fn inspect(ctx: RequestContext) -> Result, EdgeError> { | `into_request()` | `Request` - consume context, take request | | `proxy_handle()` | `Option` - adapter proxy hook | +## Sharing app state + +Request-derived extractors (`Json`, `Query`, `Path`, …) cover per-request data. +For app-owned state that outlives a single request — a settings object, a +connection registry, an orchestrator — register it once on the router and read +it back with the `State` extractor. + +Register the value with `RouterBuilder::with_state`. It is cloned into every +request's extensions before dispatch, so `T` must be `Clone + Send + Sync + +'static` — typically an `Arc`, where the clone is a cheap refcount +bump: + +```rust +use std::sync::Arc; +use edgezero_core::extractor::State; +use edgezero_core::router::RouterService; + +#[derive(Clone)] +struct AppState { + greeting: String, +} + +let state = Arc::new(AppState { greeting: "hello".into() }); + +let service = RouterService::builder() + .with_state(Arc::clone(&state)) + .get("/greet", greet) + .build(); +``` + +Read it in any `#[action]` handler by adding a `State` argument — it composes +with the other extractors: + +```rust +use edgezero_core::action; +use edgezero_core::error::EdgeError; +use edgezero_core::extractor::State; +use std::sync::Arc; + +#[action] +async fn greet( + State(state): State>, +) -> Result { + Ok(state.greeting.clone()) +} +``` + +Register different types independently (`with_state(a).with_state(b)`); each is +resolved by its own type. Registering the same `T` twice is last-write-wins. If +a handler asks for a `State` that was never registered, extraction fails with +a `500` — register it before `build()`. + ## Response Types ### Text Responses From a6326691061fad0793f6efbd16959c06ea6e1ef7 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Thu, 2 Jul 2026 22:49:49 -0700 Subject: [PATCH 36/74] chore: sync Cargo.lock for edgezero-macros futures dev-dep --- Cargo.lock | 1 + 1 file changed, 1 insertion(+) diff --git a/Cargo.lock b/Cargo.lock index 2100fc07..5413e70f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -825,6 +825,7 @@ name = "edgezero-macros" version = "0.1.0" dependencies = [ "edgezero-core", + "futures", "log", "proc-macro2", "quote", From 6ebc29a58df179e1c61e15cc2a54edae927a3f2f Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Thu, 2 Jul 2026 22:53:14 -0700 Subject: [PATCH 37/74] =?UTF-8?q?docs:=20fold=20review-round-4=20plan=20fi?= =?UTF-8?q?xes;=20reconcile=20spec=20=C2=A74.3=20with=20fn=20secret=5Ffiel?= =?UTF-8?q?ds()?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../2026-07-02-edgezero-nested-secrets.md | 104 +++++++++++++++--- .../2026-07-02-edgezero-state-extractor.md | 5 +- ...dgezero-state-and-nested-secrets-design.md | 14 ++- 3 files changed, 102 insertions(+), 21 deletions(-) diff --git a/docs/superpowers/plans/2026-07-02-edgezero-nested-secrets.md b/docs/superpowers/plans/2026-07-02-edgezero-nested-secrets.md index 4f9cec7f..9df3ea97 100644 --- a/docs/superpowers/plans/2026-07-02-edgezero-nested-secrets.md +++ b/docs/superpowers/plans/2026-07-02-edgezero-nested-secrets.md @@ -1372,7 +1372,10 @@ mod tests { let file = syn::parse_file(src).expect("parse"); let mut collector = AppConfigStructCollector::default(); syn::visit::visit_file(&mut collector, &file); - let mut visitor = NestedAppConfigVisitor::new(&collector.app_config_structs, std::path::Path::new("t.rs")); + // NB: `new(source_path, app_config_structs)` — path FIRST, per the + // real signature at check_no_nested_app_config.rs:127. + let mut visitor = + NestedAppConfigVisitor::new(std::path::Path::new("t.rs"), &collector.app_config_structs); syn::visit::visit_file(&mut visitor, &file); visitor.violations } @@ -1430,16 +1433,21 @@ In `NestedAppConfigVisitor::visit_item_struct` (`check_no_nested_app_config.rs:1 // malformed `#[app_config(...)]` before this binary ever runs. fn field_has_nested_optin(field: &syn::Field) -> bool { field.attrs.iter().any(|attr| { - attr.path().is_ident("app_config") - && attr - .parse_nested_meta(|meta| { - if meta.path.is_ident("nested") { - Ok(()) - } else { - Err(meta.error("unknown app_config option")) - } - }) - .is_ok() + if !attr.path().is_ident("app_config") { + return false; + } + // Must actually see `nested`. A bare `#[app_config()]` parses Ok but + // never sets `found`, so `.is_ok()` alone would wrongly report opt-in. + let mut found = false; + let parsed = attr.parse_nested_meta(|meta| { + if meta.path.is_ident("nested") { + found = true; + Ok(()) + } else { + Err(meta.error("unknown app_config option")) + } + }); + parsed.is_ok() && found }) } ``` @@ -1583,10 +1591,11 @@ api_key = "" } #[test] - fn validate_typed_reports_dotted_path_for_missing_nested_leaf() { - // integrations.datadome table present but server_side_key missing. - // (Note: serde deny_unknown_fields + required String means this also - // fails deserialization; assert the dotted path appears either way.) + fn validate_typed_rejects_missing_required_nested_leaf_at_deserialize() { + // A MISSING required nested leaf fails serde DESERIALIZATION + // (config.rs:207) before `typed_secret_checks`/`run_adapter_typed_checks` + // ever run — so this is deserialize-path coverage, NOT proof of the + // path-aware collector. The direct collector test below covers that. let app_config = r#" [integrations.datadome] @@ -1601,6 +1610,63 @@ api_key = "p0" "error names the missing nested leaf: {err}" ); } + + // Direct coverage of the path-aware TOML collector (the new logic). + // Bypasses `run_config_validate_typed` so deserialization does not preempt + // it — proves the collector itself resolves array indices and reports the + // dotted label for a present-but-invalid / missing leaf. + #[test] + fn collect_secret_leaves_resolves_array_indices_and_dotted_labels() { + let raw: toml::Value = r#" +[[partners]] +api_key = "p0" + +[[partners]] +api_key = "p1" +"# + .parse() + .expect("toml"); + + let field = SecretField { + kind: SecretKind::KeyInDefault, + path: vec![ + SecretPathSegment::Field(std::borrow::Cow::Borrowed("partners")), + SecretPathSegment::ArrayEach, + SecretPathSegment::Field(std::borrow::Cow::Borrowed("api_key")), + ], + optional: false, + }; + let leaves = collect_secret_leaves(&raw, &field).expect("collect"); + let labels: Vec<&str> = leaves.iter().map(|leaf| leaf.label.as_str()).collect(); + assert_eq!(labels, vec!["partners[0].api_key", "partners[1].api_key"]); + let values: Vec<&str> = leaves.iter().map(|leaf| leaf.value).collect(); + assert_eq!(values, vec!["p0", "p1"]); + } + + #[test] + fn collect_secret_leaves_errors_on_missing_required_leaf_with_dotted_label() { + let raw: toml::Value = r#" +[integrations.datadome] +other = "x" +"# + .parse() + .expect("toml"); + + let field = SecretField { + kind: SecretKind::KeyInDefault, + path: vec![ + SecretPathSegment::Field(std::borrow::Cow::Borrowed("integrations")), + SecretPathSegment::Field(std::borrow::Cow::Borrowed("datadome")), + SecretPathSegment::Field(std::borrow::Cow::Borrowed("server_side_key")), + ], + optional: false, + }; + let err = collect_secret_leaves(&raw, &field).expect_err("missing required leaf"); + assert!( + err.contains("integrations.datadome.server_side_key"), + "collector error names the dotted path: {err}" + ); + } ``` Nested `KeyInNamedStore` CLI case (proves the store_ref sibling is resolved within the innermost parent table, and the named store must be declared in `[stores.secrets].ids`): @@ -1869,3 +1935,11 @@ git commit -m "test(secrets): end-to-end nested + named-store resolution; docs: - **Array pruning all-secret success:** added `validate_excluding_secrets_prunes_array_all_secret_failures_to_ok`, proving an array branch whose every element's only failure is the secret leaf collapses to `Ok(())` (the `items.retain(..)`/`items.is_empty()` path), complementing the sibling-survives test (Task 3). - **Task 6 CLI tests made concrete:** replaced the pseudo-code with real tests driven through the public `run_config_validate_typed::` entry point using the existing `setup_project`/`args_for` harness (`config.rs:1662/1671`) and real nested `#[derive(AppConfig)]` fixtures — with concrete TOML, `partners[1].api_key` indexed-label assertion, missing-leaf assertion, and the nested `KeyInNamedStore` case (Task 6 Step 1). - **Removed the obsolete pruning sketch:** Task 3 Step 3 now shows a single `prune_secret_leaf` (the peek-next-segment form); the earlier draft referencing the undefined `list_children_mut` is deleted. + +## Review round 4 — fixes folded in + +- **`field_has_nested_optin` false-positive on `#[app_config()]`:** the CI-guard helper checked only `parse_nested_meta(..).is_ok()`, so a bare `#[app_config()]` (no `nested`) reported opt-in. Now tracks a `found` flag and returns `parsed.is_ok() && found` (Task 5 Step 3). (The derive's `nested_optin` already did this correctly.) +- **`NestedAppConfigVisitor::new` arg order:** the Task 5 test helper had the args reversed; the real signature is `new(source_path, app_config_structs)` (`check_no_nested_app_config.rs:127`). Fixed. +- **Missing-nested-leaf CLI test was deserialize coverage, not collector coverage:** in `run_config_validate_typed`, serde deserialization (`config.rs:207`) runs before `typed_secret_checks`/`run_adapter_typed_checks`, so a *missing required* leaf fails deserialization first and never reaches the path collector. Retitled that test `..._rejects_missing_required_nested_leaf_at_deserialize` and added two **direct** `collect_secret_leaves` unit tests (array-index labels + missing-required-leaf dotted error) that bypass the entry point (Task 6 Step 1). +- **State plan stale snippet:** the State extractor plan's missing-registration test used `expect_err` (needs `State: Debug`); updated to `.err().expect(..)` to match the committed code. +- **Spec §4.3 reconciled:** the B-3 note and the recursion sketch now reference `secret_fields()` (owned `Vec`) instead of child `SECRET_FIELDS` / `Cow` const shapes. diff --git a/docs/superpowers/plans/2026-07-02-edgezero-state-extractor.md b/docs/superpowers/plans/2026-07-02-edgezero-state-extractor.md index dcab2524..ab6d1eed 100644 --- a/docs/superpowers/plans/2026-07-02-edgezero-state-extractor.md +++ b/docs/superpowers/plans/2026-07-02-edgezero-state-extractor.md @@ -89,8 +89,11 @@ Append to the `#[cfg(test)] mod tests` block at the end of `crates/edgezero-core .expect("request"); let ctx = RequestContext::new(request, PathParams::default()); + // `.err().expect(..)` (not `expect_err`) so we don't require + // `State: Debug` — extractors here mirror Json/Path and omit it. let err = block_on(State::>::from_request(&ctx)) - .expect_err("missing state must surface as an error, not a default"); + .err() + .expect("missing state must surface as an error, not a default"); assert_eq!(err.status(), StatusCode::INTERNAL_SERVER_ERROR); } diff --git a/docs/superpowers/specs/2026-07-02-edgezero-state-and-nested-secrets-design.md b/docs/superpowers/specs/2026-07-02-edgezero-state-and-nested-secrets-design.md index 0be27549..036abc86 100644 --- a/docs/superpowers/specs/2026-07-02-edgezero-state-and-nested-secrets-design.md +++ b/docs/superpowers/specs/2026-07-02-edgezero-state-and-nested-secrets-design.md @@ -289,15 +289,19 @@ Recurse into fields whose type is itself an `AppConfig`-derived struct (or a `Ve - **(Recommended) Explicit opt-in attribute**, e.g. `#[app_config(nested)]` on the containing field (and `#[app_config(nested)]` on a `Vec` field for array recursion). Mirrors `#[validate(nested)]`, is unambiguous, and keeps the derive purely syntactic. The sub-struct must itself derive `AppConfig` (enforced at runtime via the `AppConfigRoot` marker / a generated const assertion). - **(Alternative) Type-name heuristic** — recurse into any field whose type path "looks like" a struct. Rejected: brittle, silently wrong for third-party types, and can't see through aliases. -With explicit opt-in, the derive emits, for each nested field, code that references the sub-struct's own `SECRET_FIELDS` and prefixes the path — so recursion composes without the parent macro needing the child's fields: +With explicit opt-in, the derive emits, for each nested field, code that calls the sub-struct's own `secret_fields()` and prefixes the path — so recursion composes without the parent macro needing the child's fields: ```rust -// sketch of emitted metadata for `integrations: IntegrationSettings` (#[app_config(nested)]) -// concatenate, at const-eval where possible, or via a generated fn: -// prefix [Field("integrations")] onto each of ::SECRET_FIELDS +// emitted metadata for `integrations: IntegrationSettings` (#[app_config(nested)]): +// for each SecretField the child returns, prepend Field("integrations") to its path. +// for mut f in ::secret_fields() { +// f.path.insert(0, SecretPathSegment::Field(Cow::Borrowed("integrations"))); +// out.push(f); +// } +// (a Vec field prepends Field("field"), then ArrayEach.) ``` -> Implementation note: `&'static [SecretField]` concatenation across crates is not trivial at `const` if paths must be `&'static [SecretPathSegment]`. Two viable lowerings: (a) generate a `const` block per struct that hand-builds the full flattened array literal by inlining child segments the macro can see through the opt-in type path; or (b) change `AppConfigMeta` from an associated `const` to an associated `fn secret_fields() -> Vec` (owned, path segments `Vec<...>`), letting recursion build owned vectors at runtime. **Recommendation:** prefer (b) — a `fn secret_fields() -> Cow<'static, [SecretField]>` — because it makes cross-crate recursion straightforward and the walk runs once per request anyway (allocation cost negligible vs. the network fetch). Flag this as **B-3** for maintainer decision, since it changes the `AppConfigMeta` trait shape. +> **B-3 — resolved (§8).** Cross-crate `const` concatenation of `&'static [SecretPathSegment]` with a parent-prepended prefix is not expressible in stable `const` (the macro cannot see the child's segments). So `AppConfigMeta` becomes an associated **`fn secret_fields() -> Vec`** (owned segments), and recursion builds owned vectors at runtime — allocation cost is negligible vs. the per-request network fetch. Every in-tree `impl AppConfigMeta` / `SECRET_FIELDS` site flips to the `fn`, including the ~10 hand-rolled test impls. See the implementation plan for the concrete emission. Also relax `enforce_scalar_string_type` to additionally accept `Option` on `#[secret]` fields (optional secrets are common in real config); an absent/`None` optional secret is skipped by the walk rather than erroring. Keep rejecting non-string scalar types. From 44b32ad1b758364569e1d51668d211b6a8cdba2c Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Thu, 2 Jul 2026 23:30:31 -0700 Subject: [PATCH 38/74] refactor(secrets): owned path-qualified SecretField + AppConfigMeta::secret_fields() --- crates/edgezero-adapter-spin/src/cli.rs | 2 +- crates/edgezero-adapter/src/registry.rs | 12 +- crates/edgezero-cli/src/config.rs | 137 +++++++++------ crates/edgezero-core/src/app_config.rs | 158 ++++++++++++++---- crates/edgezero-core/src/extractor.rs | 79 +++++---- crates/edgezero-macros/src/app_config.rs | 12 +- .../tests/app_config_derive.rs | 59 +++---- .../crates/app-demo-core/src/config.rs | 14 +- 8 files changed, 310 insertions(+), 163 deletions(-) diff --git a/crates/edgezero-adapter-spin/src/cli.rs b/crates/edgezero-adapter-spin/src/cli.rs index 11c1d6a2..dd6a8ec6 100644 --- a/crates/edgezero-adapter-spin/src/cli.rs +++ b/crates/edgezero-adapter-spin/src/cli.rs @@ -541,7 +541,7 @@ impl Adapter for SpinCliAdapter { value = entry.key_value, )); } - if let Some(prev_field) = seen.insert(spin_var.clone(), entry.field_name) { + if let Some(prev_field) = seen.insert(spin_var.clone(), entry.field_name.as_str()) { return Err(format!( "Spin variable `{spin_var}` would receive values from BOTH `#[secret]` field `{prev_field}` AND `#[secret]` field `{this_field}`; Spin's flat variable namespace cannot disambiguate them. Pick distinct `#[secret]` values whose lowercased forms differ.", this_field = entry.field_name, diff --git a/crates/edgezero-adapter/src/registry.rs b/crates/edgezero-adapter/src/registry.rs index 6a24ce87..9cdc5da0 100644 --- a/crates/edgezero-adapter/src/registry.rs +++ b/crates/edgezero-adapter/src/registry.rs @@ -176,8 +176,8 @@ impl<'ctx> AdapterPushContext<'ctx> { /// v2 source-compat; construction goes through `new`. #[non_exhaustive] pub struct TypedSecretEntry<'entry> { - /// Rust struct field name (e.g. `"api_token"`). - pub field_name: &'entry str, + /// Dotted secret-field path label (e.g. `"partners[3].api_key"`). + pub field_name: String, /// Blob value — i.e. the secret-store KEY NAME. pub key_value: &'entry str, /// Logical secret-store id this key targets. @@ -188,9 +188,13 @@ impl<'entry> TypedSecretEntry<'entry> { /// Construct a new entry from its three components. #[must_use] #[inline] - pub fn new(store_id: &'entry str, field_name: &'entry str, key_value: &'entry str) -> Self { + pub fn new>( + store_id: &'entry str, + field_name: Name, + key_value: &'entry str, + ) -> Self { Self { - field_name, + field_name: field_name.into(), key_value, store_id, } diff --git a/crates/edgezero-cli/src/config.rs b/crates/edgezero-cli/src/config.rs index 4ca508bd..22a8abd8 100644 --- a/crates/edgezero-cli/src/config.rs +++ b/crates/edgezero-cli/src/config.rs @@ -25,7 +25,7 @@ use edgezero_adapter::registry::{ self as adapter_registry, ReadConfigEntry, ResolvedStoreId, TypedSecretEntry, }; use edgezero_core::app_config::{ - self, AppConfigError, AppConfigLoadOptions, AppConfigMeta, SecretKind, + self, AppConfigError, AppConfigLoadOptions, AppConfigMeta, SecretKind, SecretPathSegment, }; use edgezero_core::blob_envelope::BlobEnvelope; use edgezero_core::env_config::EnvConfig; @@ -1305,19 +1305,26 @@ fn run_adapter_typed_checks(ctx: &ValidationContext) -> Result .as_ref() .map(StoreDeclaration::default_id); let mut entries: Vec> = Vec::new(); - for field in C::SECRET_FIELDS { + for field in C::secret_fields() { + // Task 1: flat/length-1 paths only — the leaf is the single Field + // segment. Task 6 makes this a full TOML path navigator. + let leaf = field.dotted_path(); + let key = match field.path.last() { + Some(SecretPathSegment::Field(name)) => name.as_ref(), + _ => continue, + }; match field.kind { SecretKind::KeyInDefault => { - let opt_value = raw_table.get(field.name).and_then(Value::as_str); + let opt_value = raw_table.get(key).and_then(Value::as_str); if let (Some(key_value), Some(store_id)) = (opt_value, default_store_id) { - entries.push(TypedSecretEntry::new(store_id, field.name, key_value)); + entries.push(TypedSecretEntry::new(store_id, leaf.clone(), key_value)); } } SecretKind::KeyInNamedStore { store_ref_field } => { let opt_store = raw_table.get(store_ref_field).and_then(Value::as_str); - let opt_value = raw_table.get(field.name).and_then(Value::as_str); + let opt_value = raw_table.get(key).and_then(Value::as_str); if let (Some(store_id), Some(key_value)) = (opt_store, opt_value) { - entries.push(TypedSecretEntry::new(store_id, field.name, key_value)); + entries.push(TypedSecretEntry::new(store_id, leaf.clone(), key_value)); } } SecretKind::StoreRef => {} @@ -1345,22 +1352,26 @@ fn typed_secret_checks( .as_table() .ok_or_else(|| "raw app-config was not a TOML table after load".to_owned())?; - for field in C::SECRET_FIELDS { - let value = raw_table - .get(field.name) - .and_then(Value::as_str) - .ok_or_else(|| { - format!( - "{}: `#[secret]` field `{}` is missing or not a string at the top level", - ctx.app_config_path.display(), - field.name - ) - })?; + for field in C::secret_fields() { + // Task 1: flat/length-1 paths only — the leaf is the single Field + // segment. Task 6 makes this a full TOML path navigator. + let leaf = field.dotted_path(); + let key = match field.path.last() { + Some(SecretPathSegment::Field(name)) => name.as_ref(), + _ => continue, + }; + let value = raw_table.get(key).and_then(Value::as_str).ok_or_else(|| { + format!( + "{}: `#[secret]` field `{}` is missing or not a string at the top level", + ctx.app_config_path.display(), + leaf + ) + })?; if value.is_empty() { return Err(format!( "{}: `#[secret]` field `{}` must be non-empty", ctx.app_config_path.display(), - field.name + leaf )); } match field.kind { @@ -1369,7 +1380,7 @@ fn typed_secret_checks( return Err(format!( "{}: `#[secret]` field `{}` requires `[stores.secrets]` to be declared in {}", ctx.app_config_path.display(), - field.name, + leaf, ctx.manifest_path.display() )); } @@ -1382,7 +1393,7 @@ fn typed_secret_checks( return Err(format!( "{}: `#[secret(store_ref = \"...\")]` field `{}` requires `[stores.secrets]` to be declared in {}", ctx.app_config_path.display(), - field.name, + leaf, ctx.manifest_path.display() )); } @@ -1392,7 +1403,7 @@ fn typed_secret_checks( format!( "{}: `#[secret(store_ref)]` field `{}` requires `[stores.secrets]` to be declared in {}", ctx.app_config_path.display(), - field.name, + leaf, ctx.manifest_path.display() ) })?; @@ -1400,7 +1411,7 @@ fn typed_secret_checks( return Err(format!( "{}: `#[secret(store_ref)]` field `{}` = {:?} is not in [stores.secrets].ids ({:?})", ctx.app_config_path.display(), - field.name, + leaf, value, secrets.ids )); @@ -1545,6 +1556,7 @@ mod tests { use crate::test_support::{manifest_guard, EnvOverride}; use edgezero_core::app_config::SecretField; use serde::{Deserialize, Serialize}; + use std::borrow::Cow; #[cfg(unix)] use std::ffi::OsString; use std::fs; @@ -1647,16 +1659,20 @@ source = "target/wasm32-wasip2/release/demo.wasm" } impl AppConfigMeta for FixtureConfig { - const SECRET_FIELDS: &'static [SecretField] = &[ - SecretField { - kind: SecretKind::KeyInDefault, - name: "api_token", - }, - SecretField { - kind: SecretKind::StoreRef, - name: "vault", - }, - ]; + fn secret_fields() -> Vec { + vec![ + SecretField { + kind: SecretKind::KeyInDefault, + path: vec![SecretPathSegment::Field(Cow::Borrowed("api_token"))], + optional: false, + }, + SecretField { + kind: SecretKind::StoreRef, + path: vec![SecretPathSegment::Field(Cow::Borrowed("vault"))], + optional: false, + }, + ] + } } fn setup_project(manifest: &str, app_config: &str) -> (TempDir, PathBuf, PathBuf) { @@ -1864,10 +1880,13 @@ serve = "echo" greeting: String, } impl AppConfigMeta for SecretValidatorConfig { - const SECRET_FIELDS: &'static [SecretField] = &[SecretField { - kind: SecretKind::KeyInDefault, - name: "api_token", - }]; + fn secret_fields() -> Vec { + vec![SecretField { + kind: SecretKind::KeyInDefault, + path: vec![SecretPathSegment::Field(Cow::Borrowed("api_token"))], + optional: false, + }] + } } let app_config = r#" @@ -2202,16 +2221,20 @@ ids = ["default"] vault: String, } impl AppConfigMeta for StoreRefRegressionConfig { - const SECRET_FIELDS: &'static [SecretField] = &[ - SecretField { - kind: SecretKind::KeyInDefault, - name: "api_token", - }, - SecretField { - kind: SecretKind::StoreRef, - name: "vault", - }, - ]; + fn secret_fields() -> Vec { + vec![ + SecretField { + kind: SecretKind::KeyInDefault, + path: vec![SecretPathSegment::Field(Cow::Borrowed("api_token"))], + optional: false, + }, + SecretField { + kind: SecretKind::StoreRef, + path: vec![SecretPathSegment::Field(Cow::Borrowed("vault"))], + optional: false, + }, + ] + } } let manifest = r#" @@ -2744,10 +2767,13 @@ default = "one" greeting: String, } impl AppConfigMeta for SecretValidatorConfig { - const SECRET_FIELDS: &'static [SecretField] = &[SecretField { - kind: SecretKind::KeyInDefault, - name: "api_token", - }]; + fn secret_fields() -> Vec { + vec![SecretField { + kind: SecretKind::KeyInDefault, + path: vec![SecretPathSegment::Field(Cow::Borrowed("api_token"))], + optional: false, + }] + } } let app_config = r#" @@ -3313,10 +3339,13 @@ ids = ["default"] greeting: String, } impl AppConfigMeta for DiffSecretConfig { - const SECRET_FIELDS: &'static [SecretField] = &[SecretField { - kind: SecretKind::KeyInDefault, - name: "api_token", - }]; + fn secret_fields() -> Vec { + vec![SecretField { + kind: SecretKind::KeyInDefault, + path: vec![SecretPathSegment::Field(Cow::Borrowed("api_token"))], + optional: false, + }] + } } let app_config_empty_secret = r#" diff --git a/crates/edgezero-core/src/app_config.rs b/crates/edgezero-core/src/app_config.rs index de85aa15..ef6faf6e 100644 --- a/crates/edgezero-core/src/app_config.rs +++ b/crates/edgezero-core/src/app_config.rs @@ -14,6 +14,7 @@ //! [`load_app_config_raw_with_options`]. use std::any; +use std::borrow::Cow; use std::collections::HashMap; use std::env; use std::fs; @@ -27,24 +28,61 @@ use toml::value::Datetime; use toml::Value; use validator::{Validate, ValidationErrors}; -/// Per-field metadata emitted by `#[derive(AppConfig)]`. The -/// derive enumerates every field annotated with `#[secret]` / -/// `#[secret(store_ref)]`; `config validate` and `config push` -/// reflect over this array to gate secret-aware behaviour. -pub trait AppConfigMeta { - /// Every `#[secret]` / `#[secret(store_ref)]` field on the struct. - const SECRET_FIELDS: &'static [SecretField]; +/// One segment of a [`SecretField`] path. +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum SecretPathSegment { + /// Every element of an array/`Vec` at this position. + ArrayEach, + /// An object key — a Rust field name, verbatim (no `serde(rename)`). + Field(Cow<'static, str>), } /// One field's worth of secret-annotation metadata. -#[derive(Clone, Copy, Debug, Eq, PartialEq)] +/// +/// The `path` locates the secret leaf from the config root. A top-level +/// scalar has a length-1 path `[Field("api_token")]`. +#[derive(Clone, Debug, Eq, PartialEq)] pub struct SecretField { - /// Whether the field's value is a key in the default secret store - /// or the logical id of a `[stores.secrets]` entry. + /// Which secret-store resolution this field participates in. pub kind: SecretKind, - /// Rust field name verbatim (no `serde(rename)` translation — - /// `#[secret]` rejects renames at compile time). - pub name: &'static str, + /// `true` for `#[secret]` on `Option`: an absent leaf is + /// skipped by the runtime walk instead of erroring. + pub optional: bool, + /// Path from the config root to the secret leaf. + pub path: Vec, +} + +impl SecretField { + /// Human-readable dotted path for error messages and CLI output. + /// `ArrayEach` renders as `[*]` (the static form); the runtime walk + /// renders per-index `[n]` as it descends. + #[inline] + #[must_use] + pub fn dotted_path(&self) -> String { + let mut out = String::new(); + for segment in &self.path { + match segment { + SecretPathSegment::Field(name) => { + if !out.is_empty() { + out.push('.'); + } + out.push_str(name); + } + SecretPathSegment::ArrayEach => out.push_str("[*]"), + } + } + out + } +} + +/// Per-field metadata emitted by `#[derive(AppConfig)]`. `config validate` +/// / `config push` and the runtime secret walk reflect over this to gate +/// secret-aware behaviour. +pub trait AppConfigMeta { + /// Every `#[secret]` / `#[secret(store_ref)]` leaf on the struct, + /// including those reached through `#[app_config(nested)]` children, + /// each carrying its full path from this struct's root. + fn secret_fields() -> Vec; } /// Discriminator on a [`SecretField`] capturing which secret-store @@ -209,15 +247,16 @@ pub fn validate_excluding_secrets( return Ok(()); }; // validator 0.20 exposes errors_mut() -> &mut HashMap, ValidationErrorsKind>. - // `bag.remove(field.name)` works because `field.name` is `&'static str` - // and `Cow<'static, str>: Borrow` (the earlier comment cited the - // wrong key type — this is the corrected form). let bag = errors.errors_mut(); - for field in C::SECRET_FIELDS { + for field in C::secret_fields() { if matches!(field.kind, SecretKind::StoreRef) { continue; // store_id field; validator stays } - bag.remove(field.name); + // Task 1: flat removal by the first path segment (length-1 paths only + // exist until the derive emits nesting). Task 3 makes this nested-aware. + if let Some(SecretPathSegment::Field(name)) = field.path.first() { + bag.remove(name.as_ref()); + } } if bag.is_empty() { return Ok(()); @@ -618,7 +657,9 @@ mod tests { } impl AppConfigMeta for FixtureConfig { - const SECRET_FIELDS: &'static [SecretField] = &[]; + fn secret_fields() -> Vec { + vec![] + } } fn write_fixture(contents: &str) -> NamedTempFile { @@ -1104,7 +1145,9 @@ greeting = "hello" } // Hand-rolled AppConfigMeta — matches the same shape as the rest of this test. impl AppConfigMeta for Fixture { - const SECRET_FIELDS: &'static [SecretField] = &[]; + fn secret_fields() -> Vec { + vec![] + } } impl validator::Validate for Fixture { fn validate(&self) -> Result<(), validator::ValidationErrors> { @@ -1136,7 +1179,9 @@ greeting = "hello" greeting: String, } impl AppConfigMeta for Fixture { - const SECRET_FIELDS: &'static [SecretField] = &[]; + fn secret_fields() -> Vec { + vec![] + } } let cfg = Fixture { greeting: "hello".into(), @@ -1154,10 +1199,13 @@ greeting = "hello" greeting: String, } impl AppConfigMeta for Fixture { - const SECRET_FIELDS: &'static [SecretField] = &[SecretField { - name: "api_token", - kind: SecretKind::KeyInDefault, - }]; + fn secret_fields() -> Vec { + vec![SecretField { + kind: SecretKind::KeyInDefault, + path: vec![SecretPathSegment::Field(Cow::Borrowed("api_token"))], + optional: false, + }] + } } let cfg = Fixture { api_token: "short".into(), @@ -1179,10 +1227,13 @@ greeting = "hello" greeting: String, } impl AppConfigMeta for Fixture { - const SECRET_FIELDS: &'static [SecretField] = &[SecretField { - name: "api_token", - kind: SecretKind::KeyInDefault, - }]; + fn secret_fields() -> Vec { + vec![SecretField { + kind: SecretKind::KeyInDefault, + path: vec![SecretPathSegment::Field(Cow::Borrowed("api_token"))], + optional: false, + }] + } } let cfg = Fixture { api_token: "x".into(), @@ -1199,10 +1250,13 @@ greeting = "hello" store_id: String, } impl AppConfigMeta for Fixture { - const SECRET_FIELDS: &'static [SecretField] = &[SecretField { - name: "store_id", - kind: SecretKind::StoreRef, - }]; + fn secret_fields() -> Vec { + vec![SecretField { + kind: SecretKind::StoreRef, + path: vec![SecretPathSegment::Field(Cow::Borrowed("store_id"))], + optional: false, + }] + } } let cfg = Fixture { store_id: "short".into(), @@ -1210,4 +1264,42 @@ greeting = "hello" // StoreRef keeps its validator — short store_id still fails. validate_excluding_secrets(&cfg).unwrap_err(); } + + #[test] + fn dotted_path_renders_nested_and_array_segments() { + use super::{SecretField, SecretKind, SecretPathSegment::*}; + use std::borrow::Cow; + + let top = SecretField { + kind: SecretKind::KeyInDefault, + path: vec![Field(Cow::Borrowed("api_token"))], + optional: false, + }; + assert_eq!(top.dotted_path(), "api_token"); + + let nested = SecretField { + kind: SecretKind::KeyInDefault, + path: vec![ + Field(Cow::Borrowed("integrations")), + Field(Cow::Borrowed("datadome")), + Field(Cow::Borrowed("server_side_key")), + ], + optional: false, + }; + assert_eq!( + nested.dotted_path(), + "integrations.datadome.server_side_key" + ); + + let array = SecretField { + kind: SecretKind::KeyInDefault, + path: vec![ + Field(Cow::Borrowed("partners")), + ArrayEach, + Field(Cow::Borrowed("api_key")), + ], + optional: false, + }; + assert_eq!(array.dotted_path(), "partners[*].api_key"); + } } diff --git a/crates/edgezero-core/src/extractor.rs b/crates/edgezero-core/src/extractor.rs index bba2c489..d80f7ace 100644 --- a/crates/edgezero-core/src/extractor.rs +++ b/crates/edgezero-core/src/extractor.rs @@ -6,7 +6,7 @@ use http::header; use serde::de::DeserializeOwned; use validator::Validate; -use crate::app_config::{AppConfigMeta, SecretKind}; +use crate::app_config::{AppConfigMeta, SecretKind, SecretPathSegment}; use crate::blob_envelope::BlobEnvelope; use crate::config_store::ConfigStoreHandle; use crate::context::RequestContext; @@ -881,7 +881,7 @@ where Ok(cfg) } -/// Walk `C::SECRET_FIELDS` and replace each `#[secret]` key NAME in `data` +/// Walk `C::secret_fields()` and replace each `#[secret]` key NAME in `data` /// with the resolved secret VALUE from the appropriate secret store. /// /// `StoreRef` fields are skipped — their value is a store id, not a key. @@ -892,14 +892,25 @@ where let data_obj = data .as_object_mut() .ok_or_else(|| EdgeError::internal(anyhow::anyhow!("blob `data` is not a JSON object")))?; - for field in C::SECRET_FIELDS { + for field in C::secret_fields() { + // Task 1: top-level only — the leaf is the single Field segment. + let leaf_key = match field.path.last() { + Some(SecretPathSegment::Field(name)) => name.clone().into_owned(), + _ => { + return Err(EdgeError::internal(anyhow::anyhow!( + "secret field `{}` has no field leaf", + field.dotted_path() + ))) + } + }; + let hint = field.dotted_path(); let key_name = data_obj - .get(field.name) + .get(leaf_key.as_str()) .and_then(|val| val.as_str()) .ok_or_else(|| { EdgeError::config_out_of_date( - format!("missing or non-string value at `{}`", field.name), - field.name.to_owned(), + format!("missing or non-string value at `{hint}`"), + hint.clone(), ) })? .to_owned(); @@ -908,11 +919,10 @@ where let bound = ctx.secret_store_default().ok_or_else(|| { EdgeError::config_out_of_date( format!( - "secret field `{}` has kind KeyInDefault but no default secret \ + "secret field `{hint}` has kind KeyInDefault but no default secret \ store is registered", - field.name, ), - field.name.to_owned(), + hint.clone(), ) })?; let id = bound.store_name().to_owned(); @@ -926,10 +936,9 @@ where .ok_or_else(|| { EdgeError::config_out_of_date( format!( - "missing store_ref `{store_ref_field}` for secret field `{}`", - field.name + "missing store_ref `{store_ref_field}` for secret field `{hint}`" ), - field.name.to_owned(), + hint.clone(), ) })? .to_owned(); @@ -939,7 +948,7 @@ where "blob declared store_ref `{store_id_str}` but \ [stores.secrets] has no such id" ), - field.name.to_owned(), + hint.clone(), ) })?; (bound, store_id_str) @@ -948,8 +957,8 @@ where let secret = bound .require_str(&key_name) .await - .map_err(|err| map_secret_error(err, field.name, &resolved_store_id, &key_name))?; - data_obj.insert(field.name.to_owned(), serde_json::Value::String(secret)); + .map_err(|err| map_secret_error(err, &hint, &resolved_store_id, &key_name))?; + data_obj.insert(leaf_key, serde_json::Value::String(secret)); } Ok(()) } @@ -1037,7 +1046,7 @@ fn first_violating_field(errors: &validator::ValidationErrors) -> Option #[cfg(test)] mod tests { use super::*; - use crate::app_config::{AppConfigMeta, SecretField, SecretKind}; + use crate::app_config::{AppConfigMeta, SecretField, SecretKind, SecretPathSegment}; use crate::blob_envelope::BlobEnvelope; use crate::body::Body; use crate::config_store::{ConfigStore, ConfigStoreError, ConfigStoreHandle}; @@ -1048,6 +1057,7 @@ mod tests { use crate::store_registry::StoreRegistry; use futures::executor::block_on; use serde::{Deserialize, Serialize}; + use std::borrow::Cow; use std::collections::HashMap; use std::sync::Arc; use validator::Validate; @@ -1113,7 +1123,9 @@ mod tests { } impl AppConfigMeta for FixtureCfg { - const SECRET_FIELDS: &'static [SecretField] = &[]; + fn secret_fields() -> Vec { + vec![] + } } // Fixture config type with one KeyInDefault secret field. Used by AppConfig tests. @@ -1126,10 +1138,13 @@ mod tests { } impl AppConfigMeta for SecretCfg { - const SECRET_FIELDS: &'static [SecretField] = &[SecretField { - name: "api_token", - kind: SecretKind::KeyInDefault, - }]; + fn secret_fields() -> Vec { + vec![SecretField { + kind: SecretKind::KeyInDefault, + path: vec![SecretPathSegment::Field(Cow::Borrowed("api_token"))], + optional: false, + }] + } } fn ctx(body: Body, params: PathParams) -> RequestContext { @@ -2393,10 +2408,13 @@ mod tests { } impl AppConfigMeta for SecretLen { - const SECRET_FIELDS: &'static [SecretField] = &[SecretField { - name: "api_token", - kind: SecretKind::KeyInDefault, - }]; + fn secret_fields() -> Vec { + vec![SecretField { + kind: SecretKind::KeyInDefault, + path: vec![SecretPathSegment::Field(Cow::Borrowed("api_token"))], + optional: false, + }] + } } struct BlobStore(String); @@ -2434,10 +2452,13 @@ mod tests { } impl AppConfigMeta for SecretLen { - const SECRET_FIELDS: &'static [SecretField] = &[SecretField { - name: "api_token", - kind: SecretKind::KeyInDefault, - }]; + fn secret_fields() -> Vec { + vec![SecretField { + kind: SecretKind::KeyInDefault, + path: vec![SecretPathSegment::Field(Cow::Borrowed("api_token"))], + optional: false, + }] + } } struct BlobStore(String); diff --git a/crates/edgezero-macros/src/app_config.rs b/crates/edgezero-macros/src/app_config.rs index 3444cba9..c2feb682 100644 --- a/crates/edgezero-macros/src/app_config.rs +++ b/crates/edgezero-macros/src/app_config.rs @@ -141,10 +141,15 @@ fn expand(input: &DeriveInput) -> Result { }) } }; + // Task 1: length-1 Field path, non-optional. Task 4 sets `optional` + // from Option and prepends nested/array segments. quote! { ::edgezero_core::app_config::SecretField { - name: #name_lit, kind: #kind_tokens, + path: ::std::vec![::edgezero_core::app_config::SecretPathSegment::Field( + ::std::borrow::Cow::Borrowed(#name_lit) + )], + optional: false, } } }); @@ -154,8 +159,9 @@ fn expand(input: &DeriveInput) -> Result { impl #impl_generics ::edgezero_core::app_config::AppConfigMeta for #struct_ident #type_generics #where_clause { - const SECRET_FIELDS: &'static [::edgezero_core::app_config::SecretField] = - &[#(#entries),*]; + fn secret_fields() -> ::std::vec::Vec<::edgezero_core::app_config::SecretField> { + ::std::vec![#(#entries),*] + } } #[automatically_derived] diff --git a/crates/edgezero-macros/tests/app_config_derive.rs b/crates/edgezero-macros/tests/app_config_derive.rs index 3fe92fbc..5cfb8833 100644 --- a/crates/edgezero-macros/tests/app_config_derive.rs +++ b/crates/edgezero-macros/tests/app_config_derive.rs @@ -3,7 +3,7 @@ #[cfg(test)] mod tests { - use edgezero_core::app_config::{AppConfigMeta as _, AppConfigRoot, SecretField, SecretKind}; + use edgezero_core::app_config::{AppConfigMeta, AppConfigRoot, SecretKind}; #[derive(serde::Deserialize, validator::Validate, edgezero_core::AppConfig)] #[serde(deny_unknown_fields)] @@ -66,46 +66,43 @@ mod tests { vault: String, } + /// Reflect each derived `SecretField` down to the tuple the + /// assertions compare: `(dotted_path, kind, optional)`. + fn reflect() -> Vec<(String, SecretKind, bool)> { + C::secret_fields() + .into_iter() + .map(|field| (field.dotted_path(), field.kind, field.optional)) + .collect() + } + #[test] fn no_secret_annotation_yields_empty_secret_fields() { - assert!(ConfigNoSecrets::SECRET_FIELDS.is_empty()); + assert!(ConfigNoSecrets::secret_fields().is_empty()); } #[test] fn plain_secret_attribute_yields_key_in_default() { assert_eq!( - ConfigKeyInDefault::SECRET_FIELDS, - &[SecretField { - name: "api_token", - kind: SecretKind::KeyInDefault, - }] + reflect::(), + vec![("api_token".to_owned(), SecretKind::KeyInDefault, false)] ); } #[test] fn secret_store_ref_attribute_yields_store_ref() { assert_eq!( - ConfigStoreRef::SECRET_FIELDS, - &[SecretField { - name: "vault", - kind: SecretKind::StoreRef, - }] + reflect::(), + vec![("vault".to_owned(), SecretKind::StoreRef, false)] ); } #[test] fn both_secret_kinds_are_collected_in_source_order() { assert_eq!( - ConfigBothKinds::SECRET_FIELDS, - &[ - SecretField { - name: "api_token", - kind: SecretKind::KeyInDefault, - }, - SecretField { - name: "vault", - kind: SecretKind::StoreRef, - }, + reflect::(), + vec![ + ("api_token".to_owned(), SecretKind::KeyInDefault, false), + ("vault".to_owned(), SecretKind::StoreRef, false), ] ); } @@ -113,18 +110,16 @@ mod tests { #[test] fn key_in_named_store_attribute_yields_correct_secret_fields() { assert_eq!( - ConfigKeyInNamedStore::SECRET_FIELDS, - &[ - SecretField { - name: "api_token", - kind: SecretKind::KeyInNamedStore { + reflect::(), + vec![ + ( + "api_token".to_owned(), + SecretKind::KeyInNamedStore { store_ref_field: "vault", }, - }, - SecretField { - name: "vault", - kind: SecretKind::StoreRef, - }, + false, + ), + ("vault".to_owned(), SecretKind::StoreRef, false), ] ); } diff --git a/examples/app-demo/crates/app-demo-core/src/config.rs b/examples/app-demo/crates/app-demo-core/src/config.rs index 145525cf..0eeae7a6 100644 --- a/examples/app-demo/crates/app-demo-core/src/config.rs +++ b/examples/app-demo/crates/app-demo-core/src/config.rs @@ -123,16 +123,16 @@ mod tests { #[test] fn secret_fields_metadata_matches_declarations() { - let mut by_name: Vec<(&str, SecretKind)> = AppDemoConfig::SECRET_FIELDS - .iter() - .map(|f| (f.name, f.kind)) + let mut by_path: Vec<(String, SecretKind)> = AppDemoConfig::secret_fields() + .into_iter() + .map(|f| (f.dotted_path(), f.kind)) .collect(); - by_name.sort_by_key(|(name, _)| *name); + by_path.sort_by_key(|(path, _)| path.clone()); assert_eq!( - by_name, + by_path, vec![ - ("api_token", SecretKind::KeyInDefault), - ("vault", SecretKind::StoreRef), + ("api_token".to_owned(), SecretKind::KeyInDefault), + ("vault".to_owned(), SecretKind::StoreRef), ], ); } From f8132bdae28bb30ec9500bbe965dfc76c930a42c Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Thu, 2 Jul 2026 23:40:20 -0700 Subject: [PATCH 39/74] refactor(core): store app state in an Extensions bag, not closure inserters Replaces state_inserters: Vec> with a single state_extensions: Extensions on RouterBuilder/RouterInner. with_state inserts by type; dispatch does extensions.extend(state_extensions.clone()). Drops the StateInserter alias, one closure alloc per registered state, and one vtable call per state per request. Identical behavior: Clone+Send+Sync+'static bound, last-write-wins by TypeId. --- crates/edgezero-core/src/router.rs | 31 ++++++++----------- .../2026-07-02-edgezero-state-extractor.md | 20 +++++++++--- ...dgezero-state-and-nested-secrets-design.md | 9 ++++++ 3 files changed, 38 insertions(+), 22 deletions(-) diff --git a/crates/edgezero-core/src/router.rs b/crates/edgezero-core/src/router.rs index b29398c5..5b9881de 100644 --- a/crates/edgezero-core/src/router.rs +++ b/crates/edgezero-core/src/router.rs @@ -67,17 +67,15 @@ enum RouteMatch<'route> { NotFound, } -/// Type-erased closure that clones a registered state value into a request's -/// extensions at dispatch. See [`RouterBuilder::with_state`]. -type StateInserter = Arc; - #[derive(Default)] pub struct RouterBuilder { manifest_json: Option>, middlewares: Vec, route_info: Vec, routes: HashMap>, - state_inserters: Vec, + /// App state registered via [`RouterBuilder::with_state`], keyed by type. + /// Cloned into every request's extensions at dispatch. + state_extensions: Extensions, } impl RouterBuilder { @@ -120,7 +118,7 @@ impl RouterBuilder { self.middlewares, route_index, self.manifest_json, - self.state_inserters, + self.state_extensions, ) } @@ -215,10 +213,7 @@ impl RouterBuilder { where T: Clone + Send + Sync + 'static, { - self.state_inserters - .push(Arc::new(move |ext: &mut Extensions| { - ext.insert(value.clone()); - })); + self.state_extensions.insert(value); self } } @@ -228,7 +223,7 @@ struct RouterInner { middlewares: Vec, route_index: Arc<[RouteInfo]>, routes: HashMap>, - state_inserters: Vec, + state_extensions: Extensions, } impl RouterInner { @@ -254,11 +249,11 @@ impl RouterInner { .insert(RouteTable(Arc::clone(&self.route_index))); } // App-owned state registered via RouterBuilder::with_state. - // Runs after introspection inserts; distinct TypeIds, so no - // collision. Last registered wins for a given `T`. - for inserter in &self.state_inserters { - inserter(request.extensions_mut()); - } + // Runs after introspection inserts; `extend` overwrites by + // TypeId, so app state wins last-write on any collision. + request + .extensions_mut() + .extend(self.state_extensions.clone()); let ctx = RequestContext::new(request, params); let next = Next::new(&self.middlewares, entry.handler.as_ref()); next.run(ctx).await @@ -334,7 +329,7 @@ impl RouterService { middlewares: Vec, route_index: Arc<[RouteInfo]>, manifest_json: Option>, - state_inserters: Vec, + state_extensions: Extensions, ) -> Self { Self { inner: Arc::new(RouterInner { @@ -342,7 +337,7 @@ impl RouterService { middlewares, route_index, routes, - state_inserters, + state_extensions, }), } } diff --git a/docs/superpowers/plans/2026-07-02-edgezero-state-extractor.md b/docs/superpowers/plans/2026-07-02-edgezero-state-extractor.md index ab6d1eed..964cec06 100644 --- a/docs/superpowers/plans/2026-07-02-edgezero-state-extractor.md +++ b/docs/superpowers/plans/2026-07-02-edgezero-state-extractor.md @@ -35,7 +35,7 @@ | File | Responsibility | Change | | ---- | -------------- | ------ | | `crates/edgezero-core/src/extractor.rs` | The `State` extractor + `Deref`/`DerefMut`/`into_inner` + unit tests | Modify (append) | -| `crates/edgezero-core/src/router.rs` | `StateInserter` alias, `RouterBuilder::with_state`, thread `state_inserters` through `build()` → `RouterService::new` → `RouterInner`, apply in `dispatch` + router tests | Modify | +| `crates/edgezero-core/src/router.rs` | `state_extensions: Extensions` bag on `RouterBuilder`/`RouterInner`, `RouterBuilder::with_state` (inserts into it), thread through `build()` → `RouterService::new` → `RouterInner`, `extend` in `dispatch` + router tests | Modify | | `crates/edgezero-macros/tests/action_state.rs` | Integration test proving `#[action]` composes `State` with `Query` end-to-end | Create | | `crates/edgezero-macros/Cargo.toml` | Add `futures` dev-dependency (for `block_on` in the integration test) | Modify | | `docs/guide/handlers.md` | "Sharing app state" section | Modify (append) | @@ -218,8 +218,20 @@ git commit -m "feat(core): add State extractor for app-owned shared state" ## Task 2: `RouterBuilder::with_state` + dispatch plumbing +> **Superseded design note (post-review refactor):** the shipped implementation +> is simpler than the `StateInserter` closure approach the steps below describe. +> Instead of `state_inserters: Vec>`, the builder and +> `RouterInner` hold a single **`state_extensions: Extensions`** bag; +> `with_state` is `self.state_extensions.insert(value)`, and dispatch does +> `request.extensions_mut().extend(self.state_extensions.clone())` after the +> introspection inserts. This removes the alias, the `Vec`, one closure +> allocation per registered state, and one vtable call per state per request — +> identical behavior (`http::Extensions::insert` bound is `Clone + Send + Sync + +> 'static`; `extend` is last-write-wins by `TypeId`). The step-by-step code below +> reflects the original closure approach; follow router.rs for the final shape. + **Files:** -- Modify: `crates/edgezero-core/src/router.rs` (add `StateInserter` alias, `state_inserters` field on `RouterBuilder` and `RouterInner`, `with_state` method, 5th arg through `build()`/`RouterService::new`, apply in `dispatch`; add router tests in the existing `#[cfg(test)] mod tests`) +- Modify: `crates/edgezero-core/src/router.rs` (add `state_extensions: Extensions` field on `RouterBuilder` and `RouterInner`, `with_state` method inserting into it, 5th arg through `build()`/`RouterService::new`, `extend` in `dispatch`; add router tests in the existing `#[cfg(test)] mod tests`) **Interfaces:** - Consumes: `State` from Task 1 (`crate::extractor::State`), `crate::http::Extensions` (facade alias), `std::sync::Arc` (imported at `router.rs:2`). @@ -785,8 +797,8 @@ git commit -m "test(macros): prove #[action] composes State; docs: sharing ap ## Self-review notes (mapping to spec §3) - §3.1 `State` extractor + `Deref`/`into_inner` → Task 1. -- §3.2 router plumbing (`state_inserters` field, `with_state`, dispatch insertion) → Task 2, mirroring PR #300's `manifest_json` column exactly. +- §3.2 router plumbing (`state_extensions: Extensions` bag, `with_state`, dispatch `extend`) → Task 2, mirroring PR #300's `manifest_json` column; simplified from the spec's `StateInserter` closure sketch. - §3.3 naming → `State` only (no `Extension` alias), per locked decision. - §3.4 tests: resolves registered / 500 unregistered / Deref (Task 1); handler sees value / two `T`s coexist / last-write-wins (Task 2); `#[action]` composition (Task 3); concurrency/no-bleed (Task 2). - §3.5 docs: `docs/guide/handlers.md` + rustdoc → Task 3. -- §8 corrections folded in: facade `crate::http::Extensions` (not bare `http::Extensions`); no `lib.rs` re-export; `state_inserters` threaded through `RouterInner` + `RouterService::new` + `build()`. +- §8 corrections folded in: facade `crate::http::Extensions` (not bare `http::Extensions`); no `lib.rs` re-export; `state_extensions` bag threaded through `RouterInner` + `RouterService::new` + `build()`. diff --git a/docs/superpowers/specs/2026-07-02-edgezero-state-and-nested-secrets-design.md b/docs/superpowers/specs/2026-07-02-edgezero-state-and-nested-secrets-design.md index 036abc86..90783aa5 100644 --- a/docs/superpowers/specs/2026-07-02-edgezero-state-and-nested-secrets-design.md +++ b/docs/superpowers/specs/2026-07-02-edgezero-state-and-nested-secrets-design.md @@ -170,6 +170,15 @@ pub async fn handle_auction( ### 3.2 Router plumbing +> **Implemented design (simpler than this sketch):** rather than a `Vec` of +> type-erased closures, `RouterBuilder`/`RouterInner` hold a single +> `state_extensions: crate::http::Extensions` bag. `with_state` calls +> `self.state_extensions.insert(value)` and dispatch calls +> `request.extensions_mut().extend(self.state_extensions.clone())` after the +> introspection inserts — same `Clone + Send + Sync + 'static` bound, same +> last-write-wins-by-`TypeId`, no closure/vtable machinery. The sketch below is +> the original idea; the shipped code (router.rs) uses the `Extensions` bag. + Add to `RouterBuilder` a list of type-erased inserters and thread them into `RouterInner`: ```rust From d390874b7438443583220d6be00d298181a92fb3 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Fri, 3 Jul 2026 00:02:03 -0700 Subject: [PATCH 40/74] feat(secrets): path-navigating secret_walk (nested objects, arrays, optionals) --- crates/edgezero-core/src/extractor.rs | 394 +++++++++++++++++++++----- 1 file changed, 330 insertions(+), 64 deletions(-) diff --git a/crates/edgezero-core/src/extractor.rs b/crates/edgezero-core/src/extractor.rs index d80f7ace..8b3fc941 100644 --- a/crates/edgezero-core/src/extractor.rs +++ b/crates/edgezero-core/src/extractor.rs @@ -1,12 +1,14 @@ use std::any; +use std::future::Future; use std::ops::{Deref, DerefMut}; +use std::pin::Pin; use async_trait::async_trait; use http::header; use serde::de::DeserializeOwned; use validator::Validate; -use crate::app_config::{AppConfigMeta, SecretKind, SecretPathSegment}; +use crate::app_config::{AppConfigMeta, SecretField, SecretKind, SecretPathSegment}; use crate::blob_envelope::BlobEnvelope; use crate::config_store::ConfigStoreHandle; use crate::context::RequestContext; @@ -889,77 +891,159 @@ async fn secret_walk(ctx: &RequestContext, data: &mut serde_json::Value) -> R where C: AppConfigMeta, { - let data_obj = data - .as_object_mut() - .ok_or_else(|| EdgeError::internal(anyhow::anyhow!("blob `data` is not a JSON object")))?; for field in C::secret_fields() { - // Task 1: top-level only — the leaf is the single Field segment. - let leaf_key = match field.path.last() { - Some(SecretPathSegment::Field(name)) => name.clone().into_owned(), - _ => { - return Err(EdgeError::internal(anyhow::anyhow!( - "secret field `{}` has no field leaf", - field.dotted_path() - ))) + resolve_secret_field(ctx, data, &field, &field.path, String::new()).await?; + } + Ok(()) +} + +/// Recursively descend `remaining` path segments from `node`, resolving the +/// secret leaf(s). `rendered` is the dotted path so far (with concrete `[n]` +/// indices) for error hints. +fn resolve_secret_field<'walk>( + ctx: &'walk RequestContext, + node: &'walk mut serde_json::Value, + field: &'walk SecretField, + remaining: &'walk [SecretPathSegment], + rendered: String, +) -> Pin> + 'walk>> { + Box::pin(async move { + match remaining.split_first() { + // Leaf reached: `node` is the PARENT object; the last Field is the key. + Some((SecretPathSegment::Field(name), [])) => { + resolve_leaf(ctx, node, field, name.as_ref(), &rendered).await } - }; - let hint = field.dotted_path(); - let key_name = data_obj - .get(leaf_key.as_str()) - .and_then(|val| val.as_str()) - .ok_or_else(|| { + // Descend into an object key. + Some((SecretPathSegment::Field(name), rest)) => { + let next_rendered = join_field(&rendered, name.as_ref()); + match node.get_mut(name.as_ref()) { + // Absent optional subtree: key missing OR serialized as null. + None | Some(serde_json::Value::Null) if field.optional => Ok(()), + Some(child) => { + resolve_secret_field(ctx, child, field, rest, next_rendered).await + } + None => Err(EdgeError::config_out_of_date( + format!("missing or non-object value at `{next_rendered}`"), + next_rendered, + )), + } + } + // Iterate every array element. + Some((SecretPathSegment::ArrayEach, rest)) => { + let Some(items) = node.as_array_mut() else { + if field.optional { + return Ok(()); + } + return Err(EdgeError::config_out_of_date( + format!("expected an array at `{rendered}`"), + rendered, + )); + }; + for (idx, item) in items.iter_mut().enumerate() { + let indexed = format!("{rendered}[{idx}]"); + resolve_secret_field(ctx, item, field, rest, indexed).await?; + } + Ok(()) + } + None => Ok(()), + } + }) +} + +fn join_field(prefix: &str, name: &str) -> String { + if prefix.is_empty() { + name.to_owned() + } else { + format!("{prefix}.{name}") + } +} + +/// Resolve one leaf: `parent` is the innermost containing object; `key` is the +/// secret field name; `store_ref_field` (for `KeyInNamedStore`) is a sibling +/// within `parent`. +async fn resolve_leaf( + ctx: &RequestContext, + parent: &mut serde_json::Value, + field: &SecretField, + key: &str, + rendered_parent: &str, +) -> Result<(), EdgeError> { + if matches!(field.kind, SecretKind::StoreRef) { + return Ok(()); // store id, not a secret key + } + let leaf_path = join_field(rendered_parent, key); + + let Some(parent_obj) = parent.as_object_mut() else { + if field.optional { + return Ok(()); + } + return Err(EdgeError::config_out_of_date( + format!("expected an object containing `{key}` at `{rendered_parent}`"), + leaf_path, + )); + }; + + let key_name = match parent_obj.get(key) { + Some(serde_json::Value::String(name)) => name.clone(), + // An optional secret is absent when the key is MISSING *or* serialized + // as JSON `null`. serde emits `Option::None` as `null` (and `#[secret]` + // bans `skip_serializing_if`, so the key is never omitted), so both + // cases must skip — not just the missing-key case. + None | Some(serde_json::Value::Null) if field.optional => return Ok(()), + _ => { + return Err(EdgeError::config_out_of_date( + format!("missing or non-string value at `{leaf_path}`"), + leaf_path, + )) + } + }; + + let (bound, resolved_store_id) = match field.kind { + SecretKind::KeyInDefault => { + let bound = ctx.secret_store_default().ok_or_else(|| { EdgeError::config_out_of_date( - format!("missing or non-string value at `{hint}`"), - hint.clone(), + format!( + "secret field `{leaf_path}` has kind KeyInDefault but no default secret \ + store is registered" + ), + leaf_path.clone(), ) - })? - .to_owned(); - let (bound, resolved_store_id) = match field.kind { - SecretKind::KeyInDefault => { - let bound = ctx.secret_store_default().ok_or_else(|| { - EdgeError::config_out_of_date( - format!( - "secret field `{hint}` has kind KeyInDefault but no default secret \ - store is registered", - ), - hint.clone(), - ) - })?; - let id = bound.store_name().to_owned(); - (bound, id) - } - SecretKind::StoreRef => continue, - SecretKind::KeyInNamedStore { store_ref_field } => { - let store_id_str = data_obj - .get(store_ref_field) - .and_then(|val| val.as_str()) - .ok_or_else(|| { - EdgeError::config_out_of_date( - format!( - "missing store_ref `{store_ref_field}` for secret field `{hint}`" - ), - hint.clone(), - ) - })? - .to_owned(); - let bound = ctx.secret_store(&store_id_str).ok_or_else(|| { + })?; + let id = bound.store_name().to_owned(); + (bound, id) + } + SecretKind::StoreRef => return Ok(()), + SecretKind::KeyInNamedStore { store_ref_field } => { + let store_id_str = parent_obj + .get(store_ref_field) + .and_then(|val| val.as_str()) + .ok_or_else(|| { EdgeError::config_out_of_date( format!( - "blob declared store_ref `{store_id_str}` but \ - [stores.secrets] has no such id" + "missing store_ref `{store_ref_field}` for secret field `{leaf_path}`" ), - hint.clone(), + leaf_path.clone(), ) - })?; - (bound, store_id_str) - } - }; - let secret = bound - .require_str(&key_name) - .await - .map_err(|err| map_secret_error(err, &hint, &resolved_store_id, &key_name))?; - data_obj.insert(leaf_key, serde_json::Value::String(secret)); - } + })? + .to_owned(); + let bound = ctx.secret_store(&store_id_str).ok_or_else(|| { + EdgeError::config_out_of_date( + format!( + "blob declared store_ref `{store_id_str}` but \ + [stores.secrets] has no such id" + ), + leaf_path.clone(), + ) + })?; + (bound, store_id_str) + } + }; + + let secret = bound + .require_str(&key_name) + .await + .map_err(|err| map_secret_error(err, &leaf_path, &resolved_store_id, &key_name))?; + parent_obj.insert(key.to_owned(), serde_json::Value::String(secret)); Ok(()) } @@ -1147,6 +1231,68 @@ mod tests { } } + // Array leaf: partners[*].api_key + struct ArrayCfg; + impl AppConfigMeta for ArrayCfg { + fn secret_fields() -> Vec { + vec![SecretField { + kind: SecretKind::KeyInDefault, + path: vec![ + SecretPathSegment::Field(Cow::Borrowed("partners")), + SecretPathSegment::ArrayEach, + SecretPathSegment::Field(Cow::Borrowed("api_key")), + ], + optional: false, + }] + } + } + + // Nested KeyInNamedStore: vaulted.token resolves against the store named by + // its SIBLING vaulted.vault (the sibling-in-innermost-parent scoping rule). + struct NamedStoreCfg; + impl AppConfigMeta for NamedStoreCfg { + fn secret_fields() -> Vec { + vec![SecretField { + kind: SecretKind::KeyInNamedStore { + store_ref_field: "vault", + }, + path: vec![ + SecretPathSegment::Field(Cow::Borrowed("vaulted")), + SecretPathSegment::Field(Cow::Borrowed("token")), + ], + optional: false, + }] + } + } + + // Nested object leaf: integrations.datadome.server_side_key + struct NestedCfg; + impl AppConfigMeta for NestedCfg { + fn secret_fields() -> Vec { + vec![SecretField { + kind: SecretKind::KeyInDefault, + path: vec![ + SecretPathSegment::Field(Cow::Borrowed("integrations")), + SecretPathSegment::Field(Cow::Borrowed("datadome")), + SecretPathSegment::Field(Cow::Borrowed("server_side_key")), + ], + optional: false, + }] + } + } + + // Optional top-level leaf: maybe_key + struct OptionalCfg; + impl AppConfigMeta for OptionalCfg { + fn secret_fields() -> Vec { + vec![SecretField { + kind: SecretKind::KeyInDefault, + path: vec![SecretPathSegment::Field(Cow::Borrowed("maybe_key"))], + optional: true, + }] + } + } + fn ctx(body: Body, params: PathParams) -> RequestContext { let request = request_builder() .method(Method::POST) @@ -2305,6 +2451,126 @@ mod tests { } } + // Build a RequestContext whose default secret store maps `default/{key}` -> + // `value`, for exercising `secret_walk` directly. + fn ctx_with_default_secret_store(key: &str, value: &str) -> RequestContext { + ctx_with_default_secret_store_map(&[(key, value)]) + } + + // Multi-entry variant of `ctx_with_default_secret_store`. + fn ctx_with_default_secret_store_map(entries: &[(&str, &str)]) -> RequestContext { + let store = InMemorySecretStore::new(entries.iter().map(|(key, value)| { + ( + format!("default/{key}"), + bytes::Bytes::from((*value).to_owned()), + ) + })); + let bound = BoundSecretStore::new(SecretHandle::new(Arc::new(store)), "default".to_owned()); + let registry: SecretRegistry = StoreRegistry::single_id("default".to_owned(), bound); + let mut request = request_builder() + .method(Method::GET) + .uri("/cfg") + .body(Body::empty()) + .expect("request"); + request.extensions_mut().insert(registry); + RequestContext::new(request, PathParams::default()) + } + + // Build a RequestContext whose secret store `store_id` maps + // `{store_id}/{key}` -> `value`, resolvable via `ctx.secret_store(store_id)`. + fn ctx_with_named_secret_store(store_id: &str, key: &str, value: &str) -> RequestContext { + let store = InMemorySecretStore::new([( + format!("{store_id}/{key}"), + bytes::Bytes::from(value.to_owned()), + )]); + let bound = BoundSecretStore::new(SecretHandle::new(Arc::new(store)), store_id.to_owned()); + let registry: SecretRegistry = StoreRegistry::single_id(store_id.to_owned(), bound); + let mut request = request_builder() + .method(Method::GET) + .uri("/cfg") + .body(Body::empty()) + .expect("request"); + request.extensions_mut().insert(registry); + RequestContext::new(request, PathParams::default()) + } + + #[test] + fn secret_walk_resolves_nested_object_leaf() { + let ctx = ctx_with_default_secret_store("dd_key", "resolved-dd"); + let mut data = serde_json::json!({ + "integrations": { "datadome": { "server_side_key": "dd_key" } } + }); + block_on(secret_walk::(&ctx, &mut data)).expect("walk"); + assert_eq!( + data["integrations"]["datadome"]["server_side_key"], + serde_json::json!("resolved-dd") + ); + } + + #[test] + fn secret_walk_resolves_each_array_element() { + let ctx = ctx_with_default_secret_store_map(&[("k0", "v0"), ("k1", "v1")]); + let mut data = serde_json::json!({ + "partners": [ { "api_key": "k0" }, { "api_key": "k1" } ] + }); + block_on(secret_walk::(&ctx, &mut data)).expect("walk"); + assert_eq!(data["partners"][0]["api_key"], serde_json::json!("v0")); + assert_eq!(data["partners"][1]["api_key"], serde_json::json!("v1")); + } + + #[test] + fn secret_walk_resolves_nested_named_store_via_sibling_in_parent() { + let ctx = ctx_with_named_secret_store("named", "tok_key", "TOK"); + let mut data = serde_json::json!({ + "vaulted": { "token": "tok_key", "vault": "named" } + }); + block_on(secret_walk::(&ctx, &mut data)).expect("walk"); + assert_eq!(data["vaulted"]["token"], serde_json::json!("TOK")); + // The store_ref sibling is left intact (it names a store, not a secret). + assert_eq!(data["vaulted"]["vault"], serde_json::json!("named")); + } + + #[test] + fn secret_walk_nested_named_store_missing_sibling_errors_with_dotted_path() { + let ctx = ctx_with_named_secret_store("named", "tok_key", "TOK"); + let mut data = serde_json::json!({ "vaulted": { "token": "tok_key" } }); // no `vault` + let err = block_on(secret_walk::(&ctx, &mut data)) + .expect_err("missing store_ref sibling"); + assert_eq!(err.status(), StatusCode::SERVICE_UNAVAILABLE); + assert!(err.to_string().contains("vaulted.token")); + } + + #[test] + fn secret_walk_skips_absent_optional_leaf() { + let ctx = ctx_with_default_secret_store("unused", "unused"); + let mut data = serde_json::json!({ "greeting": "hi" }); // no maybe_key + block_on(secret_walk::(&ctx, &mut data)).expect("absent optional is fine"); + assert!(data.get("maybe_key").is_none()); + } + + #[test] + fn secret_walk_skips_null_optional_leaf() { + // serde serializes `Option::None` as JSON `null` (the key is present, + // not omitted). The walk must skip a null optional leaf, not error it. + let ctx = ctx_with_default_secret_store("unused", "unused"); + let mut data = serde_json::json!({ "maybe_key": null }); + block_on(secret_walk::(&ctx, &mut data)) + .expect("null optional is skipped, not treated as non-string"); + assert_eq!(data["maybe_key"], serde_json::json!(null)); // left untouched + } + + #[test] + fn secret_walk_missing_required_nested_leaf_errors_with_dotted_path() { + let ctx = ctx_with_default_secret_store("dd_key", "resolved-dd"); + let mut data = serde_json::json!({ "integrations": { "datadome": {} } }); + let err = block_on(secret_walk::(&ctx, &mut data)) + .expect_err("missing required nested leaf"); + assert_eq!(err.status(), StatusCode::SERVICE_UNAVAILABLE); + assert!(err + .to_string() + .contains("integrations.datadome.server_side_key")); + } + #[test] fn app_config_named_reads_different_key() { struct KeyEchoStore; From fe7bd72dc6b85d28956c1ec7ae079c40de9c4aba Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Fri, 3 Jul 2026 00:10:11 -0700 Subject: [PATCH 41/74] feat(secrets): nested/list-aware validate_excluding_secrets pruning --- crates/edgezero-core/src/app_config.rs | 235 ++++++++++++++++++++++++- 1 file changed, 227 insertions(+), 8 deletions(-) diff --git a/crates/edgezero-core/src/app_config.rs b/crates/edgezero-core/src/app_config.rs index ef6faf6e..2cd16d90 100644 --- a/crates/edgezero-core/src/app_config.rs +++ b/crates/edgezero-core/src/app_config.rs @@ -246,24 +246,75 @@ pub fn validate_excluding_secrets( let Err(mut errors) = result else { return Ok(()); }; - // validator 0.20 exposes errors_mut() -> &mut HashMap, ValidationErrorsKind>. - let bag = errors.errors_mut(); for field in C::secret_fields() { if matches!(field.kind, SecretKind::StoreRef) { continue; // store_id field; validator stays } - // Task 1: flat removal by the first path segment (length-1 paths only - // exist until the derive emits nesting). Task 3 makes this nested-aware. - if let Some(SecretPathSegment::Field(name)) = field.path.first() { - bag.remove(name.as_ref()); - } + prune_secret_leaf(&mut errors, &field.path); } - if bag.is_empty() { + if errors.errors().is_empty() { return Ok(()); } Err(errors) } +/// Remove the per-field validator error for the secret leaf at `path`, +/// descending `ValidationErrorsKind::Struct`/`List` containers, and prune any +/// container that becomes empty so a fully-cleared branch disappears. Without +/// the prune, an empty `Struct`/`List` marker would keep `errors` non-empty and +/// make `validate_excluding_secrets` wrongly return `Err`. +fn prune_secret_leaf(errors: &mut ValidationErrors, path: &[SecretPathSegment]) { + use validator::ValidationErrorsKind; + + let Some((head, rest)) = path.split_first() else { + return; + }; + let SecretPathSegment::Field(name) = head else { + // `ArrayEach` only appears immediately after a `Field` (the root is + // always a struct), so it is consumed by the peek below, never a head. + return; + }; + + // Leaf reached: drop the validator error keyed by this field name. + if rest.is_empty() { + errors.errors_mut().remove(name.as_ref()); + return; + } + + // A `Field` immediately followed by `ArrayEach` targets a `List` nested + // under the field's key; consume the `ArrayEach` here so the recursive + // descent sees each element's own remaining path. + let (kind_is_array, tail) = + if let Some((SecretPathSegment::ArrayEach, array_tail)) = rest.split_first() { + (true, array_tail) + } else { + (false, rest) + }; + + let mut clear = false; + if kind_is_array { + if let Some(ValidationErrorsKind::List(items)) = errors.errors_mut().get_mut(name.as_ref()) + { + for inner in items.values_mut() { + prune_secret_leaf(inner, tail); + } + items.retain(|_, inner| !inner.errors().is_empty()); + clear = items.is_empty(); + } + } + if !kind_is_array { + if let Some(ValidationErrorsKind::Struct(inner)) = + errors.errors_mut().get_mut(name.as_ref()) + { + prune_secret_leaf(inner, tail); + clear = inner.errors().is_empty(); + } + } + if clear { + errors.errors_mut().remove(name.as_ref()); + } +} + /// Load and validate a typed app-config from `.toml`. /// /// `env_overlay` is on by default; pass [`AppConfigLoadOptions`] @@ -1265,6 +1316,174 @@ greeting = "hello" validate_excluding_secrets(&cfg).unwrap_err(); } + #[test] + fn validate_excluding_secrets_prunes_nested_secret_leaf_validator() { + #[derive(Validate)] + struct Inner { + #[validate(length(min = 100))] + server_side_key: String, // holds a short KEY NAME at push time + } + #[derive(Validate)] + struct Outer { + #[validate(nested)] + integrations: Inner, + } + impl AppConfigMeta for Outer { + fn secret_fields() -> Vec { + vec![SecretField { + kind: SecretKind::KeyInDefault, + path: vec![ + SecretPathSegment::Field(Cow::Borrowed("integrations")), + SecretPathSegment::Field(Cow::Borrowed("server_side_key")), + ], + optional: false, + }] + } + } + + let cfg = Outer { + integrations: Inner { + server_side_key: "dd_key".to_owned(), // 6 chars < 100 + }, + }; + // The only failure is the nested secret leaf's validator -> pruned -> Ok. + validate_excluding_secrets(&cfg).unwrap(); + } + + #[test] + fn validate_excluding_secrets_keeps_nested_non_secret_failures() { + #[derive(Validate)] + struct Inner { + #[validate(length(min = 100))] + note: String, // NON-secret, must still fail + #[validate(length(min = 100))] + server_side_key: String, + } + #[derive(Validate)] + struct Outer { + #[validate(nested)] + integrations: Inner, + } + impl AppConfigMeta for Outer { + fn secret_fields() -> Vec { + vec![SecretField { + kind: SecretKind::KeyInDefault, + path: vec![ + SecretPathSegment::Field(Cow::Borrowed("integrations")), + SecretPathSegment::Field(Cow::Borrowed("server_side_key")), + ], + optional: false, + }] + } + } + + let cfg = Outer { + integrations: Inner { + server_side_key: "dd_key".to_owned(), + note: "short".to_owned(), + }, + }; + validate_excluding_secrets(&cfg).unwrap_err(); // `note` still fails + } + + #[test] + fn validate_excluding_secrets_prunes_array_secret_leaf_keeps_siblings() { + #[derive(Validate)] + struct Outer { + #[validate(nested)] + partners: Vec, + } + #[derive(Validate)] + struct Partner { + #[validate(length(min = 100))] + api_key: String, // secret leaf (a key NAME at push time) + #[validate(length(min = 100))] + label: String, // NON-secret sibling + } + impl AppConfigMeta for Outer { + fn secret_fields() -> Vec { + vec![SecretField { + kind: SecretKind::KeyInDefault, + path: vec![ + SecretPathSegment::Field(Cow::Borrowed("partners")), + SecretPathSegment::ArrayEach, + SecretPathSegment::Field(Cow::Borrowed("api_key")), + ], + optional: false, + }] + } + } + + // Every element fails BOTH validators at push time. + let cfg = Outer { + partners: vec![ + Partner { + api_key: "k0".to_owned(), + label: "s".to_owned(), + }, + Partner { + api_key: "k1".to_owned(), + label: "s".to_owned(), + }, + ], + }; + // `api_key` (secret) pruned from every List element; `label` + // (non-secret) survives in every element -> overall Err. + let err = validate_excluding_secrets(&cfg).expect_err("non-secret siblings still fail"); + let rendered = format!("{err:?}"); + assert!( + rendered.contains("label"), + "non-secret sibling must survive" + ); + assert!( + !rendered.contains("api_key"), + "secret leaf must be pruned from every array element" + ); + } + + #[test] + fn validate_excluding_secrets_prunes_array_all_secret_failures_to_ok() { + #[derive(Validate)] + struct Outer { + #[validate(nested)] + partners: Vec, + } + #[derive(Validate)] + struct Partner { + #[validate(length(min = 100))] + api_key: String, // the ONLY validated field, and it's the secret leaf + } + impl AppConfigMeta for Outer { + fn secret_fields() -> Vec { + vec![SecretField { + kind: SecretKind::KeyInDefault, + path: vec![ + SecretPathSegment::Field(Cow::Borrowed("partners")), + SecretPathSegment::ArrayEach, + SecretPathSegment::Field(Cow::Borrowed("api_key")), + ], + optional: false, + }] + } + } + + // Every element's only failure is the secret leaf -> each List element + // clears -> the empty List is pruned -> `partners` removed -> Ok. + let cfg = Outer { + partners: vec![ + Partner { + api_key: "k0".to_owned(), + }, + Partner { + api_key: "k1".to_owned(), + }, + ], + }; + validate_excluding_secrets(&cfg).expect( + "an array branch whose only failures are secret leaves must fully prune to Ok(())", + ); + } + #[test] fn dotted_path_renders_nested_and_array_segments() { use super::{SecretField, SecretKind, SecretPathSegment::*}; From 9351db14d2384e7cb56f361d3cdd3ea7a6f3d8b8 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Fri, 3 Jul 2026 08:28:40 -0700 Subject: [PATCH 42/74] feat(macros): #[app_config(nested)] recursion, Option secrets, path emission --- crates/edgezero-macros/src/app_config.rs | 272 +++++++++++++++--- crates/edgezero-macros/src/lib.rs | 2 +- .../tests/app_config_derive.rs | 98 +++++++ .../ui/app_config_nested_on_non_appconfig.rs | 15 + .../app_config_nested_on_non_appconfig.stderr | 40 +++ .../tests/ui/app_config_unknown_option.rs | 10 + .../tests/ui/app_config_unknown_option.stderr | 5 + ...y_in_named_store_sibling_not_string.stderr | 2 +- .../tests/ui/nested_field_serde_rename.rs | 18 ++ .../tests/ui/nested_field_serde_rename.stderr | 5 + .../tests/ui/nested_parent_rename_all.rs | 18 ++ .../tests/ui/nested_parent_rename_all.stderr | 5 + .../tests/ui/secret_on_non_scalar.stderr | 2 +- .../tests/ui/secret_on_option_non_string.rs | 10 + .../ui/secret_on_option_non_string.stderr | 5 + .../tests/ui/secret_store_ref_optional.rs | 10 + .../tests/ui/secret_store_ref_optional.stderr | 5 + 17 files changed, 482 insertions(+), 40 deletions(-) create mode 100644 crates/edgezero-macros/tests/ui/app_config_nested_on_non_appconfig.rs create mode 100644 crates/edgezero-macros/tests/ui/app_config_nested_on_non_appconfig.stderr create mode 100644 crates/edgezero-macros/tests/ui/app_config_unknown_option.rs create mode 100644 crates/edgezero-macros/tests/ui/app_config_unknown_option.stderr create mode 100644 crates/edgezero-macros/tests/ui/nested_field_serde_rename.rs create mode 100644 crates/edgezero-macros/tests/ui/nested_field_serde_rename.stderr create mode 100644 crates/edgezero-macros/tests/ui/nested_parent_rename_all.rs create mode 100644 crates/edgezero-macros/tests/ui/nested_parent_rename_all.stderr create mode 100644 crates/edgezero-macros/tests/ui/secret_on_option_non_string.rs create mode 100644 crates/edgezero-macros/tests/ui/secret_on_option_non_string.stderr create mode 100644 crates/edgezero-macros/tests/ui/secret_store_ref_optional.rs create mode 100644 crates/edgezero-macros/tests/ui/secret_store_ref_optional.stderr diff --git a/crates/edgezero-macros/src/app_config.rs b/crates/edgezero-macros/src/app_config.rs index c2feb682..7f1d9120 100644 --- a/crates/edgezero-macros/src/app_config.rs +++ b/crates/edgezero-macros/src/app_config.rs @@ -12,8 +12,8 @@ use proc_macro2::{Span, TokenStream as TokenStream2}; use quote::quote; use syn::punctuated::Punctuated; use syn::{ - parse_macro_input, Attribute, Data, DeriveInput, Expr, ExprLit, Field, Fields, Ident, Lit, - Meta, MetaNameValue, Path, Type, + parse_macro_input, Attribute, Data, DeriveInput, Expr, ExprLit, Field, Fields, GenericArgument, + Ident, Lit, Meta, MetaNameValue, Path, PathArguments, Type, }; /// Recognised `#[secret(...)]` annotation kinds. @@ -36,6 +36,20 @@ enum SecretAnnotation { struct FieldAnnotation { kind: SecretAnnotation, name: Ident, + /// `true` when the annotated field is `Option`. + optional: bool, +} + +/// A `#[app_config(nested)]` field to recurse into when emitting +/// `secret_fields()`. +struct NestedDescriptor<'field> { + /// The element type whose `secret_fields()` are prepended: the field + /// type for an object, or the `Vec`/slice element type for an array. + child_ty: &'field Type, + /// The Rust field name, emitted verbatim as a `Field` path segment. + field_name: Ident, + /// `true` when the field is `Vec` / `[T]` (emit `Field` + `ArrayEach`). + is_array: bool, } /// Inspect the input struct, emit `impl AppConfigMeta` with the @@ -50,29 +64,21 @@ pub fn derive(tokens: TokenStream) -> TokenStream { } fn expand(input: &DeriveInput) -> Result { - let struct_ident = &input.ident; - let (impl_generics, type_generics, where_clause) = input.generics.split_for_impl(); - let fields = struct_fields(input)?; // Enforce serde skip/flatten bans on EVERY field (not just secret ones). enforce_no_disallowed_serde_attrs_on_all_fields(fields)?; - let mut annotations: Vec = Vec::new(); - for field in fields { - if let Some(annotation) = scan_field(field)? { - annotations.push(annotation); - } - } + let (annotations, nested_descriptors) = classify_fields(fields)?; - // SECRET_FIELDS emits the Rust field name verbatim. A container- + // secret_fields() emits the Rust field name verbatim. A container- // level `#[serde(rename_all = ...)]` would desync that metadata // from what `config validate` (and the Spin collision check) sees - // on the wire — silently — so reject it whenever any - // secret field is present. Structs with no secret fields are - // unaffected: SECRET_FIELDS is empty and the validator never - // compares names. - if !annotations.is_empty() { + // on the wire — silently — so reject it whenever any secret field is + // present, whether direct or reached through a nested child. Structs + // with no secret paths are unaffected: secret_fields() is empty and + // the validator never compares names. + if !annotations.is_empty() || !nested_descriptors.is_empty() { enforce_no_container_rename_all(&input.attrs)?; } @@ -125,8 +131,67 @@ fn expand(input: &DeriveInput) -> Result { } } - let entries = annotations.iter().map(|annotation| { + Ok(emit_impl(input, &annotations, &nested_descriptors)) +} + +/// Classify every field as a direct `#[secret]` annotation or a +/// `#[app_config(nested)]` recursion descriptor. A field may not be both. +fn classify_fields( + fields: &Punctuated, +) -> syn::Result<(Vec, Vec>)> { + let mut annotations: Vec = Vec::new(); + let mut nested_descriptors: Vec = Vec::new(); + for field in fields { + let is_nested = nested_optin(field)?; + match scan_field(field)? { + Some(_) if is_nested => { + return Err(syn::Error::new_spanned( + field, + "a field may not be both `#[secret]` and `#[app_config(nested)]`", + )); + } + Some(annotation) => annotations.push(annotation), + None if is_nested => { + // The emitter writes `Field(field_name)` verbatim, so a + // `#[serde(rename/flatten/skip*)]` on the nested parent would + // desync the path segment from the serialized key — banned on + // any secret path. + enforce_no_disallowed_serde_attrs(field)?; + let Some(field_name) = field.ident.clone() else { + return Err(syn::Error::new_spanned( + field, + "`#[app_config(nested)]` requires a named field", + )); + }; + let (child_ty, is_array) = nested_child_type(&field.ty); + nested_descriptors.push(NestedDescriptor { + child_ty, + field_name, + is_array, + }); + } + None => {} + } + } + Ok((annotations, nested_descriptors)) +} + +/// Emit `impl AppConfigMeta` (with the `secret_fields()` body), the +/// `AppConfigRoot` marker impl, and a per-child `AppConfigRoot` bound +/// assertion. +fn emit_impl( + input: &DeriveInput, + annotations: &[FieldAnnotation], + nested_descriptors: &[NestedDescriptor<'_>], +) -> TokenStream2 { + let struct_ident = &input.ident; + let (impl_generics, type_generics, where_clause) = input.generics.split_for_impl(); + + // Direct `#[secret]` leaves: length-1 `Field` path, `optional` set from + // `Option`. + let direct_entries = annotations.iter().map(|annotation| { let name_lit = annotation.name.to_string(); + let optional = annotation.optional; let kind_tokens = match &annotation.kind { SecretAnnotation::KeyInDefault => { quote!(::edgezero_core::app_config::SecretKind::KeyInDefault) @@ -141,26 +206,89 @@ fn expand(input: &DeriveInput) -> Result { }) } }; - // Task 1: length-1 Field path, non-optional. Task 4 sets `optional` - // from Option and prepends nested/array segments. quote! { ::edgezero_core::app_config::SecretField { kind: #kind_tokens, path: ::std::vec![::edgezero_core::app_config::SecretPathSegment::Field( ::std::borrow::Cow::Borrowed(#name_lit) )], - optional: false, + optional: #optional, + } + } + }); + + // Nested children: prepend `Field(field)` (object) or `Field(field)` + + // `ArrayEach` (`Vec`/slice) onto every leaf the child reports. + let nested_pushes = nested_descriptors.iter().map(|descriptor| { + let field_lit = descriptor.field_name.to_string(); + let child_ty = descriptor.child_ty; + let prefix = if descriptor.is_array { + quote! { + ::std::vec![ + ::edgezero_core::app_config::SecretPathSegment::Field( + ::std::borrow::Cow::Borrowed(#field_lit) + ), + ::edgezero_core::app_config::SecretPathSegment::ArrayEach, + ] + } + } else { + quote! { + ::std::vec![ + ::edgezero_core::app_config::SecretPathSegment::Field( + ::std::borrow::Cow::Borrowed(#field_lit) + ), + ] + } + }; + quote! { + for mut __f in <#child_ty as ::edgezero_core::app_config::AppConfigMeta>::secret_fields() { + let mut __p = #prefix; + __p.append(&mut __f.path); + __f.path = __p; + __out.push(__f); } } }); - Ok(quote! { + let secret_fields_body = if nested_descriptors.is_empty() { + quote! { ::std::vec![#(#direct_entries),*] } + } else { + quote! { + let mut __out: ::std::vec::Vec<::edgezero_core::app_config::SecretField> = + ::std::vec![#(#direct_entries),*]; + #(#nested_pushes)* + __out + } + }; + + // A nested child must go through `#[derive(AppConfig)]` — the + // `AppConfigRoot` marker — not merely impl `AppConfigMeta` by hand. + // The closure is never called, but coercing it to `fn()` type-checks + // its body, enforcing the bound with a clear error span per child. + let nested_child_tys: Vec<&Type> = nested_descriptors + .iter() + .map(|descriptor| descriptor.child_ty) + .collect(); + let root_assertion = if nested_child_tys.is_empty() { + quote! {} + } else { + quote! { + const _: fn() = || { + fn __assert_app_config_root<__T: ::edgezero_core::app_config::AppConfigRoot>() {} + #( __assert_app_config_root::<#nested_child_tys>(); )* + }; + } + }; + + quote! { + #root_assertion + #[automatically_derived] impl #impl_generics ::edgezero_core::app_config::AppConfigMeta for #struct_ident #type_generics #where_clause { fn secret_fields() -> ::std::vec::Vec<::edgezero_core::app_config::SecretField> { - ::std::vec![#(#entries),*] + #secret_fields_body } } @@ -168,7 +296,7 @@ fn expand(input: &DeriveInput) -> Result { impl #impl_generics ::edgezero_core::app_config::AppConfigRoot for #struct_ident #type_generics #where_clause {} - }) + } } /// Borrow the struct's named fields, or error with a clear message. @@ -218,10 +346,71 @@ fn scan_field(field: &Field) -> Result, syn::Error> { } let kind = parse_secret_kind(first)?; - enforce_scalar_string_type(field)?; + let optional = secret_string_optionality(&field.ty).ok_or_else(|| { + syn::Error::new_spanned( + &field.ty, + "`#[secret]` may only annotate `String` or `Option`", + ) + })?; + // A `#[secret(store_ref)]` value is a store id — structural, always + // present. `Option` there is undefined (an absent store cannot + // resolve its dependent `KeyInNamedStore` sibling), so reject it. + if optional && matches!(kind, SecretAnnotation::StoreRef) { + return Err(syn::Error::new_spanned( + &field.ty, + "`#[secret(store_ref)]` may not be `Option`: a store id is structural and must always be present", + )); + } enforce_no_disallowed_serde_attrs(field)?; - Ok(Some(FieldAnnotation { kind, name })) + Ok(Some(FieldAnnotation { + kind, + name, + optional, + })) +} + +/// Whether `field` carries `#[app_config(nested)]`. Returns `Err` (not +/// `false`) on a malformed `#[app_config(...)]` such as `#[app_config(bogus)]` +/// or an empty `#[app_config()]`, so a typo is a hard compile error rather +/// than a silently-ignored non-recursion (which would drop the child's +/// secrets). +fn nested_optin(field: &Field) -> syn::Result { + let mut found = false; + for attr in &field.attrs { + if !attr.path().is_ident("app_config") { + continue; + } + attr.parse_nested_meta(|meta| { + if meta.path.is_ident("nested") { + found = true; + Ok(()) + } else { + Err(meta.error("`#[app_config(...)]` only accepts `nested`")) + } + })?; + } + Ok(found) +} + +/// The child element type to recurse into and whether it is an array element. +/// `Vec` / `[T]` -> (T, true); otherwise (`field_ty`, false). +fn nested_child_type(ty: &Type) -> (&Type, bool) { + if let Type::Path(type_path) = ty { + if let Some(last) = type_path.path.segments.last() { + if last.ident == "Vec" { + if let PathArguments::AngleBracketed(bracketed) = &last.arguments { + if let Some(GenericArgument::Type(inner)) = bracketed.args.first() { + return (inner, true); + } + } + } + } + } + if let Type::Slice(slice) = ty { + return (&slice.elem, true); + } + (ty, false) } /// Decode `#[secret]` (`KeyInDefault`), `#[secret(store_ref)]` @@ -264,18 +453,27 @@ fn parse_secret_kind(attr: &Attribute) -> Result { } } -/// `#[secret]` may only annotate a scalar string field. Per we -/// accept bare `String` only — generic or qualified forms (e.g. -/// `Option`, `Cow<'_, str>`) are intentionally rejected so -/// `cfg.api_token` resolves to a value at every call site. -fn enforce_scalar_string_type(field: &Field) -> Result<(), syn::Error> { - if !is_scalar_string_type(&field.ty) { - return Err(syn::Error::new_spanned( - &field.ty, - "`#[secret]` / `#[secret(store_ref)]` may only annotate a scalar string field (e.g. `String`)", - )); +/// Classify a `#[secret]` field's type: `String` -> `Some(false)`, +/// `Option` -> `Some(true)`, anything else (e.g. `Vec`, +/// `Cow<'_, str>`, non-string scalars) -> `None`. +fn secret_string_optionality(ty: &Type) -> Option { + if is_scalar_string_type(ty) { + return Some(false); } - Ok(()) + if let Type::Path(type_path) = ty { + if let Some(last) = type_path.path.segments.last() { + if last.ident == "Option" { + if let PathArguments::AngleBracketed(bracketed) = &last.arguments { + if let Some(GenericArgument::Type(inner)) = bracketed.args.first() { + if is_scalar_string_type(inner) { + return Some(true); + } + } + } + } + } + } + None } fn is_scalar_string_type(ty: &Type) -> bool { diff --git a/crates/edgezero-macros/src/lib.rs b/crates/edgezero-macros/src/lib.rs index 17a572d9..3c5538b3 100644 --- a/crates/edgezero-macros/src/lib.rs +++ b/crates/edgezero-macros/src/lib.rs @@ -17,7 +17,7 @@ pub fn app(input: TokenStream) -> TokenStream { app::expand_app(input) } -#[proc_macro_derive(AppConfig, attributes(secret))] +#[proc_macro_derive(AppConfig, attributes(secret, app_config))] #[inline] pub fn app_config_derive(input: TokenStream) -> TokenStream { app_config::derive(input) diff --git a/crates/edgezero-macros/tests/app_config_derive.rs b/crates/edgezero-macros/tests/app_config_derive.rs index 5cfb8833..af98bf29 100644 --- a/crates/edgezero-macros/tests/app_config_derive.rs +++ b/crates/edgezero-macros/tests/app_config_derive.rs @@ -4,6 +4,7 @@ #[cfg(test)] mod tests { use edgezero_core::app_config::{AppConfigMeta, AppConfigRoot, SecretKind}; + use validator::Validate as _; #[derive(serde::Deserialize, validator::Validate, edgezero_core::AppConfig)] #[serde(deny_unknown_fields)] @@ -66,6 +67,62 @@ mod tests { vault: String, } + // Optional secret: `#[secret]` on `Option` -> `optional: true`. + #[derive(serde::Deserialize, validator::Validate, edgezero_core::AppConfig)] + #[serde(deny_unknown_fields)] + #[expect( + dead_code, + reason = "fields exist only to feed `#[derive(AppConfig)]`; secret_fields() reads them via the derive, not via Rust field access" + )] + struct ConfigOptionalSecret { + #[secret] + api_token: Option, + } + + // Nested object + array recursion. + #[derive(serde::Deserialize, validator::Validate, edgezero_core::AppConfig)] + #[serde(deny_unknown_fields)] + #[expect( + dead_code, + reason = "fields exist only to feed `#[derive(AppConfig)]`; secret_fields() reads them via the derive, not via Rust field access" + )] + struct DataDome { + #[secret] + server_side_key: String, + } + + #[derive(serde::Deserialize, validator::Validate, edgezero_core::AppConfig)] + #[serde(deny_unknown_fields)] + struct Integrations { + #[app_config(nested)] + #[validate(nested)] + datadome: DataDome, + } + + #[derive(serde::Deserialize, validator::Validate, edgezero_core::AppConfig)] + #[serde(deny_unknown_fields)] + #[expect( + dead_code, + reason = "fields exist only to feed `#[derive(AppConfig)]`; secret_fields() reads them via the derive, not via Rust field access" + )] + struct Partner { + #[secret] + api_key: String, + #[secret] + maybe: Option, + } + + #[derive(serde::Deserialize, validator::Validate, edgezero_core::AppConfig)] + #[serde(deny_unknown_fields)] + struct Settings { + #[app_config(nested)] + #[validate(nested)] + integrations: Integrations, + #[app_config(nested)] + #[validate(nested)] + partners: Vec, + } + /// Reflect each derived `SecretField` down to the tuple the /// assertions compare: `(dotted_path, kind, optional)`. fn reflect() -> Vec<(String, SecretKind, bool)> { @@ -124,6 +181,40 @@ mod tests { ); } + #[test] + fn optional_string_secret_sets_optional_flag() { + assert_eq!( + reflect::(), + vec![("api_token".to_owned(), SecretKind::KeyInDefault, true)] + ); + } + + #[test] + fn nested_and_array_paths_are_emitted() { + let mut paths = reflect::(); + paths.sort_by(|left, right| left.0.cmp(&right.0)); + assert_eq!( + paths, + vec![ + ( + "integrations.datadome.server_side_key".to_owned(), + SecretKind::KeyInDefault, + false, + ), + ( + "partners[*].api_key".to_owned(), + SecretKind::KeyInDefault, + false + ), + ( + "partners[*].maybe".to_owned(), + SecretKind::KeyInDefault, + true + ), + ], + ); + } + #[test] fn derive_emits_app_config_root_impl() { // The trait is a marker; we just need it to compile and the @@ -150,6 +241,13 @@ mod tests { cases.compile_fail("tests/ui/non_secret_with_serde_flatten.rs"); cases.compile_fail("tests/ui/non_secret_with_serde_skip_serializing.rs"); cases.compile_fail("tests/ui/non_secret_with_serde_skip_serializing_if.rs"); + // `#[app_config(nested)]` recursion + `Option` secret guards. + // The `secret_*.rs` glob above already covers + // `secret_on_option_non_string.rs` and `secret_store_ref_optional.rs`. + cases.compile_fail("tests/ui/app_config_nested_on_non_appconfig.rs"); + cases.compile_fail("tests/ui/app_config_unknown_option.rs"); + cases.compile_fail("tests/ui/nested_field_serde_rename.rs"); + cases.compile_fail("tests/ui/nested_parent_rename_all.rs"); cases.pass("tests/ui/secret_with_store_ref_named.rs"); } } diff --git a/crates/edgezero-macros/tests/ui/app_config_nested_on_non_appconfig.rs b/crates/edgezero-macros/tests/ui/app_config_nested_on_non_appconfig.rs new file mode 100644 index 00000000..62a55b0c --- /dev/null +++ b/crates/edgezero-macros/tests/ui/app_config_nested_on_non_appconfig.rs @@ -0,0 +1,15 @@ +//! `#[app_config(nested)]` on a field whose type does not derive +//! `AppConfig` must fail with a clear `AppConfigRoot` bound error. + +#[derive(serde::Deserialize)] +struct NotAppConfig { + _key: String, +} + +#[derive(serde::Deserialize, validator::Validate, edgezero_core::AppConfig)] +struct Config { + #[app_config(nested)] + child: NotAppConfig, +} + +fn main() {} diff --git a/crates/edgezero-macros/tests/ui/app_config_nested_on_non_appconfig.stderr b/crates/edgezero-macros/tests/ui/app_config_nested_on_non_appconfig.stderr new file mode 100644 index 00000000..893b763a --- /dev/null +++ b/crates/edgezero-macros/tests/ui/app_config_nested_on_non_appconfig.stderr @@ -0,0 +1,40 @@ +error[E0277]: the trait bound `NotAppConfig: AppConfigRoot` is not satisfied + --> tests/ui/app_config_nested_on_non_appconfig.rs:12:12 + | +12 | child: NotAppConfig, + | ^^^^^^^^^^^^ unsatisfied trait bound + | +help: the trait `AppConfigRoot` is not implemented for `NotAppConfig` + --> tests/ui/app_config_nested_on_non_appconfig.rs:5:1 + | + 5 | struct NotAppConfig { + | ^^^^^^^^^^^^^^^^^^^ +help: the trait `AppConfigRoot` is implemented for `Config` + --> tests/ui/app_config_nested_on_non_appconfig.rs:9:51 + | + 9 | #[derive(serde::Deserialize, validator::Validate, edgezero_core::AppConfig)] + | ^^^^^^^^^^^^^^^^^^^^^^^^ +note: required by a bound in `__assert_app_config_root` + --> tests/ui/app_config_nested_on_non_appconfig.rs:9:51 + | + 9 | #[derive(serde::Deserialize, validator::Validate, edgezero_core::AppConfig)] + | ^^^^^^^^^^^^^^^^^^^^^^^^ required by this bound in `__assert_app_config_root` + = note: this error originates in the derive macro `edgezero_core::AppConfig` (in Nightly builds, run with -Z macro-backtrace for more info) + +error[E0277]: the trait bound `NotAppConfig: AppConfigMeta` is not satisfied + --> tests/ui/app_config_nested_on_non_appconfig.rs:12:12 + | +12 | child: NotAppConfig, + | ^^^^^^^^^^^^ unsatisfied trait bound + | +help: the trait `AppConfigMeta` is not implemented for `NotAppConfig` + --> tests/ui/app_config_nested_on_non_appconfig.rs:5:1 + | + 5 | struct NotAppConfig { + | ^^^^^^^^^^^^^^^^^^^ +help: the trait `AppConfigMeta` is implemented for `Config` + --> tests/ui/app_config_nested_on_non_appconfig.rs:9:51 + | + 9 | #[derive(serde::Deserialize, validator::Validate, edgezero_core::AppConfig)] + | ^^^^^^^^^^^^^^^^^^^^^^^^ + = note: this error originates in the derive macro `edgezero_core::AppConfig` (in Nightly builds, run with -Z macro-backtrace for more info) diff --git a/crates/edgezero-macros/tests/ui/app_config_unknown_option.rs b/crates/edgezero-macros/tests/ui/app_config_unknown_option.rs new file mode 100644 index 00000000..8fe0308a --- /dev/null +++ b/crates/edgezero-macros/tests/ui/app_config_unknown_option.rs @@ -0,0 +1,10 @@ +//! `#[app_config(bogus)]` must be a hard compile error (a typo must not +//! be silently ignored — that would drop the child's secrets). + +#[derive(serde::Deserialize, validator::Validate, edgezero_core::AppConfig)] +struct Config { + #[app_config(bogus)] + child: String, +} + +fn main() {} diff --git a/crates/edgezero-macros/tests/ui/app_config_unknown_option.stderr b/crates/edgezero-macros/tests/ui/app_config_unknown_option.stderr new file mode 100644 index 00000000..2db58821 --- /dev/null +++ b/crates/edgezero-macros/tests/ui/app_config_unknown_option.stderr @@ -0,0 +1,5 @@ +error: `#[app_config(...)]` only accepts `nested` + --> tests/ui/app_config_unknown_option.rs:6:18 + | +6 | #[app_config(bogus)] + | ^^^^^ diff --git a/crates/edgezero-macros/tests/ui/key_in_named_store_sibling_not_string.stderr b/crates/edgezero-macros/tests/ui/key_in_named_store_sibling_not_string.stderr index be76c2c4..c9e69eca 100644 --- a/crates/edgezero-macros/tests/ui/key_in_named_store_sibling_not_string.stderr +++ b/crates/edgezero-macros/tests/ui/key_in_named_store_sibling_not_string.stderr @@ -1,4 +1,4 @@ -error: `#[secret]` / `#[secret(store_ref)]` may only annotate a scalar string field (e.g. `String`) +error: `#[secret]` may only annotate `String` or `Option` --> tests/ui/key_in_named_store_sibling_not_string.rs:11:12 | 11 | vault: u32, diff --git a/crates/edgezero-macros/tests/ui/nested_field_serde_rename.rs b/crates/edgezero-macros/tests/ui/nested_field_serde_rename.rs new file mode 100644 index 00000000..548b969f --- /dev/null +++ b/crates/edgezero-macros/tests/ui/nested_field_serde_rename.rs @@ -0,0 +1,18 @@ +//! `#[serde(rename = "...")]` on a `#[app_config(nested)]` field must +//! error — the emitter writes the Rust field name verbatim as a `Field` +//! path segment, which a rename would desync from the serialized key. + +#[derive(serde::Deserialize, validator::Validate, edgezero_core::AppConfig)] +struct Child { + #[secret] + api_key: String, +} + +#[derive(serde::Deserialize, validator::Validate, edgezero_core::AppConfig)] +struct Config { + #[serde(rename = "x")] + #[app_config(nested)] + child: Child, +} + +fn main() {} diff --git a/crates/edgezero-macros/tests/ui/nested_field_serde_rename.stderr b/crates/edgezero-macros/tests/ui/nested_field_serde_rename.stderr new file mode 100644 index 00000000..e3d21b53 --- /dev/null +++ b/crates/edgezero-macros/tests/ui/nested_field_serde_rename.stderr @@ -0,0 +1,5 @@ +error: `#[secret]` is incompatible with `#[serde(rename)]` — the derive emits the Rust field name verbatim and config validate / push round-trip it via TOML + --> tests/ui/nested_field_serde_rename.rs:13:5 + | +13 | #[serde(rename = "x")] + | ^^^^^^^^^^^^^^^^^^^^^^ diff --git a/crates/edgezero-macros/tests/ui/nested_parent_rename_all.rs b/crates/edgezero-macros/tests/ui/nested_parent_rename_all.rs new file mode 100644 index 00000000..fb89b56c --- /dev/null +++ b/crates/edgezero-macros/tests/ui/nested_parent_rename_all.rs @@ -0,0 +1,18 @@ +//! A parent with only `#[app_config(nested)]` children (no direct +//! `#[secret]`) carrying `#[serde(rename_all = ...)]` must error — the +//! rename would desync the emitted `Field(parent_field)` path segment. + +#[derive(serde::Deserialize, validator::Validate, edgezero_core::AppConfig)] +struct Child { + #[secret] + api_key: String, +} + +#[derive(serde::Deserialize, validator::Validate, edgezero_core::AppConfig)] +#[serde(rename_all = "kebab-case")] +struct Config { + #[app_config(nested)] + child_config: Child, +} + +fn main() {} diff --git a/crates/edgezero-macros/tests/ui/nested_parent_rename_all.stderr b/crates/edgezero-macros/tests/ui/nested_parent_rename_all.stderr new file mode 100644 index 00000000..167edca5 --- /dev/null +++ b/crates/edgezero-macros/tests/ui/nested_parent_rename_all.stderr @@ -0,0 +1,5 @@ +error: `#[derive(AppConfig)]` rejects `#[serde(rename_all = ...)]` on structs with `#[secret]` fields: SECRET_FIELDS uses Rust field names verbatim, so a container rename would silently desync `config validate` from runtime deserialisation + --> tests/ui/nested_parent_rename_all.rs:12:1 + | +12 | #[serde(rename_all = "kebab-case")] + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ diff --git a/crates/edgezero-macros/tests/ui/secret_on_non_scalar.stderr b/crates/edgezero-macros/tests/ui/secret_on_non_scalar.stderr index 817d8c55..f2df9e2e 100644 --- a/crates/edgezero-macros/tests/ui/secret_on_non_scalar.stderr +++ b/crates/edgezero-macros/tests/ui/secret_on_non_scalar.stderr @@ -1,4 +1,4 @@ -error: `#[secret]` / `#[secret(store_ref)]` may only annotate a scalar string field (e.g. `String`) +error: `#[secret]` may only annotate `String` or `Option` --> tests/ui/secret_on_non_scalar.rs:7:17 | 7 | api_tokens: Vec, diff --git a/crates/edgezero-macros/tests/ui/secret_on_option_non_string.rs b/crates/edgezero-macros/tests/ui/secret_on_option_non_string.rs new file mode 100644 index 00000000..4ec447eb --- /dev/null +++ b/crates/edgezero-macros/tests/ui/secret_on_option_non_string.rs @@ -0,0 +1,10 @@ +//! `#[secret]` on `Option` must error — only `String` or +//! `Option` are accepted. + +#[derive(serde::Deserialize, validator::Validate, edgezero_core::AppConfig)] +struct Config { + #[secret] + api_token: Option, +} + +fn main() {} diff --git a/crates/edgezero-macros/tests/ui/secret_on_option_non_string.stderr b/crates/edgezero-macros/tests/ui/secret_on_option_non_string.stderr new file mode 100644 index 00000000..114ab8eb --- /dev/null +++ b/crates/edgezero-macros/tests/ui/secret_on_option_non_string.stderr @@ -0,0 +1,5 @@ +error: `#[secret]` may only annotate `String` or `Option` + --> tests/ui/secret_on_option_non_string.rs:7:16 + | +7 | api_token: Option, + | ^^^^^^^^^^^ diff --git a/crates/edgezero-macros/tests/ui/secret_store_ref_optional.rs b/crates/edgezero-macros/tests/ui/secret_store_ref_optional.rs new file mode 100644 index 00000000..2ea63bff --- /dev/null +++ b/crates/edgezero-macros/tests/ui/secret_store_ref_optional.rs @@ -0,0 +1,10 @@ +//! `#[secret(store_ref)]` on `Option` must error — a store id is +//! structural and must always be present. + +#[derive(serde::Deserialize, validator::Validate, edgezero_core::AppConfig)] +struct Config { + #[secret(store_ref)] + vault: Option, +} + +fn main() {} diff --git a/crates/edgezero-macros/tests/ui/secret_store_ref_optional.stderr b/crates/edgezero-macros/tests/ui/secret_store_ref_optional.stderr new file mode 100644 index 00000000..cd0e028d --- /dev/null +++ b/crates/edgezero-macros/tests/ui/secret_store_ref_optional.stderr @@ -0,0 +1,5 @@ +error: `#[secret(store_ref)]` may not be `Option`: a store id is structural and must always be present + --> tests/ui/secret_store_ref_optional.rs:7:12 + | +7 | vault: Option, + | ^^^^^^^^^^^^^^ From e26529bbfedcf2884b2e3a3801368c895ca12851 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Fri, 3 Jul 2026 08:35:38 -0700 Subject: [PATCH 43/74] ci(secrets): allow nested AppConfig when field opts in via #[app_config(nested)] --- .../src/bin/check_no_nested_app_config.rs | 78 +++++++++++++++++++ 1 file changed, 78 insertions(+) diff --git a/crates/edgezero-cli/src/bin/check_no_nested_app_config.rs b/crates/edgezero-cli/src/bin/check_no_nested_app_config.rs index 1aa71c42..a9f5c26f 100644 --- a/crates/edgezero-cli/src/bin/check_no_nested_app_config.rs +++ b/crates/edgezero-cli/src/bin/check_no_nested_app_config.rs @@ -63,6 +63,7 @@ use walkdir::WalkDir; // Pass 1: collect struct identifiers that derive AppConfig // --------------------------------------------------------------------------- +#[derive(Default)] struct AppConfigStructCollector { app_config_structs: HashSet, } @@ -166,6 +167,9 @@ impl<'ast> Visit<'ast> for NestedAppConfigVisitor<'_, '_> { if let Some(inner_name) = type_contains_app_config_struct(&field.ty, self.app_config_structs) { + if field_has_nested_optin(field) { + continue; // opted in via #[app_config(nested)] — allowed + } let span = field .ident .as_ref() @@ -177,6 +181,33 @@ impl<'ast> Visit<'ast> for NestedAppConfigVisitor<'_, '_> { } } +/// Returns `true` only for a well-formed `#[app_config(nested)]`. A malformed +/// `#[app_config(...)]` returns `false` -> the field is treated as NOT opted +/// in, so the guard still FLAGS the nesting (loud CI failure) rather than +/// silently waving it through. This is safe here (unlike the derive's +/// `nested_optin`, which must hard-error): the guard runs only over +/// already-compiling code, and the derive's strict `nested_optin` has already +/// rejected any malformed `#[app_config(...)]` before this binary ever runs. +fn field_has_nested_optin(field: &syn::Field) -> bool { + field.attrs.iter().any(|attr| { + if !attr.path().is_ident("app_config") { + return false; + } + // Must actually see `nested`. A bare `#[app_config()]` parses Ok but + // never sets `found`, so `.is_ok()` alone would wrongly report opt-in. + let mut found = false; + let parsed = attr.parse_nested_meta(|meta| { + if meta.path.is_ident("nested") { + found = true; + Ok(()) + } else { + Err(meta.error("unknown app_config option")) + } + }); + parsed.is_ok() && found + }) +} + // --------------------------------------------------------------------------- // Type-unwrapping helpers // --------------------------------------------------------------------------- @@ -351,3 +382,50 @@ fn main() { println!("check_no_nested_app_config: OK"); } + +#[cfg(test)] +mod tests { + use super::*; + + const NESTED_VEC_WITH_OPT_IN: &str = " + #[derive(edgezero_core::AppConfig)] struct Inner { #[secret] k: String } + #[derive(edgezero_core::AppConfig)] struct Outer { #[app_config(nested)] inner: Vec } + "; + + const NESTED_WITHOUT_OPT_IN: &str = " + #[derive(edgezero_core::AppConfig)] struct Inner { #[secret] k: String } + #[derive(edgezero_core::AppConfig)] struct Outer { inner: Inner } + "; + + const NESTED_WITH_OPT_IN: &str = " + #[derive(edgezero_core::AppConfig)] struct Inner { #[secret] k: String } + #[derive(edgezero_core::AppConfig)] struct Outer { #[app_config(nested)] inner: Inner } + "; + + fn violations_in(src: &str) -> usize { + let file = syn::parse_file(src).expect("parse"); + let mut collector = AppConfigStructCollector::default(); + visit::visit_file(&mut collector, &file); + // NB: `new(source_path, app_config_structs)` — path FIRST, per the real + // signature above. + let mut visitor = + NestedAppConfigVisitor::new(Path::new("t.rs"), &collector.app_config_structs); + visit::visit_file(&mut visitor, &file); + visitor.violations + } + + #[test] + fn allows_nesting_with_opt_in() { + assert_eq!(violations_in(NESTED_WITH_OPT_IN), 0); + } + + #[test] + fn allows_vec_nesting_with_opt_in() { + assert_eq!(violations_in(NESTED_VEC_WITH_OPT_IN), 0); + } + + #[test] + fn flags_nesting_without_opt_in() { + assert_eq!(violations_in(NESTED_WITHOUT_OPT_IN), 1); + } +} From 4ce2bc5f7f7dfc96e583fb36b703e5989309acc0 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Fri, 3 Jul 2026 09:17:16 -0700 Subject: [PATCH 44/74] feat(cli): path-aware secret reflection in config validate/push/diff --- crates/edgezero-cli/src/config.rs | 473 ++++++++++++++++++++++++------ 1 file changed, 382 insertions(+), 91 deletions(-) diff --git a/crates/edgezero-cli/src/config.rs b/crates/edgezero-cli/src/config.rs index 22a8abd8..f91c8576 100644 --- a/crates/edgezero-cli/src/config.rs +++ b/crates/edgezero-cli/src/config.rs @@ -25,7 +25,8 @@ use edgezero_adapter::registry::{ self as adapter_registry, ReadConfigEntry, ResolvedStoreId, TypedSecretEntry, }; use edgezero_core::app_config::{ - self, AppConfigError, AppConfigLoadOptions, AppConfigMeta, SecretKind, SecretPathSegment, + self, AppConfigError, AppConfigLoadOptions, AppConfigMeta, SecretField, SecretKind, + SecretPathSegment, }; use edgezero_core::blob_envelope::BlobEnvelope; use edgezero_core::env_config::EnvConfig; @@ -167,6 +168,22 @@ enum RecheckOutcome { Write, } +/// One resolved `#[secret]` leaf located in the raw app-config TOML. +/// +/// `label` carries concrete `[n]` array indices (the runtime dotted +/// form used in CLI output and errors). `store_ref_value` is the sibling +/// store id resolved from the leaf's INNERMOST parent table — populated +/// only for `KeyInNamedStore` leaves. +#[derive(Debug)] +struct ResolvedTomlLeaf<'raw> { + /// Dotted runtime label, e.g. `partners[1].api_key`. + label: String, + /// Sibling store id for `KeyInNamedStore`; `None` otherwise. + store_ref_value: Option<&'raw str>, + /// The secret leaf's string value (a secret-store KEY NAME). + value: &'raw str, +} + /// Raw flow — no typed `C`. Runs every check the typed flow runs /// *except* the typed deserialise, the validator rules, the secret /// presence / store-ref checks, and the Spin config-vs-secret @@ -1287,17 +1304,93 @@ pub(crate) fn reject_merged_id_collisions( Ok(()) } +/// Collect every concrete secret leaf a `SecretField` resolves to in the +/// raw app-config TOML, navigating `Field` (table descent) and `ArrayEach` +/// (per-element) segments. `label` uses concrete `[n]` indices and, for a +/// `KeyInNamedStore` leaf, `store_ref_value` is resolved from the leaf's +/// innermost parent table. Absent optional leaves yield nothing; a missing +/// required leaf yields an `Err` carrying the dotted label. +fn collect_secret_leaves<'raw>( + root: &'raw Value, + field: &SecretField, +) -> Result>, String> { + fn walk<'raw>( + node: &'raw Value, + field: &SecretField, + remaining: &[SecretPathSegment], + rendered: &str, + out: &mut Vec>, + ) -> Result<(), String> { + match remaining.split_first() { + Some((SecretPathSegment::Field(name), [])) => { + let parent = node.as_table().ok_or_else(|| { + format!("expected a table containing `{name}` at `{rendered}`") + })?; + let leaf_label = if rendered.is_empty() { + name.to_string() + } else { + format!("{rendered}.{name}") + }; + match parent.get(name.as_ref()).and_then(Value::as_str) { + Some(value) => { + let store_ref_value = match field.kind { + SecretKind::KeyInNamedStore { store_ref_field } => { + parent.get(store_ref_field).and_then(Value::as_str) + } + SecretKind::KeyInDefault | SecretKind::StoreRef => None, + }; + out.push(ResolvedTomlLeaf { + label: leaf_label, + store_ref_value, + value, + }); + Ok(()) + } + None if field.optional && parent.get(name.as_ref()).is_none() => Ok(()), + None => Err(format!( + "`#[secret]` field `{leaf_label}` is missing or not a string" + )), + } + } + Some((SecretPathSegment::Field(name), rest)) => { + let table = node + .as_table() + .ok_or_else(|| format!("expected a table at `{rendered}`"))?; + let next_rendered = if rendered.is_empty() { + name.to_string() + } else { + format!("{rendered}.{name}") + }; + match table.get(name.as_ref()) { + Some(child) => walk(child, field, rest, &next_rendered, out), + None if field.optional => Ok(()), + None => Err(format!("missing `{next_rendered}`")), + } + } + Some((SecretPathSegment::ArrayEach, rest)) => { + let arr = node + .as_array() + .ok_or_else(|| format!("expected an array at `{rendered}`"))?; + for (idx, item) in arr.iter().enumerate() { + let indexed = format!("{rendered}[{idx}]"); + walk(item, field, rest, &indexed, out)?; + } + Ok(()) + } + None => Ok(()), + } + } + let mut out = Vec::new(); + walk(root, field, &field.path, "", &mut out)?; + Ok(out) +} + /// Typed-only adapter dispatch: feed each adapter the `#[secret]` /// (`KeyInDefault` and `KeyInNamedStore` — `StoreRef` values are /// runtime store ids, not flat-namespace candidates) so adapters /// whose secret store has a flat-namespace constraint (Spin) can /// detect within-secrets collisions. fn run_adapter_typed_checks(ctx: &ValidationContext) -> Result<(), String> { - let raw_table = ctx - .raw_config - .as_table() - .ok_or_else(|| "raw app-config was not a TOML table after load".to_owned())?; - let default_store_id = ctx .manifest() .stores @@ -1306,28 +1399,24 @@ fn run_adapter_typed_checks(ctx: &ValidationContext) -> Result .map(StoreDeclaration::default_id); let mut entries: Vec> = Vec::new(); for field in C::secret_fields() { - // Task 1: flat/length-1 paths only — the leaf is the single Field - // segment. Task 6 makes this a full TOML path navigator. - let leaf = field.dotted_path(); - let key = match field.path.last() { - Some(SecretPathSegment::Field(name)) => name.as_ref(), - _ => continue, - }; - match field.kind { - SecretKind::KeyInDefault => { - let opt_value = raw_table.get(key).and_then(Value::as_str); - if let (Some(key_value), Some(store_id)) = (opt_value, default_store_id) { - entries.push(TypedSecretEntry::new(store_id, leaf.clone(), key_value)); + for leaf in collect_secret_leaves(&ctx.raw_config, &field)? { + match field.kind { + SecretKind::KeyInDefault => { + if let Some(store_id) = default_store_id { + entries.push(TypedSecretEntry::new(store_id, leaf.label, leaf.value)); + } } - } - SecretKind::KeyInNamedStore { store_ref_field } => { - let opt_store = raw_table.get(store_ref_field).and_then(Value::as_str); - let opt_value = raw_table.get(key).and_then(Value::as_str); - if let (Some(store_id), Some(key_value)) = (opt_store, opt_value) { - entries.push(TypedSecretEntry::new(store_id, leaf.clone(), key_value)); + SecretKind::KeyInNamedStore { .. } => { + let store_id = leaf.store_ref_value.ok_or_else(|| { + format!( + "`#[secret(store_ref = \"...\")]` field `{}` is missing its store_ref sibling", + leaf.label + ) + })?; + entries.push(TypedSecretEntry::new(store_id, leaf.label, leaf.value)); } + SecretKind::StoreRef => {} } - SecretKind::StoreRef => {} } } @@ -1347,74 +1436,59 @@ fn typed_secret_checks( _typed: &C, ctx: &ValidationContext, ) -> Result<(), String> { - let raw_table = ctx - .raw_config - .as_table() - .ok_or_else(|| "raw app-config was not a TOML table after load".to_owned())?; - for field in C::secret_fields() { - // Task 1: flat/length-1 paths only — the leaf is the single Field - // segment. Task 6 makes this a full TOML path navigator. - let leaf = field.dotted_path(); - let key = match field.path.last() { - Some(SecretPathSegment::Field(name)) => name.as_ref(), - _ => continue, - }; - let value = raw_table.get(key).and_then(Value::as_str).ok_or_else(|| { - format!( - "{}: `#[secret]` field `{}` is missing or not a string at the top level", - ctx.app_config_path.display(), - leaf - ) - })?; - if value.is_empty() { - return Err(format!( - "{}: `#[secret]` field `{}` must be non-empty", - ctx.app_config_path.display(), - leaf - )); - } - match field.kind { - SecretKind::KeyInDefault => { - if ctx.manifest().stores.secrets.is_none() { - return Err(format!( - "{}: `#[secret]` field `{}` requires `[stores.secrets]` to be declared in {}", - ctx.app_config_path.display(), - leaf, - ctx.manifest_path.display() - )); - } + for leaf in collect_secret_leaves(&ctx.raw_config, &field)? { + let label = leaf.label; + let value = leaf.value; + if value.is_empty() { + return Err(format!( + "{}: `#[secret]` field `{}` must be non-empty", + ctx.app_config_path.display(), + label + )); } - SecretKind::KeyInNamedStore { .. } => { - // The field value is a key within a named store; the named - // store is identified by the sibling `#[secret(store_ref)]` - // field. Verify the store section is at least declared. - if ctx.manifest().stores.secrets.is_none() { - return Err(format!( - "{}: `#[secret(store_ref = \"...\")]` field `{}` requires `[stores.secrets]` to be declared in {}", - ctx.app_config_path.display(), - leaf, - ctx.manifest_path.display() - )); + match field.kind { + SecretKind::KeyInDefault => { + if ctx.manifest().stores.secrets.is_none() { + return Err(format!( + "{}: `#[secret]` field `{}` requires `[stores.secrets]` to be declared in {}", + ctx.app_config_path.display(), + label, + ctx.manifest_path.display() + )); + } } - } - SecretKind::StoreRef => { - let secrets = ctx.manifest().stores.secrets.as_ref().ok_or_else(|| { - format!( - "{}: `#[secret(store_ref)]` field `{}` requires `[stores.secrets]` to be declared in {}", - ctx.app_config_path.display(), - leaf, - ctx.manifest_path.display() - ) - })?; - if !secrets.ids.iter().any(|id| id == value) { - return Err(format!( - "{}: `#[secret(store_ref)]` field `{}` = {:?} is not in [stores.secrets].ids ({:?})", - ctx.app_config_path.display(), - leaf, - value, - secrets.ids - )); + SecretKind::KeyInNamedStore { .. } => { + // The field value is a key within a named store; the named + // store is identified by the sibling `#[secret(store_ref)]` + // field. Verify the store section is at least declared. + if ctx.manifest().stores.secrets.is_none() { + return Err(format!( + "{}: `#[secret(store_ref = \"...\")]` field `{}` requires `[stores.secrets]` to be declared in {}", + ctx.app_config_path.display(), + label, + ctx.manifest_path.display() + )); + } + } + SecretKind::StoreRef => { + let secrets = ctx.manifest().stores.secrets.as_ref().ok_or_else(|| { + format!( + "{}: `#[secret(store_ref)]` field `{}` requires `[stores.secrets]` to be declared in {}", + ctx.app_config_path.display(), + label, + ctx.manifest_path.display() + ) + })?; + if !secrets.ids.iter().any(|id| id == value) { + return Err(format!( + "{}: `#[secret(store_ref)]` field `{}` = {:?} is not in [stores.secrets].ids ({:?})", + ctx.app_config_path.display(), + label, + value, + secrets.ids + )); + } } } } @@ -1554,7 +1628,6 @@ fn format_app_config_error(err: &AppConfigError) -> String { mod tests { use super::*; use crate::test_support::{manifest_guard, EnvOverride}; - use edgezero_core::app_config::SecretField; use serde::{Deserialize, Serialize}; use std::borrow::Cow; #[cfg(unix)] @@ -1914,6 +1987,224 @@ ids = ["default"] .expect("secret-field validator must be skipped on typed validate"); } + // ---------- Task 6: path-aware nested / array secret reflection ---------- + + // Real nested derive: integrations.datadome.server_side_key (KeyInDefault), + // partners[*].api_key (KeyInDefault). + #[derive(Debug, Deserialize, Serialize, Validate, edgezero_core::AppConfig)] + #[serde(deny_unknown_fields)] + struct DataDome { + #[secret] + server_side_key: String, + } + #[derive(Debug, Deserialize, Serialize, Validate, edgezero_core::AppConfig)] + #[serde(deny_unknown_fields)] + struct Integrations { + #[app_config(nested)] + #[validate(nested)] + datadome: DataDome, + } + #[derive(Debug, Deserialize, Serialize, Validate, edgezero_core::AppConfig)] + #[serde(deny_unknown_fields)] + struct Partner { + #[secret] + api_key: String, + } + #[derive(Debug, Deserialize, Serialize, Validate, edgezero_core::AppConfig)] + #[serde(deny_unknown_fields)] + struct NestedCliConfig { + #[app_config(nested)] + #[validate(nested)] + integrations: Integrations, + #[app_config(nested)] + #[validate(nested)] + partners: Vec, + } + + const NESTED_MANIFEST: &str = r#" +[app] +name = "demo-app" + +[adapters.axum.adapter] +crate = "crates/demo-axum" +[adapters.axum.commands] +build = "echo" +deploy = "echo" +serve = "echo" + +[stores.config] +ids = ["app_config"] + +[stores.secrets] +ids = ["default"] +"#; + + #[test] + fn validate_typed_accepts_well_formed_nested_and_array_secrets() { + let app_config = r#" +[integrations.datadome] +server_side_key = "dd_key" + +[[partners]] +api_key = "p0" + +[[partners]] +api_key = "p1" +"#; + let (_dir, manifest_path, _) = setup_project(NESTED_MANIFEST, app_config); + run_config_validate_typed::(&args_for(&manifest_path)) + .expect("well-formed nested + array secret config validates"); + } + + #[test] + fn validate_typed_reports_dotted_path_for_empty_array_secret() { + // partners[1].api_key is empty -> typed_secret_checks must reject it and + // name the INDEXED dotted path. + let app_config = r#" +[integrations.datadome] +server_side_key = "dd_key" + +[[partners]] +api_key = "p0" + +[[partners]] +api_key = "" +"#; + let (_dir, manifest_path, _) = setup_project(NESTED_MANIFEST, app_config); + let err = run_config_validate_typed::(&args_for(&manifest_path)) + .expect_err("empty array secret must be rejected"); + assert!( + err.contains("partners[1].api_key"), + "error names the indexed dotted path: {err}" + ); + } + + #[test] + fn validate_typed_rejects_missing_required_nested_leaf_at_deserialize() { + // A MISSING required nested leaf fails serde DESERIALIZATION + // before `typed_secret_checks`/`run_adapter_typed_checks` ever run — so + // this is deserialize-path coverage, NOT proof of the path-aware + // collector. The direct collector test below covers that. + let app_config = r#" +[integrations.datadome] + +[[partners]] +api_key = "p0" +"#; + let (_dir, manifest_path, _) = setup_project(NESTED_MANIFEST, app_config); + let err = run_config_validate_typed::(&args_for(&manifest_path)) + .expect_err("missing nested leaf must be rejected"); + assert!( + err.contains("server_side_key"), + "error names the missing nested leaf: {err}" + ); + } + + // Direct coverage of the path-aware TOML collector (the new logic). + // Bypasses `run_config_validate_typed` so deserialization does not preempt + // it — proves the collector itself resolves array indices and reports the + // dotted label for a present-but-invalid / missing leaf. + #[test] + fn collect_secret_leaves_resolves_array_indices_and_dotted_labels() { + let raw: Value = toml::from_str( + r#" +[[partners]] +api_key = "p0" + +[[partners]] +api_key = "p1" +"#, + ) + .expect("toml"); + + let field = SecretField { + kind: SecretKind::KeyInDefault, + path: vec![ + SecretPathSegment::Field(Cow::Borrowed("partners")), + SecretPathSegment::ArrayEach, + SecretPathSegment::Field(Cow::Borrowed("api_key")), + ], + optional: false, + }; + let leaves = collect_secret_leaves(&raw, &field).expect("collect"); + let labels: Vec<&str> = leaves.iter().map(|leaf| leaf.label.as_str()).collect(); + assert_eq!(labels, vec!["partners[0].api_key", "partners[1].api_key"]); + let values: Vec<&str> = leaves.iter().map(|leaf| leaf.value).collect(); + assert_eq!(values, vec!["p0", "p1"]); + } + + #[test] + fn collect_secret_leaves_errors_on_missing_required_leaf_with_dotted_label() { + let raw: Value = toml::from_str( + r#" +[integrations.datadome] +other = "x" +"#, + ) + .expect("toml"); + + let field = SecretField { + kind: SecretKind::KeyInDefault, + path: vec![ + SecretPathSegment::Field(Cow::Borrowed("integrations")), + SecretPathSegment::Field(Cow::Borrowed("datadome")), + SecretPathSegment::Field(Cow::Borrowed("server_side_key")), + ], + optional: false, + }; + let err = collect_secret_leaves(&raw, &field).expect_err("missing required leaf"); + assert!( + err.contains("integrations.datadome.server_side_key"), + "collector error names the dotted path: {err}" + ); + } + + #[derive(Debug, Deserialize, Serialize, Validate, edgezero_core::AppConfig)] + #[serde(deny_unknown_fields)] + struct Vaulted { + #[secret(store_ref = "vault")] + token: String, + #[secret(store_ref)] + vault: String, + } + #[derive(Debug, Deserialize, Serialize, Validate, edgezero_core::AppConfig)] + #[serde(deny_unknown_fields)] + struct NamedStoreCliConfig { + #[app_config(nested)] + #[validate(nested)] + vaulted: Vaulted, + } + + #[test] + fn validate_typed_accepts_nested_named_store_with_sibling() { + let manifest = r#" +[app] +name = "demo-app" + +[adapters.axum.adapter] +crate = "crates/demo-axum" +[adapters.axum.commands] +build = "echo" +deploy = "echo" +serve = "echo" + +[stores.config] +ids = ["app_config"] + +[stores.secrets] +ids = ["default", "named"] +default = "default" +"#; + let app_config = r#" +[vaulted] +token = "tok_key" +vault = "named" +"#; + let (_dir, manifest_path, _) = setup_project(manifest, app_config); + run_config_validate_typed::(&args_for(&manifest_path)) + .expect("nested named-store secret with a declared store validates"); + } + // ---------- Spin checks ---------- fn spin_manifest(extra_section: &str) -> String { From aa8b67a58fda36515fdb37ca489358117aced70c Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Fri, 3 Jul 2026 09:30:02 -0700 Subject: [PATCH 45/74] test(secrets): end-to-end nested + named-store resolution; docs: nested/array secrets --- Cargo.lock | 1 + crates/edgezero-macros/Cargo.toml | 6 +- .../tests/nested_secrets_e2e.rs | 171 ++++++++++++++++++ docs/guide/configuration.md | 83 ++++++++- 4 files changed, 257 insertions(+), 4 deletions(-) create mode 100644 crates/edgezero-macros/tests/nested_secrets_e2e.rs diff --git a/Cargo.lock b/Cargo.lock index 5413e70f..1878e7f3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -824,6 +824,7 @@ dependencies = [ name = "edgezero-macros" version = "0.1.0" dependencies = [ + "async-trait", "edgezero-core", "futures", "log", diff --git a/crates/edgezero-macros/Cargo.toml b/crates/edgezero-macros/Cargo.toml index 81f0b970..18039b5a 100644 --- a/crates/edgezero-macros/Cargo.toml +++ b/crates/edgezero-macros/Cargo.toml @@ -25,8 +25,10 @@ validator = { workspace = true, features = ["derive"] } # `edgezero-core` re-exports `AppConfig`; the derive tests assert # against the trait/types over the re-export path the way downstream # users will. Cargo allows dev-dep cycles (only the main dep edge -# matters for build ordering). -edgezero-core = { workspace = true } +# matters for build ordering). `test-utils` exposes `InMemorySecretStore` +# so the end-to-end nested-secret test can drive the runtime secret walk. +async-trait = { workspace = true } +edgezero-core = { workspace = true, features = ["test-utils"] } futures = { workspace = true } tempfile = { workspace = true } trybuild = { workspace = true } diff --git a/crates/edgezero-macros/tests/nested_secrets_e2e.rs b/crates/edgezero-macros/tests/nested_secrets_e2e.rs new file mode 100644 index 00000000..3176272e --- /dev/null +++ b/crates/edgezero-macros/tests/nested_secrets_e2e.rs @@ -0,0 +1,171 @@ +//! End-to-end proof that nested and array `#[secret]` fields resolve +//! through the runtime secret walk of the `AppConfig` extractor. +//! +//! Unlike `app_config_derive.rs` (which only reflects over the metadata +//! `secret_fields()` emits), this test drives the WHOLE chain a downstream +//! app hits at request time: a `BlobEnvelope` holding secret-store KEY NAMES +//! is deserialized through `AppConfig::::from_request`, whose `secret_walk` +//! resolves every nested / array / named-store leaf against a live +//! `InMemorySecretStore` before the struct is materialised. The assertions +//! read the RESOLVED values off the deserialized config. + +#![cfg(test)] + +use async_trait::async_trait; +use edgezero_core::blob_envelope::BlobEnvelope; +use edgezero_core::body::Body; +use edgezero_core::config_store::{ConfigStore, ConfigStoreError, ConfigStoreHandle}; +use edgezero_core::context::RequestContext; +use edgezero_core::extractor::{AppConfig as AppConfigExtractor, FromRequest as _}; +use edgezero_core::http::{request_builder, Method}; +use edgezero_core::params::PathParams; +use edgezero_core::secret_store::{InMemorySecretStore, SecretHandle}; +use edgezero_core::store_registry::{ + BoundSecretStore, ConfigRegistry, ConfigStoreBinding, SecretRegistry, StoreRegistry, +}; +use futures::executor::block_on; +use std::collections::BTreeMap; +use std::sync::Arc; +use validator::Validate as _; + +// --- fixture config (nested objects + `Vec<_>` array + named store) -------- + +// A 2-level `KeyInDefault` nested leaf: `datadome.server_side_key`. +#[derive(serde::Deserialize, validator::Validate, edgezero_core::AppConfig)] +#[serde(deny_unknown_fields)] +struct DataDome { + #[secret] + server_side_key: String, +} + +// One array element carrying a `KeyInDefault` secret: `partners[*].api_key`. +#[derive(serde::Deserialize, validator::Validate, edgezero_core::AppConfig)] +#[serde(deny_unknown_fields)] +struct Partner { + #[secret] + api_key: String, +} + +// The root config, exercising every reachable secret shape at once. +#[derive(serde::Deserialize, validator::Validate, edgezero_core::AppConfig)] +#[serde(deny_unknown_fields)] +struct Settings { + #[app_config(nested)] + #[validate(nested)] + datadome: DataDome, + #[app_config(nested)] + #[validate(nested)] + partners: Vec, + #[app_config(nested)] + #[validate(nested)] + vaulted: Vaulted, +} + +// A nested `KeyInNamedStore` leaf whose `store_ref` sibling (`vault`) lives in +// the SAME inner struct — the innermost-parent scoping rule. +#[derive(serde::Deserialize, validator::Validate, edgezero_core::AppConfig)] +#[serde(deny_unknown_fields)] +struct Vaulted { + #[secret(store_ref = "vault")] + token: String, + #[secret(store_ref)] + vault: String, +} + +// --- wiring helpers --------------------------------------------------------- + +// A minimal `ConfigStore` that returns one fixed blob-envelope string, +// mirroring the hand-written stores in `extractor.rs`'s own tests. +struct BlobStore(String); + +#[async_trait(?Send)] +impl ConfigStore for BlobStore { + async fn get(&self, _key: &str) -> Result, ConfigStoreError> { + Ok(Some(self.0.clone())) + } +} + +// Build a `RequestContext` wired to a config store holding `envelope` plus a +// two-store secret registry: the default store and a `named` store, so both +// the `KeyInDefault` leaves and the `KeyInNamedStore` leaf resolve. +fn ctx_with_stores(envelope: String) -> RequestContext { + let binding = ConfigStoreBinding { + handle: ConfigStoreHandle::new(Arc::new(BlobStore(envelope))), + default_key: "app_config".to_owned(), + }; + let config_registry: ConfigRegistry = + StoreRegistry::single_id("app_config".to_owned(), binding); + + // Default store resolves the `KeyInDefault` leaves (nested + array). + let default_store = InMemorySecretStore::new([ + ("default/dd_key".to_owned(), "DD"), + ("default/p0_key".to_owned(), "P0"), + ("default/p1_key".to_owned(), "P1"), + ]); + let default_bound = BoundSecretStore::new( + SecretHandle::new(Arc::new(default_store)), + "default".to_owned(), + ); + + // Named store resolves the `KeyInNamedStore` leaf via its `vault` sibling. + let named_store = InMemorySecretStore::new([("named/tok_key".to_owned(), "TOK")]); + let named_bound = + BoundSecretStore::new(SecretHandle::new(Arc::new(named_store)), "named".to_owned()); + + let mut by_id: BTreeMap = BTreeMap::new(); + by_id.insert("default".to_owned(), default_bound); + by_id.insert("named".to_owned(), named_bound); + let secret_registry: SecretRegistry = StoreRegistry::new(by_id, "default".to_owned()); + + let mut request = request_builder() + .method(Method::GET) + .uri("/config") + .body(Body::empty()) + .expect("build request"); + request.extensions_mut().insert(config_registry); + request.extensions_mut().insert(secret_registry); + RequestContext::new(request, PathParams::default()) +} + +// The blob at rest holds secret-store KEY NAMES (Model A), not resolved +// values — exactly what `config push` persists. +fn envelope_with_key_names() -> String { + let data = serde_json::json!({ + "datadome": { "server_side_key": "dd_key" }, + "partners": [ { "api_key": "p0_key" }, { "api_key": "p1_key" } ], + "vaulted": { "token": "tok_key", "vault": "named" } + }); + let envelope = BlobEnvelope::new(data, "2026-01-01T00:00:00Z".to_owned()); + serde_json::to_string(&envelope).expect("serialise envelope") +} + +// --- the end-to-end assertions --------------------------------------------- + +#[test] +fn nested_and_named_store_secrets_resolve_through_extractor() { + let ctx = ctx_with_stores(envelope_with_key_names()); + let AppConfigExtractor(cfg) = + block_on(AppConfigExtractor::::from_request(&ctx)).expect("extraction succeeds"); + + // Nested `KeyInDefault`: `datadome.server_side_key` -> default store. + assert_eq!(cfg.datadome.server_side_key, "DD"); + + // Nested `KeyInNamedStore`: `vaulted.token` -> the store named by its + // sibling `vaulted.vault` ("named"). The `store_ref` sibling is left + // verbatim (it names a store, not a secret). + assert_eq!(cfg.vaulted.token, "TOK"); + assert_eq!(cfg.vaulted.vault, "named"); +} + +#[test] +fn array_element_secrets_resolve_per_index() { + let ctx = ctx_with_stores(envelope_with_key_names()); + let AppConfigExtractor(cfg) = + block_on(AppConfigExtractor::::from_request(&ctx)).expect("extraction succeeds"); + + // Each `partners[n].api_key` resolves independently against the default + // store — proving the `ArrayEach` runtime walk. + assert_eq!(cfg.partners.len(), 2); + assert_eq!(cfg.partners[0].api_key, "P0"); + assert_eq!(cfg.partners[1].api_key, "P1"); +} diff --git a/docs/guide/configuration.md b/docs/guide/configuration.md index ce283df6..44330b90 100644 --- a/docs/guide/configuration.md +++ b/docs/guide/configuration.md @@ -285,8 +285,10 @@ The function deserialises, runs the `validator` rules (e.g. | `#[secret]` | The field's value is a **key inside the default secret store** declared by `[stores.secrets]`. | | `#[secret(store_ref)]` | The field's value is a **logical store id** that must appear in `[stores.secrets].ids`. | -Only bare `String` fields can carry a `#[secret]` annotation; -combining it with `#[serde(flatten)]`, `#[serde(rename)]`, or +`#[secret]` fields must be `String` or `Option` (an absent +optional secret is skipped at runtime — see +[Nested and array secrets](#nested-and-array-secrets)); combining the +annotation with `#[serde(flatten)]`, `#[serde(rename)]`, or `#[serde(skip)]` is a compile error. The `config validate` command (see [CLI reference](/guide/cli-reference)) checks that every `#[secret(store_ref)]` value matches a declared id. @@ -307,6 +309,83 @@ let value = ctx .await?; ``` +### Nested and array secrets + +`#[secret]` fields don't have to live at the config root. They can sit +inside nested structs and inside `Vec<_>` elements, resolved at runtime +by their **field path** instead of a single top-level name. + +To recurse into a nested type, mark the field with `#[app_config(nested)]` +(it mirrors `#[validate(nested)]`, which you'll usually want alongside it), +and make the nested type itself derive `AppConfig`: + +```rust +#[derive(Debug, Deserialize, Serialize, Validate, edgezero_core::AppConfig)] +#[serde(deny_unknown_fields)] +pub struct Settings { + #[app_config(nested)] + #[validate(nested)] + pub integrations: Integrations, + + #[app_config(nested)] + #[validate(nested)] + pub partners: Vec, +} + +#[derive(Debug, Deserialize, Serialize, Validate, edgezero_core::AppConfig)] +#[serde(deny_unknown_fields)] +pub struct Integrations { + #[app_config(nested)] + #[validate(nested)] + pub datadome: DataDome, +} + +#[derive(Debug, Deserialize, Serialize, Validate, edgezero_core::AppConfig)] +#[serde(deny_unknown_fields)] +pub struct DataDome { + #[secret] + pub server_side_key: String, + + // A named-store secret: `token` is a key in the store named by its + // `vault` sibling. The `store_ref` sibling is resolved within the + // innermost object that contains the secret leaf. + #[secret(store_ref = "vault")] + pub token: String, + #[secret(store_ref)] + pub vault: String, +} + +#[derive(Debug, Deserialize, Serialize, Validate, edgezero_core::AppConfig)] +#[serde(deny_unknown_fields)] +pub struct Partner { + #[secret] + pub api_key: String, + + // An optional secret: absent (or `null`) at runtime -> skipped, not an error. + #[secret] + pub webhook_key: Option, +} +``` + +At request time the extractor walks each secret path and swaps the stored +**key name** for the resolved value: + +- Object nesting resolves the leaf at its full path + (`integrations.datadome.server_side_key`). +- `Vec<_>` arrays resolve the annotated field on **every** element + (`partners[*].api_key`). +- A `#[secret]` on `Option` is skipped when the value is absent + or `null`; a present value is resolved like any other secret. +- `#[secret(store_ref = "…")]` resolves against the store named by its + **sibling** field — the one in the same innermost object as the secret + leaf, not a root-level field. + +The nested type must derive `AppConfig`; marking a field +`#[app_config(nested)]` whose type does not is a compile error. Runtime +resolution failures name the offending leaf by its dotted path, with +concrete array indices — e.g. `integrations.datadome.server_side_key` or +`partners[3].api_key` — so a bad key name points straight at the field. + ### Environment-variable overlay Every key in `.toml` can be overridden at runtime by an env From 47c827f1e7d695f662ac5dcbe420137faccab92c Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Fri, 3 Jul 2026 09:58:17 -0700 Subject: [PATCH 46/74] fix(macros): hard-error empty/bare #[app_config]; refresh stale SECRET_FIELDS naming An empty #[app_config()] (or bare #[app_config]) previously returned Ok(false) from nested_optin, silently NOT recursing the field and dropping the child's #[secret] metadata. Now errors, matching the documented contract; adds an app_config_empty trybuild fixture. Also renames stale SECRET_FIELDS references in comments + the rename_all diagnostic to secret_fields(), and broadens that diagnostic to mention #[app_config(nested)] children. --- crates/edgezero-macros/src/app_config.rs | 38 +++++++++++++------ .../tests/app_config_derive.rs | 1 + .../tests/ui/app_config_empty.rs | 11 ++++++ .../tests/ui/app_config_empty.stderr | 5 +++ .../tests/ui/nested_parent_rename_all.stderr | 2 +- ...ret_with_serde_container_rename_all.stderr | 2 +- 6 files changed, 45 insertions(+), 14 deletions(-) create mode 100644 crates/edgezero-macros/tests/ui/app_config_empty.rs create mode 100644 crates/edgezero-macros/tests/ui/app_config_empty.stderr diff --git a/crates/edgezero-macros/src/app_config.rs b/crates/edgezero-macros/src/app_config.rs index 7f1d9120..e941d3f1 100644 --- a/crates/edgezero-macros/src/app_config.rs +++ b/crates/edgezero-macros/src/app_config.rs @@ -3,7 +3,7 @@ //! Scans the input struct for `#[secret]` / `#[secret(store_ref)]` //! field annotations, enforces the compile-time constraints, and //! emits `impl ::edgezero_core::app_config::AppConfigMeta` with the -//! `SECRET_FIELDS` array. +//! `secret_fields()` method. use std::collections::{HashMap, HashSet}; @@ -53,7 +53,7 @@ struct NestedDescriptor<'field> { } /// Inspect the input struct, emit `impl AppConfigMeta` with the -/// `SECRET_FIELDS` array. Errors surface as `compile_error!` tokens +/// `secret_fields()` method. Errors surface as `compile_error!` tokens /// substituted in place of the impl. #[inline] pub fn derive(tokens: TokenStream) -> TokenStream { @@ -381,14 +381,27 @@ fn nested_optin(field: &Field) -> syn::Result { if !attr.path().is_ident("app_config") { continue; } + // Track whether THIS attribute actually named `nested`. A bare + // `#[app_config]` / empty `#[app_config()]` parses Ok with the closure + // never firing; without this guard it would leave `found` false and the + // field would be silently NOT recursed, dropping the child's secrets. + let mut this_attr_nested = false; attr.parse_nested_meta(|meta| { if meta.path.is_ident("nested") { - found = true; + this_attr_nested = true; Ok(()) } else { Err(meta.error("`#[app_config(...)]` only accepts `nested`")) } })?; + if !this_attr_nested { + return Err(syn::Error::new_spanned( + attr, + "`#[app_config]` requires an option; the only supported one is \ + `nested` (e.g. `#[app_config(nested)]`)", + )); + } + found = true; } Ok(found) } @@ -530,13 +543,14 @@ fn enforce_no_disallowed_serde_attrs_on_all_fields( Ok(()) } -/// Container-level guard: a struct that carries any `#[secret]` field -/// must not also carry `#[serde(rename_all = ...)]`. The derive emits -/// `SECRET_FIELDS` with Rust field names verbatim, but `rename_all` -/// would translate the on-the-wire key name (e.g. `kebab-case` → -/// `api-token`), silently desyncing the typed `config validate` secret -/// checks from what the deserialiser actually accepts. Reject this at -/// compile time so the desync can't ship. +/// Container-level guard: a struct that carries any `#[secret]` field or +/// any `#[app_config(nested)]` child must not also carry +/// `#[serde(rename_all = ...)]`. The derive emits `secret_fields()` paths +/// with Rust field names verbatim, but `rename_all` would translate the +/// on-the-wire key name (e.g. `kebab-case` → `api-token`), silently +/// desyncing the typed `config validate` secret checks from what the +/// deserialiser actually accepts. Reject this at compile time so the +/// desync can't ship. fn enforce_no_container_rename_all(attrs: &[Attribute]) -> Result<(), syn::Error> { for attr in attrs { if !attr.path().is_ident("serde") { @@ -552,7 +566,7 @@ fn enforce_no_container_rename_all(attrs: &[Attribute]) -> Result<(), syn::Error if offending { return Err(syn::Error::new_spanned( attr, - "`#[derive(AppConfig)]` rejects `#[serde(rename_all = ...)]` on structs with `#[secret]` fields: SECRET_FIELDS uses Rust field names verbatim, so a container rename would silently desync `config validate` from runtime deserialisation", + "`#[derive(AppConfig)]` rejects `#[serde(rename_all = ...)]` on structs with `#[secret]` fields or `#[app_config(nested)]` children: secret_fields() uses Rust field names verbatim, so a container rename would silently desync `config validate` from runtime deserialisation", )); } } @@ -584,7 +598,7 @@ fn enforce_no_disallowed_serde_attrs(field: &Field) -> Result<(), syn::Error> { "skip_serializing" => Some("skip_serializing"), // `skip_serializing_if = "..."` also omits the // field from round-trips (config push reads - // SECRET_FIELDS, then serialises the typed + // secret_fields(), then serialises the typed // struct), so reject it alongside the // unconditional skip family. "skip_serializing_if" => Some("skip_serializing_if"), diff --git a/crates/edgezero-macros/tests/app_config_derive.rs b/crates/edgezero-macros/tests/app_config_derive.rs index af98bf29..1e63a706 100644 --- a/crates/edgezero-macros/tests/app_config_derive.rs +++ b/crates/edgezero-macros/tests/app_config_derive.rs @@ -244,6 +244,7 @@ mod tests { // `#[app_config(nested)]` recursion + `Option` secret guards. // The `secret_*.rs` glob above already covers // `secret_on_option_non_string.rs` and `secret_store_ref_optional.rs`. + cases.compile_fail("tests/ui/app_config_empty.rs"); cases.compile_fail("tests/ui/app_config_nested_on_non_appconfig.rs"); cases.compile_fail("tests/ui/app_config_unknown_option.rs"); cases.compile_fail("tests/ui/nested_field_serde_rename.rs"); diff --git a/crates/edgezero-macros/tests/ui/app_config_empty.rs b/crates/edgezero-macros/tests/ui/app_config_empty.rs new file mode 100644 index 00000000..f8e9abed --- /dev/null +++ b/crates/edgezero-macros/tests/ui/app_config_empty.rs @@ -0,0 +1,11 @@ +//! An empty `#[app_config()]` must be a hard compile error, not a silent +//! no-op — otherwise the field would not be recursed and the child's +//! `#[secret]` metadata would be dropped without any diagnostic. + +#[derive(serde::Deserialize, validator::Validate, edgezero_core::AppConfig)] +struct Config { + #[app_config()] + child: String, +} + +fn main() {} diff --git a/crates/edgezero-macros/tests/ui/app_config_empty.stderr b/crates/edgezero-macros/tests/ui/app_config_empty.stderr new file mode 100644 index 00000000..4edda2ac --- /dev/null +++ b/crates/edgezero-macros/tests/ui/app_config_empty.stderr @@ -0,0 +1,5 @@ +error: `#[app_config]` requires an option; the only supported one is `nested` (e.g. `#[app_config(nested)]`) + --> tests/ui/app_config_empty.rs:7:5 + | +7 | #[app_config()] + | ^^^^^^^^^^^^^^^ diff --git a/crates/edgezero-macros/tests/ui/nested_parent_rename_all.stderr b/crates/edgezero-macros/tests/ui/nested_parent_rename_all.stderr index 167edca5..620642a2 100644 --- a/crates/edgezero-macros/tests/ui/nested_parent_rename_all.stderr +++ b/crates/edgezero-macros/tests/ui/nested_parent_rename_all.stderr @@ -1,4 +1,4 @@ -error: `#[derive(AppConfig)]` rejects `#[serde(rename_all = ...)]` on structs with `#[secret]` fields: SECRET_FIELDS uses Rust field names verbatim, so a container rename would silently desync `config validate` from runtime deserialisation +error: `#[derive(AppConfig)]` rejects `#[serde(rename_all = ...)]` on structs with `#[secret]` fields or `#[app_config(nested)]` children: secret_fields() uses Rust field names verbatim, so a container rename would silently desync `config validate` from runtime deserialisation --> tests/ui/nested_parent_rename_all.rs:12:1 | 12 | #[serde(rename_all = "kebab-case")] diff --git a/crates/edgezero-macros/tests/ui/secret_with_serde_container_rename_all.stderr b/crates/edgezero-macros/tests/ui/secret_with_serde_container_rename_all.stderr index c94cb25d..30040917 100644 --- a/crates/edgezero-macros/tests/ui/secret_with_serde_container_rename_all.stderr +++ b/crates/edgezero-macros/tests/ui/secret_with_serde_container_rename_all.stderr @@ -1,4 +1,4 @@ -error: `#[derive(AppConfig)]` rejects `#[serde(rename_all = ...)]` on structs with `#[secret]` fields: SECRET_FIELDS uses Rust field names verbatim, so a container rename would silently desync `config validate` from runtime deserialisation +error: `#[derive(AppConfig)]` rejects `#[serde(rename_all = ...)]` on structs with `#[secret]` fields or `#[app_config(nested)]` children: secret_fields() uses Rust field names verbatim, so a container rename would silently desync `config validate` from runtime deserialisation --> tests/ui/secret_with_serde_container_rename_all.rs:8:1 | 8 | #[serde(rename_all = "kebab-case")] From aaf6e2834296fdfc0a5b49e92d46dfb274d81cca Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Fri, 3 Jul 2026 10:02:04 -0700 Subject: [PATCH 47/74] test(cli): nested config push preserves key names; nested config diff path-aware secret check Push: build_config_envelope_preserves_nested_and_array_secret_names asserts nested + array secret key names survive verbatim into envelope.data. Diff: diff_typed_rejects_empty_nested_secret proves the path-aware typed_secret_checks catches an empty nested secret (naming integrations. server_side_key) before the remote-read step. --- crates/edgezero-cli/src/config.rs | 140 ++++++++++++++++++++++++++++++ 1 file changed, 140 insertions(+) diff --git a/crates/edgezero-cli/src/config.rs b/crates/edgezero-cli/src/config.rs index f91c8576..f3684cce 100644 --- a/crates/edgezero-cli/src/config.rs +++ b/crates/edgezero-cli/src/config.rs @@ -2788,6 +2788,70 @@ deep = true /// The runtime extractor (`secret_walk`) reads that name to look up /// the resolved value in the secret store. Stripping the field would /// cause `ConfigOutOfDate` on every request after a push. + #[test] + fn build_config_envelope_preserves_nested_and_array_secret_names() { + use edgezero_core::blob_envelope::BlobEnvelope; + + // Push serialises the typed struct verbatim, so nested + array secret + // KEY NAMES must survive into envelope.data at their full path — the + // runtime walk reads them there. (`build_config_envelope` only needs + // `Serialize`.) + #[derive(Debug, Serialize)] + struct DataDome { + server_side_key: String, + } + #[derive(Debug, Serialize)] + struct Integrations { + datadome: DataDome, + } + #[derive(Debug, Serialize)] + struct Partner { + api_key: String, + } + #[derive(Debug, Serialize)] + struct NestedPushConfig { + integrations: Integrations, + partners: Vec, + } + + let typed = NestedPushConfig { + integrations: Integrations { + datadome: DataDome { + server_side_key: "dd_key".to_owned(), + }, + }, + partners: vec![ + Partner { + api_key: "p0".to_owned(), + }, + Partner { + api_key: "p1".to_owned(), + }, + ], + }; + + let json = build_config_envelope(&typed).expect("envelope serialises"); + let envelope: BlobEnvelope = serde_json::from_str(&json).expect("envelope parses"); + assert_eq!( + envelope.data["integrations"]["datadome"]["server_side_key"].as_str(), + Some("dd_key"), + "nested secret key name must survive at its path: {:?}", + envelope.data + ); + assert_eq!( + envelope.data["partners"][0]["api_key"].as_str(), + Some("p0"), + "array secret key name (element 0) must survive: {:?}", + envelope.data + ); + assert_eq!( + envelope.data["partners"][1]["api_key"].as_str(), + Some("p1"), + "array secret key name (element 1) must survive: {:?}", + envelope.data + ); + } + #[test] fn build_config_envelope_preserves_secret_field_values() { use edgezero_core::blob_envelope::BlobEnvelope; @@ -3612,6 +3676,82 @@ ids = ["default"] // Medium 1 — diff runs typed_secret_checks + adapter_typed_checks // ------------------------------------------------------------------- + /// A NESTED `#[secret]` that is present but empty must be caught by the + /// path-aware `typed_secret_checks` on `diff` — before any remote read — + /// and the error must name the dotted path. + #[test] + fn diff_typed_rejects_empty_nested_secret() { + #[derive(Debug, Deserialize, Serialize, Validate)] + #[serde(deny_unknown_fields)] + struct DiffInner { + server_side_key: String, + } + #[derive(Debug, Deserialize, Serialize, Validate)] + #[serde(deny_unknown_fields)] + struct DiffNestedConfig { + greeting: String, + #[validate(nested)] + integrations: DiffInner, + } + impl AppConfigMeta for DiffNestedConfig { + fn secret_fields() -> Vec { + vec![SecretField { + kind: SecretKind::KeyInDefault, + path: vec![ + SecretPathSegment::Field(Cow::Borrowed("integrations")), + SecretPathSegment::Field(Cow::Borrowed("server_side_key")), + ], + optional: false, + }] + } + } + + let app_config = r#" +greeting = "hello" + +[integrations] +server_side_key = "" +"#; + let manifest = r#" +[app] +name = "demo-app" + +[adapters.axum.adapter] +crate = "crates/demo-axum" +[adapters.axum.commands] +build = "echo" +deploy = "echo" +serve = "echo" + +[stores.config] +ids = ["app_config"] + +[stores.secrets] +ids = ["default"] +"#; + let (_dir, manifest_path, _) = setup_project(manifest, app_config); + let diff_args = ConfigDiffArgs { + adapter: "axum".to_owned(), + app_config: None, + exit_code: false, + format: DiffFormat::Unified, + key: None, + local: false, + manifest: manifest_path.clone(), + no_env: true, + runtime_config: None, + store: None, + }; + // The nested empty secret must be rejected by the path-aware + // typed_secret_checks before the remote-read step, naming the path. + let err = run_config_diff_typed::(&diff_args) + .expect_err("empty nested #[secret] must be rejected by diff typed_secret_checks"); + assert!( + err.contains("integrations.server_side_key") && err.contains("non-empty"), + "error names the nested dotted secret path: {err}" + ); + } + /// Medium 1 — spec 3.3.2: `run_config_diff_typed` must run the same /// structural checks as push, including `typed_secret_checks`. A /// `#[secret]` field that is present but empty must be rejected even From 8c2e8a7e8ea109c15c989043d4e3d508b331889f Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Fri, 3 Jul 2026 14:41:11 -0700 Subject: [PATCH 48/74] docs: mark superseded StateInserter steps; correct derive empty-app_config changelog --- .../plans/2026-07-02-edgezero-nested-secrets.md | 3 ++- .../plans/2026-07-02-edgezero-state-extractor.md | 10 +++++++++- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/docs/superpowers/plans/2026-07-02-edgezero-nested-secrets.md b/docs/superpowers/plans/2026-07-02-edgezero-nested-secrets.md index 9df3ea97..d0976a8d 100644 --- a/docs/superpowers/plans/2026-07-02-edgezero-nested-secrets.md +++ b/docs/superpowers/plans/2026-07-02-edgezero-nested-secrets.md @@ -1938,7 +1938,8 @@ git commit -m "test(secrets): end-to-end nested + named-store resolution; docs: ## Review round 4 — fixes folded in -- **`field_has_nested_optin` false-positive on `#[app_config()]`:** the CI-guard helper checked only `parse_nested_meta(..).is_ok()`, so a bare `#[app_config()]` (no `nested`) reported opt-in. Now tracks a `found` flag and returns `parsed.is_ok() && found` (Task 5 Step 3). (The derive's `nested_optin` already did this correctly.) +- **`field_has_nested_optin` false-positive on `#[app_config()]`:** the CI-guard helper checked only `parse_nested_meta(..).is_ok()`, so a bare `#[app_config()]` (no `nested`) reported opt-in. Now tracks a `found` flag and returns `parsed.is_ok() && found` (Task 5 Step 3). + - **Round-5 correction:** the *derive*'s `nested_optin` had the mirror bug (returned `Ok(false)` for empty `#[app_config()]`, silently NOT recursing and dropping the child's secrets — the opposite failure from the guard's false-positive). Fixed to hard-error when an `app_config` attribute is present without `nested`, per its documented contract; added the `app_config_empty.rs` trybuild fixture. (An earlier note here wrongly claimed the derive already handled this.) - **`NestedAppConfigVisitor::new` arg order:** the Task 5 test helper had the args reversed; the real signature is `new(source_path, app_config_structs)` (`check_no_nested_app_config.rs:127`). Fixed. - **Missing-nested-leaf CLI test was deserialize coverage, not collector coverage:** in `run_config_validate_typed`, serde deserialization (`config.rs:207`) runs before `typed_secret_checks`/`run_adapter_typed_checks`, so a *missing required* leaf fails deserialization first and never reaches the path collector. Retitled that test `..._rejects_missing_required_nested_leaf_at_deserialize` and added two **direct** `collect_secret_leaves` unit tests (array-index labels + missing-required-leaf dotted error) that bypass the entry point (Task 6 Step 1). - **State plan stale snippet:** the State extractor plan's missing-registration test used `expect_err` (needs `State: Debug`); updated to `.err().expect(..)` to match the committed code. diff --git a/docs/superpowers/plans/2026-07-02-edgezero-state-extractor.md b/docs/superpowers/plans/2026-07-02-edgezero-state-extractor.md index 964cec06..48175778 100644 --- a/docs/superpowers/plans/2026-07-02-edgezero-state-extractor.md +++ b/docs/superpowers/plans/2026-07-02-edgezero-state-extractor.md @@ -390,7 +390,15 @@ Append to `crates/edgezero-core/src/router.rs`'s main `#[cfg(test)] mod tests` ( Run: `cargo test -p edgezero-core --lib with_state 2>&1 | tail -20` Expected: FAIL — `no method named with_state found for struct RouterBuilder`. -- [ ] **Step 3: Add the `StateInserter` type alias** +> ⚠️ **Steps 3–8 below are the ORIGINAL closure approach and were superseded** +> (see the banner at the top of Task 2). The shipped code has **no `StateInserter` +> alias**: `RouterBuilder`/`RouterInner` hold a `state_extensions: Extensions` +> bag, `with_state` is `self.state_extensions.insert(value)`, and dispatch is +> `request.extensions_mut().extend(self.state_extensions.clone())`. Read +> `crates/edgezero-core/src/router.rs` for the final shape; don't follow the +> `state_inserters`/`StateInserter` snippets below literally. + +- [ ] **Step 3: Add the `StateInserter` type alias** *(superseded — see note above)* In `crates/edgezero-core/src/router.rs`, add just above `pub struct RouterBuilder` (which is at `router.rs:71`, under its `#[derive(Default)]` at `router.rs:70`): From af1e40c68716be58e741a13a8fd52600b229ff6b Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Fri, 3 Jul 2026 15:54:47 -0700 Subject: [PATCH 49/74] docs: retire remaining stale SECRET_FIELDS / StateInserter wording in spec + test comments --- .../tests/app_config_derive.rs | 13 +++--- .../secret_with_serde_container_rename_all.rs | 2 +- .../secret_with_serde_skip_serializing_if.rs | 4 +- ...dgezero-state-and-nested-secrets-design.md | 40 ++++++++----------- 4 files changed, 25 insertions(+), 34 deletions(-) diff --git a/crates/edgezero-macros/tests/app_config_derive.rs b/crates/edgezero-macros/tests/app_config_derive.rs index 1e63a706..ab40b573 100644 --- a/crates/edgezero-macros/tests/app_config_derive.rs +++ b/crates/edgezero-macros/tests/app_config_derive.rs @@ -13,14 +13,13 @@ mod tests { } // The `#[secret]`-annotated fields below are exercised only via the - // `SECRET_FIELDS` associated constant the derive emits — Rust still - // counts them as "never read", so silence the dead-code lint at the - // struct level. + // `secret_fields()` method the derive emits — Rust still counts them + // as "never read", so silence the dead-code lint at the struct level. #[derive(serde::Deserialize, validator::Validate, edgezero_core::AppConfig)] #[serde(deny_unknown_fields)] #[expect( dead_code, - reason = "fields exist only to feed `#[derive(AppConfig)]`; the SECRET_FIELDS array reads them via the derive, not via Rust field access" + reason = "fields exist only to feed `#[derive(AppConfig)]`; secret_fields() reads them via the derive, not via Rust field access" )] struct ConfigKeyInDefault { _greeting: String, @@ -32,7 +31,7 @@ mod tests { #[serde(deny_unknown_fields)] #[expect( dead_code, - reason = "fields exist only to feed `#[derive(AppConfig)]`; the SECRET_FIELDS array reads them via the derive, not via Rust field access" + reason = "fields exist only to feed `#[derive(AppConfig)]`; secret_fields() reads them via the derive, not via Rust field access" )] struct ConfigStoreRef { _greeting: String, @@ -44,7 +43,7 @@ mod tests { #[serde(deny_unknown_fields)] #[expect( dead_code, - reason = "fields exist only to feed `#[derive(AppConfig)]`; the SECRET_FIELDS array reads them via the derive, not via Rust field access" + reason = "fields exist only to feed `#[derive(AppConfig)]`; secret_fields() reads them via the derive, not via Rust field access" )] struct ConfigBothKinds { _greeting: String, @@ -58,7 +57,7 @@ mod tests { #[serde(deny_unknown_fields)] #[expect( dead_code, - reason = "fields exist only to feed `#[derive(AppConfig)]`; the SECRET_FIELDS array reads them via the derive, not via Rust field access" + reason = "fields exist only to feed `#[derive(AppConfig)]`; secret_fields() reads them via the derive, not via Rust field access" )] struct ConfigKeyInNamedStore { #[secret(store_ref = "vault")] diff --git a/crates/edgezero-macros/tests/ui/secret_with_serde_container_rename_all.rs b/crates/edgezero-macros/tests/ui/secret_with_serde_container_rename_all.rs index a50d90fa..594adb5e 100644 --- a/crates/edgezero-macros/tests/ui/secret_with_serde_container_rename_all.rs +++ b/crates/edgezero-macros/tests/ui/secret_with_serde_container_rename_all.rs @@ -1,6 +1,6 @@ //! Container-level `#[serde(rename_all = ...)]` on a struct that has a //! `#[secret]` field must be rejected: the renamer would translate the -//! TOML key to `api-token` while `SECRET_FIELDS` keeps reporting +//! TOML key to `api-token` while `secret_fields()` keeps reporting //! `api_token`, silently desyncing the typed `config validate` secret //! checks and the Spin collision check. diff --git a/crates/edgezero-macros/tests/ui/secret_with_serde_skip_serializing_if.rs b/crates/edgezero-macros/tests/ui/secret_with_serde_skip_serializing_if.rs index b0c088b1..9be7c3fc 100644 --- a/crates/edgezero-macros/tests/ui/secret_with_serde_skip_serializing_if.rs +++ b/crates/edgezero-macros/tests/ui/secret_with_serde_skip_serializing_if.rs @@ -1,8 +1,8 @@ //! `#[serde(skip_serializing_if = "...")]` conditionally omits the //! field from serialisation. Combined with `#[secret]`, that would -//! make `config push` (which reads `SECRET_FIELDS`, then serialises +//! make `config push` (which reads `secret_fields()`, then serialises //! the typed struct) drop the secret key under the condition — -//! desyncing the on-the-wire shape from the SECRET_FIELDS invariant +//! desyncing the on-the-wire shape from the secret_fields() invariant //! relies on. Reject at compile time. #[derive(serde::Deserialize, serde::Serialize, validator::Validate, edgezero_core::AppConfig)] diff --git a/docs/superpowers/specs/2026-07-02-edgezero-state-and-nested-secrets-design.md b/docs/superpowers/specs/2026-07-02-edgezero-state-and-nested-secrets-design.md index 90783aa5..7c5ac820 100644 --- a/docs/superpowers/specs/2026-07-02-edgezero-state-and-nested-secrets-design.md +++ b/docs/superpowers/specs/2026-07-02-edgezero-state-and-nested-secrets-design.md @@ -170,43 +170,35 @@ pub async fn handle_auction( ### 3.2 Router plumbing -> **Implemented design (simpler than this sketch):** rather than a `Vec` of -> type-erased closures, `RouterBuilder`/`RouterInner` hold a single -> `state_extensions: crate::http::Extensions` bag. `with_state` calls -> `self.state_extensions.insert(value)` and dispatch calls -> `request.extensions_mut().extend(self.state_extensions.clone())` after the -> introspection inserts — same `Clone + Send + Sync + 'static` bound, same -> last-write-wins-by-`TypeId`, no closure/vtable machinery. The sketch below is -> the original idea; the shipped code (router.rs) uses the `Extensions` bag. +> **Note:** an earlier draft used a `Vec` of type-erased closures +> (`StateInserter = Arc`). The shipped design below is +> simpler — a single `Extensions` bag — with the same `Clone + Send + Sync + +> 'static` bound and last-write-wins-by-`TypeId` semantics, and no closure/vtable +> machinery. -Add to `RouterBuilder` a list of type-erased inserters and thread them into `RouterInner`: +Store the registered state in an `Extensions` bag on `RouterBuilder` and thread it into `RouterInner`: ```rust -type StateInserter = Arc; - #[derive(Default)] pub struct RouterBuilder { // ... existing fields ... - state_inserters: Vec, + state_extensions: crate::http::Extensions, } pub fn with_state(mut self, value: T) -> Self where T: Clone + Send + Sync + 'static { - self.state_inserters.push(Arc::new(move |ext: &mut http::Extensions| { - ext.insert(value.clone()); - })); + self.state_extensions.insert(value); self } ``` -In `RouterInner::dispatch`, apply inserters to the owned request **before** building the context: +In `RouterInner::dispatch`, extend the owned request's extensions with the state bag **before** building the context: ```rust RouteMatch::Found(entry, params) => { let mut request = request; - for inserter in &self.state_inserters { - inserter(request.extensions_mut()); - } + // ... introspection inserts (manifest/routes) run first ... + request.extensions_mut().extend(self.state_extensions.clone()); let ctx = RequestContext::new(request, params); let next = Next::new(&self.middlewares, entry.handler.as_ref()); next.run(ctx).await @@ -214,9 +206,9 @@ RouteMatch::Found(entry, params) => { ``` Notes: -- `RouterInner` gains a `state_inserters: Vec` field; `RouterService::new` takes it from the builder. -- Insertion happens **after** the adapter has already inserted the store registries into the same extensions map (different `TypeId`s, no collision). If an app ever registers a `T` that an adapter also inserts, last-write-wins and the router runs last — document this; it is not expected in practice. -- Cost is one `Arc` clone (or one `T::clone`) per registered state per request — negligible for `Arc`. +- `RouterInner` gains a `state_extensions: Extensions` field; `RouterService::new` takes it from the builder. +- Insertion happens **after** the introspection inserts into the same extensions map (different `TypeId`s, no collision). `Extensions::extend` is last-write-wins by `TypeId`, and the router runs last — document this; a `T` collision with an adapter/introspection value is not expected in practice. +- Cost is one `Extensions::clone` (which clones each registered `T`) per request — negligible for `Arc` (a refcount bump per entry). - The route-listing internal handler and middleware are unaffected. ### 3.3 Naming decision (needs maintainer sign-off) @@ -335,7 +327,7 @@ Replace the top-level-only loop with a **path navigator**: ### 4.5 CLI touchpoints (`edgezero-cli`) -`run_config_validate_typed` / `run_config_push_typed` / `run_config_diff_typed` reflect over `SECRET_FIELDS` (now paths) to know which fields hold key-names vs. values. Update those reflections to walk paths. `build_config_envelope` is unchanged (it serializes the typed struct verbatim; secret leaves already hold key names at push time). Verify the Spin lowercase-secret-name collision check still operates over the new path metadata. +`run_config_validate_typed` / `run_config_push_typed` / `run_config_diff_typed` reflect over `secret_fields()` (now paths) to know which fields hold key-names vs. values. Update those reflections to walk paths. `build_config_envelope` is unchanged (it serializes the typed struct verbatim; secret leaves already hold key names at push time). Verify the Spin lowercase-secret-name collision check still operates over the new path metadata. ### 4.6 Backward compatibility @@ -390,7 +382,7 @@ Replace the top-level-only loop with a **path navigator**: ## 7. Files to touch (edgezero repo) **Workstream A** -- `crates/edgezero-core/src/router.rs` — `RouterBuilder::with_state`, `RouterInner.state_inserters`, dispatch insertion (before `RequestContext::new`, alongside the introspection injects from PR #300). +- `crates/edgezero-core/src/router.rs` — `RouterBuilder::with_state`, `RouterInner.state_extensions` (an `Extensions` bag), dispatch `extend` (before `RequestContext::new`, alongside the introspection injects from PR #300). - `crates/edgezero-core/src/extractor.rs` — `State` extractor (+ `Deref`/`DerefMut`/`into_inner`). Making `State` `pub` here is sufficient; **no `lib.rs` crate-root re-export** (§8 [A, minor] — no extractor is re-exported at the crate root today; consumers use `edgezero_core::extractor::State`). - `docs/guide/handlers.md`. From 47a112c3769ce9f63b0d50e13e26d089fafba25f Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Fri, 3 Jul 2026 15:56:04 -0700 Subject: [PATCH 50/74] docs: last stale SECRET_FIELDS in a config.rs test comment --- crates/edgezero-cli/src/config.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/edgezero-cli/src/config.rs b/crates/edgezero-cli/src/config.rs index f3684cce..d01be6b5 100644 --- a/crates/edgezero-cli/src/config.rs +++ b/crates/edgezero-cli/src/config.rs @@ -2486,7 +2486,7 @@ ids = ["default"] // Regression: `#[secret(store_ref)]` values are logical // store ids (resolved at runtime), not Spin variable names — // they must not enter the Spin collision set. Earlier the - // walker treated every SECRET_FIELDS entry as a potential + // walker treated every secret_fields() entry as a potential // Spin var, so a perfectly valid `vault = "default"` plus a // config key whose flattened name happened to be `default` // would falsely trip a collision. From 65afbd389c49dc20b6d72f79ba7f3b5627885114 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Sat, 4 Jul 2026 14:19:57 -0700 Subject: [PATCH 51/74] docs: P0-C/P0-D spec (Fastly dispatch fidelity + macro app-state), maintainer-review revised Adds the P0-C (Fastly run_app dispatch fidelity: Set-Cookie multi-value, owns_logging opt-out, raw-request pre-dispatch hook) + P0-D (app! state= injection) design, revised across three review rounds against 47a112c: P0-D reduced to a macro-only change reusing the router Extensions bag; C3 Extensions path + before-conversion scratch-bag ordering; C1 proxy response fidelity; C2 cross-adapter owns_logging + missing_trait_methods emission; full app! argument grammar. --- ...0cd-fastly-dispatch-and-appstate-design.md | 221 ++++++++++++++++++ 1 file changed, 221 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-03-edgezero-p0cd-fastly-dispatch-and-appstate-design.md diff --git a/docs/superpowers/specs/2026-07-03-edgezero-p0cd-fastly-dispatch-and-appstate-design.md b/docs/superpowers/specs/2026-07-03-edgezero-p0cd-fastly-dispatch-and-appstate-design.md new file mode 100644 index 00000000..00fbabdb --- /dev/null +++ b/docs/superpowers/specs/2026-07-03-edgezero-p0cd-fastly-dispatch-and-appstate-design.md @@ -0,0 +1,221 @@ +# EdgeZero P0-C + P0-D — Fastly `run_app` dispatch fidelity + app-state injection + +- **Status:** Draft for edgezero maintainer +- **Date:** 2026-07-03 +- **Target repo:** `github.com/stackpop/edgezero` (`edgezero-adapter-fastly`, `edgezero-core`, `edgezero-macros`) +- **Consumed by:** trusted-server "full convergence" migration — the decision that every adapter binary becomes the one-line `run_app::` with `#[action]` handlers. These two capabilities are the remaining gaps that block Fastly (P0-C) and macro-based app state (P0-D). Independent of the earlier Phase 0 spec (State + nested `#[secret]`, PR #306). +- **Verified against:** originally `6ebc29a5`; **re-verified and revised against `47a112c`** (branch `worktree-state-nested-secrets-spec-review`, PR #306 tip). The revision matters: between those commits the router's app-state layer was simplified from a `Vec` of type-erased closures to a single `state_extensions: Extensions` bag, which materially simplifies P0-D (see §P0-D). + +> **Maintainer-review revisions (2026-07-03..04):** folded in after verifying every claim against `47a112c`. +> - **Round 1 — P0-D re-specced** to reuse the router's existing `Extensions`-bag state injection (macro-only; no new `Hooks` method, no `AppState` carrier, no per-adapter edits — the original `StateInserter` design no longer exists). **C3 wiring corrected** — the raw-request closure runs *before* conversion against a scratch `Extensions` (conversion consumes the `fastly::Request`). **C2** gained an `app!` opt-in for fully-macro apps. +> - **Round 2 — C3 `Extensions` path** fixed to `edgezero_core::http::Extensions` (the snippet lives in the fastly adapter, not core). **C2 scope** made a decision point: `owns_logging()` is a platform-neutral `Hooks` method, so **all four** adapter entrypoints must gate their logger init on it (or C2 is scoped Fastly-only) — the files list previously only touched Fastly. **C1** extended to the proxy **response** path (`convert_response` collapses multi-value origin `Set-Cookie`). **`app!` argument grammar** fully specified (coexistence of the custom app ident with `state =` / `owns_logging =`, order, duplicate/unknown-key errors) — previously undefined. +> - **Round 3 — `owns_logging` macro emission** corrected: the macro must **always emit** `fn owns_logging() -> bool { #bool }` (not rely on the trait default), because `clippy::missing_trait_methods` (`restriction = deny`) forbids inheriting defaulted trait methods; the same lint means adding the trait method touches **every** `impl Hooks` in the workspace (noted in the C2 scope decision). Stale `state = f` acceptance wording corrected to `state = `. + +--- + +## Why + +trusted-server is converging on the canonical `app-demo` wiring: `run_app::` on every adapter, `#[action]` handlers, `State>`. Two things stop that today: + +1. **Fastly `run_app` loses fidelity** that trusted-server's hand-written custom dispatch preserves: multi-value `Set-Cookie` headers, an opt-out from the per-call logger reinit, and a pre-dispatch hook to capture Fastly-only request signals (TLS JA4 / H2 fingerprint, client IP) from the raw `fastly::Request` before it is converted to the neutral core request. → **P0-C.** +2. **Macro/`run_app` apps can't inject app-owned state.** `State` + `RouterBuilder::with_state` exist (PR #306) and the router injects registered state at dispatch — but the `app!` macro generates the router and never calls `with_state`, and `run_app` doesn't inject app state. So `State>` can't reach handlers in a macro app. → **P0-D.** + +**P0-D is optional** (see §4): if a downstream keeps a hand-written `Hooks::routes()` that calls `RouterBuilder::with_state`, the existing dispatch-time injection already delivers `State` under `run_app` — no edgezero change needed. P0-D is required only to support app-owned state **through the `app!` macro**. It is specified here so the maintainer can choose to support the fully-macro path. + +--- + +## P0-C — Fastly `run_app` dispatch fidelity + +Three independent sub-changes in `edgezero-adapter-fastly`. Each is small and separately testable. + +### C1 — Preserve multi-value response headers (`Set-Cookie`) + +**Current (bug):** `crates/edgezero-adapter-fastly/src/response.rs` builds the `fastly::Response` by looping over the core response's `HeaderMap` and calling `set_header`, which **replaces** — so N `Set-Cookie` values collapse to the last one: + +```rust +// response.rs (~line 28) +for (name, value) in &parts.headers { + fastly_response.set_header(name.as_str(), value.as_bytes()); +} +``` + +`http::HeaderMap`'s iterator yields **one entry per value** (duplicates included), and the `fastly::Response` starts empty (`FastlyResponse::from_status(...)`). So the fix is to **append** instead of set: + +```rust +for (name, value) in &parts.headers { + fastly_response.append_header(name.as_str(), value.as_bytes()); +} +``` + +`append_header` adds without clobbering, so all `Set-Cookie` (and any other multi-value header) survive. This is unconditionally correct given a fresh response; no per-header special-casing needed. + +**Two more proxy header surfaces have the same class of defect:** + +1. **Proxy *request* construction** — `proxy.rs:53` uses `set_header` when building the upstream `fastly::Request`. Request-side multi-value headers are rare (`Cookie` folds to one), so this is audit-only: either switch to `append_header` for consistency or document why `set_header` is acceptable here. +2. **Proxy *response* conversion (must fix, or explicitly exclude)** — `convert_response` in `proxy.rs` collapses **origin** multi-value response headers. It iterates `fastly_response.get_header_names()` and does `proxy_response.headers_mut().insert(header, fastly_response.get_header(header)...)` — `get_header` returns only the first value and `HeaderMap::insert` replaces, so multiple upstream `Set-Cookie` collapse to one. Origin `Set-Cookie` is common (auth/session), so this matters more than the request side. Fix by reading **all** values and appending: for each `name` in `get_header_names()`, iterate `fastly_response.get_header_all(name)` and `proxy_response.headers_mut().append(name, value.clone())`. **If P0-C intends to preserve multi-value headers, this is in scope**; otherwise state explicitly that proxy-response fidelity is out of P0-C. + +**Tests:** (a) a handler returns a `Response` with two `Set-Cookie` values → the converted `fastly::Response` (`get_header_all("set-cookie")`) contains both; (b) an upstream `fastly::Response` with two `Set-Cookie` → `convert_response` yields a `ProxyResponse` whose `HeaderMap` retains both. + +### C2 — Let the app opt out of the `run_app` logger init + +**Current:** `run_app` (`lib.rs:113`) initializes the Fastly logger unconditionally when `use_fastly_logger`: + +```rust +let logging = logging_from_env(&env); +if logging.use_fastly_logger { + init_logger(endpoint, logging.level, logging.echo_stdout)?; +} +``` + +An app that already owns `log`/`log-fastly` initialization (trusted-server does) cannot use `run_app` without a double-init conflict. Provide an opt-out. **Preferred:** a `Hooks` flag consulted by every adapter's `run_app`, so it is platform-neutral: + +```rust +// edgezero-core/src/app.rs — Hooks +/// When `true`, the adapter's `run_app` skips its own logger +/// initialization; the app is responsible for installing a `log` backend. +/// Default `false` (adapter initializes logging as today). +fn owns_logging() -> bool { false } +``` + +**Scope decision (this is a platform-neutral `Hooks` method, so ALL adapters must honor it — otherwise the flag is a lie on 3 of 4 platforms).** Every adapter entrypoint that initializes a logger must gate it on `!A::owns_logging()`: +- **Fastly** — `lib.rs:117` `init_logger(...)` (the primary target). +- **Cloudflare** — `lib.rs:105` `drop(init_logger())`. +- **Axum** — `dev_server.rs:343` `SimpleLogger::new()...init()`. +- **Spin** — `lib.rs:115` `drop(init_logger())` (already a no-op, but gate it for uniformity and to keep the contract honest). + +**Hidden cost of the `Hooks` method (feeds this decision):** `clippy::missing_trait_methods` (`restriction = deny`) forbids any `impl Hooks` from inheriting a defaulted method. So adding `owns_logging` to the trait — even with a default — forces **every existing `impl Hooks` in the workspace to add an explicit `fn owns_logging()`**: the `app!` macro's emitted impl (add it alongside `configure`/`build_app`), `app-demo`'s app, and every hand-written test/fixture `Hooks` impl. This is mechanical but wider than "core + 4 adapters." The Fastly-only alternative (a `run_app_without_logger::` variant, no trait method) avoids this ripple entirely. + +If cross-adapter wiring + the trait-impl ripple are undesirable for the first landing, the alternative is to **scope C2 explicitly to Fastly** and NOT add a core `Hooks` method — e.g. a Fastly-only `run_app_without_logger::` variant. Do not ship a platform-neutral `Hooks::owns_logging()` that only Fastly consults. **Recommendation:** the neutral `Hooks` method, wired through all four entrypoints and all `Hooks` impls (mechanical), since trusted-server converges on all adapters — but the plan must enumerate every `impl Hooks` site it touches. + +`run_app` becomes `if logging.use_fastly_logger && !A::owns_logging() { init_logger(...)?; }`. (Alternative: a `run_app_without_logger::` variant — but the `Hooks` flag composes with the `app!` macro and applies uniformly across adapters, so prefer it.) + +**Macro opt-in (required for the fully-macro path).** The `Hooks` default `owns_logging() -> false` is only overridable by a **hand-written** `Hooks` impl. A fully-macro app (the stated trusted-server target, which *does* own its `log`/`log-fastly` init) has no way to set it — the `app!` macro generates the `Hooks` impl and would emit the default. So C2 must also give `app!` an `owns_logging = true` argument (parallel to P0-D's `state = …`), e.g. `app!("edgezero.toml", owns_logging = true)`, which emits `fn owns_logging() -> bool { true }` in the generated impl. Without it, C2 only helps hand-written `Hooks` impls, not the macro path C2 exists to unblock. + +**Test:** an app with `owns_logging() == true` runs `run_app` twice / after the app initialized its own logger without the init error. Add a macro-path test: `app!("…", owns_logging = true)` emits `owns_logging() == true`. + +### C3 — Pre-dispatch hook for raw-request signals (JA4 / H2 / client IP) + +**Current:** `run_app` → `dispatch_with_registries` → `dispatch_with_handles` (`request.rs:279`) calls `into_core_request(req)` (`request.rs:284`), which **consumes the `fastly::Request` by value**, then inserts the store registries into the core request's extensions (`request.rs:266-272`) and runs `app.router().oneshot(core_request)` (`request.rs:274`). There is **no hook** to read the *original* `fastly::Request` (whose `get_tls_ja4()`, `get_client_h2_fingerprint()`, client-IP getter are only available pre-conversion) and stash derived values into the core request's extensions. trusted-server's custom path does this before dispatch. (Note: `context.rs:19` already inserts a `FastlyContext` into the core request; C3 supplements that with app-specific signals — the plan should state whether client-IP is already captured there to avoid duplication.) + +**Ordering constraint (this corrects the original sketch).** The raw signals must be **read before** `into_core_request` consumes `req`, but they must be **written into** the core request's `Extensions`, which only exist **after** conversion. So the closure cannot receive `core_req.extensions_mut()` "after conversion" — by then `req` is gone. Resolve by running the closure against a **scratch `Extensions`** before conversion, then merging it in after: + +```rust +// edgezero-adapter-fastly/src/lib.rs +use edgezero_core::http::Extensions; // NOT `crate::http` — that facade is edgezero-core's; + // the fastly adapter reaches it via `edgezero_core::http`. + +pub fn run_app_with_request_extensions( + req: fastly::Request, + extend: F, +) -> Result +where + A: Hooks, + F: FnOnce(&fastly::Request, &mut Extensions), +{ /* same as run_app, but thread `extend` into dispatch; there: + let mut scratch = Extensions::default(); // Extensions: Default + extend(&req, &mut scratch); // BEFORE into_core_request(req) + let mut core_req = into_core_request(req)?; + core_req.extensions_mut().extend(scratch); // reuse the Extensions::extend pattern + // ... registry inserts, then router.oneshot ... */ } +``` + +(The neutral core request produced by `into_core_request` is `edgezero_core::http::Request`, so `edgezero_core::http::Extensions` is the matching type — the closure's bag and the core request's extensions map are the same `http::Extensions`.) + +The closure runs once per request, reads the raw `fastly::Request`, and populates a scratch bag that is `extend`ed into the core request (the same `Extensions::extend` mechanism the router uses for app state). `run_app` stays as the no-hook convenience wrapper (`run_app_with_request_extensions::(req, |_, _| {})`). + +This requires threading the closure from `run_app_with_request_extensions` → `dispatch_with_registries` → `dispatch_with_handles` (add a generic `extend: F` parameter, or `Option<&mut dyn FnMut(&FastlyRequest, &mut Extensions)>`), with the scratch-then-extend step landing in `dispatch_with_handles` around `into_core_request`. Keep the existing `dispatch_with_registries` entry working (the no-op closure). + +**Test:** a handler reads a value from extensions that only the pre-dispatch closure could have set (e.g. a synthetic `Ja4` newtype); assert it is present. + +### P0-C acceptance + +- Multi-value `Set-Cookie` round-trips through `run_app` (C1) — both the handler-response path (`response.rs`) and, unless explicitly excluded, the proxy-response path (`proxy.rs::convert_response`). +- An app with `owns_logging() == true` runs under `run_app` without a logger-init error (C2) — verified on Fastly and, since `owns_logging` is a platform-neutral `Hooks` method, wired through the Cloudflare / Axum / Spin entrypoints too (or C2 is explicitly scoped Fastly-only — see the C2 scope decision). +- A pre-dispatch closure can populate core-request extensions from the raw `fastly::Request` (C3). +- `app-demo` still builds/serves; existing Fastly tests green; `run_app` (no-hook) behavior unchanged for apps that don't opt in. + +--- + +## P0-D — App-state injection for macro / `run_app` apps + +### The gap + +`State` (`extractor.rs:550`) reads from request extensions; `RouterBuilder::with_state` (`router.rs`) registers a value in the router's `state_extensions: Extensions` bag, which dispatch clones into each request via `request.extensions_mut().extend(self.state_extensions.clone())`. That works when the app **hand-builds** its router. But the `app!` macro's generated `build_router()` (`edgezero-macros/src/app.rs:185`) only calls `.with_manifest_json(...)` — never `.with_state(...)` — so a macro app has no way to provide `State>`. + +### Design — bake app state into the router via the macro (revised) + +> **Revised after the `Extensions`-bag reshape.** The original design added a `Hooks::app_state()` method returning a type-erased `AppState` carrier and applied it in **all four adapters'** `run_app`, "mirroring registry injection." That is unnecessary and was written against the removed `StateInserter` layer. Registries are injected adapter-side **because they are platform-specific handles that cannot live in the neutral router**; **app state is platform-neutral and already lives in the router** (`state_extensions`), whose dispatch injection (shipped in PR #306) delivers it to every request. So P0-D is just: **have the macro-generated router call `with_state`** — reusing the exact path the hand-written case already uses. No new `Hooks` method, no `AppState` carrier, **no adapter changes**. + +**`edgezero-macros` — `app!` gains an optional `state` argument.** `build_router()` emits one extra builder call, right next to the existing `with_manifest_json`: + +```rust +edgezero_core::app!("edgezero.toml", state = crate::app_state()); + +// in the generated build_router(): +let mut builder = RouterService::builder(); +builder = builder.with_manifest_json(#manifest_json_lit); +builder = builder.with_state(crate::app_state()); // #state_expr, only when `state = …` is given +// ... routes ... +``` + +**`state = ` is a full Rust expression** evaluating to the app-owned value, emitted **verbatim** into `.with_state()`. It must be the call/expression, **not** a bare function path — `with_state` takes the state *value*, so `state = crate::app_state` (a fn item) would pass the function, not its result. Write `state = crate::app_state()` or `state = std::sync::Arc::new(AppState::new())`. (This mirrors nothing magical: the macro does not append `()`.) `run_app` → `A::build_app()` → `routes()` → `build_router()` already runs per request (Fastly) / once at startup (Axum), and #306's dispatch injection clones the value into each request — so `State` reaches `#[action]` handlers on **all four adapters** with no adapter edits. Without the `state` argument, `build_router()` is unchanged (no state), preserving current behavior. + +**Single vs. multiple state types.** `with_state` registers one `T` — a single `state = crate::app_state()` covers the `Arc` case (what trusted-server / `app-demo` need). If multiple state types are ever required, allow repeated `state = a(), state = b()` (emit one `.with_state(...)` per occurrence) or add a `RouterBuilder::with_state_extensions(Extensions)` fed by an app-supplied `Extensions` bag. Default to the single-value form unless a concrete multi-type need appears. **The grammar below permits repeated `state`; whether repeats are accepted or rejected is a decision the plan must state** (see §`app!` argument grammar). + +### `app!` argument grammar (governs P0-D `state` and C2 `owns_logging`) + +This must be nailed down before a step-by-step plan. Today `AppArgs::parse` (`edgezero-macros/src/app.rs:12`) accepts only `app!("path")` or `app!("path", AppIdent)` and errors on any further tokens. Extend it to: + +``` +app!( PATH [, APP_IDENT] [, KEY = VALUE]* ) +``` + +- **PATH** — string literal (manifest path). Required, first. Unchanged. +- **APP_IDENT** — optional bare identifier (the custom `App` type name), exactly as today. If present it must be the **first** comma item after PATH, before any `KEY = VALUE`. At most one. +- **KEY = VALUE** — zero or more keyword arguments, **order-independent** among themselves, following `APP_IDENT` if that is present. Recognized keys: + - `state = ` — a `syn::Expr`; emits `.with_state()` in `build_router()`. + - `owns_logging = ` — `true` or `false` (a `syn::LitBool`). The macro **always emits** `fn owns_logging() -> bool { #bool }` in the generated `Hooks` impl, defaulting `#bool` to `false` when the argument is omitted. It must **not** rely on the trait default: `clippy::missing_trait_methods` (`restriction = deny`, `Cargo.toml`) forbids an impl from inheriting a defaulted trait method, which is exactly why the macro already emits explicit `configure`/`build_app` bodies (`edgezero-macros/src/app.rs:154`). Emit `owns_logging` the same way. +- **Disambiguation** — after PATH, iterate comma-separated items. For each item, `peek2(Token![=])`: if the next-next token is `=`, parse `Ident = Value` as a keyword; otherwise parse a bare `Ident` as `APP_IDENT`. +- **Errors (define exact messages in the plan):** + - Unknown key → `` unknown `app!` argument ``; expected `state` or `owns_logging` ``. + - Duplicate key → `` duplicate `` argument ``. (Decide state-repeat policy: reject as duplicate, or allow N `state` args — pick one; recommend **reject duplicates** initially, single `state` only, to keep it simple.) + - Bare ident after a keyword arg, or a second bare ident → `` the custom App identifier must come immediately after the manifest path, before keyword arguments ``. + - Wrong value type (`state` given a non-expression, `owns_logging` given a non-bool) → a clear per-key message. +- **`AppArgs` becomes** `{ path: LitStr, app_ident: Option, state: Option, owns_logging: Option }`; the two new fields feed `build_router()` (state) and the generated `Hooks` impl (owns_logging). Add UI/trybuild-style or unit coverage for: happy path each key, both keys, key + app ident, unknown key, duplicate key, ident-after-key. + +### Alternative that needs NO macro change (document in the guide) + +A downstream that keeps a **hand-written `Hooks::routes()`** can call `RouterBuilder::with_state(app_state)` there directly; the dispatch-time `state_extensions` injection then delivers it under `run_app` with zero further change. The trade-off is routes are built in Rust rather than declared in `edgezero.toml`. trusted-server may take this path — but the `state = …` macro argument is what makes app state work for the **fully macro-driven** shape `app-demo` models. + +### P0-D acceptance + +- A macro app declaring `app!("...", state = )` (e.g. `state = crate::app_state()`) can extract `State` (where `T` is the expression's value type) in an `#[action]` handler on all four adapters. +- An app that provides no state is unaffected (`State` for an unregistered `T` returns the existing "no state registered" 500). +- `app-demo` gains a small example using `app!(..., state = ...)` + a `State` handler. +- **No adapter (`run_app`) or `Hooks`-trait change is required for P0-D** — the diff is confined to `edgezero-macros` (and the `app-demo` example). This is the acceptance signal that the revised, simpler design was taken. + +--- + +## Sequencing & interaction with trusted-server Phase 1 + +- **P0-C is required** for trusted-server Phase 4 (Fastly `run_app`). Until it lands, trusted-server's Phase 1 keeps interim Fastly local registry builders + custom `oneshot`; those are deleted in Phase 4 once P0-C exists. Landing P0-C early lets Phase 1 skip that throwaway scaffolding. +- **P0-D is required only for the `app!`-macro path.** If trusted-server keeps hand-built `routes()` + `with_state`, P0-C alone suffices for full `run_app` convergence. Decide this before Phase 4. +- Both are independent of the nested-`#[secret]` work already in #306. + +## Files to touch (edgezero) + +**P0-C** +- `crates/edgezero-adapter-fastly/src/response.rs` — `set_header` → `append_header` in the handler-response loop (C1) +- `crates/edgezero-adapter-fastly/src/proxy.rs` — (a) audit/switch request-side `set_header` (`:53`); (b) fix `convert_response` to read `get_header_all` + `headers_mut().append` so multi-value **origin** response headers survive — unless proxy-response fidelity is explicitly excluded from P0-C (C1) +- `crates/edgezero-core/src/app.rs` — `Hooks::owns_logging()` default-`false` method (C2) +- `crates/edgezero-adapter-fastly/src/lib.rs` — gate `init_logger` (`:117`) on `!A::owns_logging()`; add `run_app_with_request_extensions` (C2, C3) +- `crates/edgezero-adapter-{cloudflare,axum,spin}/src/…` — gate each entrypoint's logger init on `!A::owns_logging()` (Cloudflare `lib.rs:105`, Axum `dev_server.rs:343`, Spin `lib.rs:115`) — required to keep the neutral `Hooks` flag honest (C2; omit only if C2 is scoped Fastly-only) +- `crates/edgezero-adapter-fastly/src/request.rs` — thread the pre-dispatch closure through `dispatch_with_registries`/`dispatch_with_handles`; scratch-`Extensions`-then-`extend` around `into_core_request` (C3) +- `crates/edgezero-macros/src/app.rs` — `owns_logging = true` argument so fully-macro apps can opt out of adapter logger init; per the `app!` argument grammar (C2) + +**P0-D** *(revised — macro-only; the router already owns state injection)* +- `crates/edgezero-macros/src/app.rs` — optional `state = ` argument (per the `app!` argument grammar); `build_router()` emits `.with_state(#state_expr)` +- `examples/app-demo/…` — small `app!(..., state = …)` + `State` handler example +- *(No `edgezero-core` `Hooks`/`AppState` change and no adapter change — superseding the original list's `Hooks::app_state()`, the router `StateInserter`-exposure line, and the four-adapter edits, all of which the `Extensions`-bag reshape made unnecessary.)* + +> **Note:** `crates/edgezero-macros/src/app.rs` is touched by **both** C2 (`owns_logging =`) and P0-D (`state =`), and both extend the same `AppArgs` grammar. If P0-C and P0-D are planned/shipped as separate plans (recommended — they are independent), the `app!` grammar extension is a shared prerequisite; land the `AppArgs` grammar rework once (with both keys) or sequence the plans so the second rebases on the first. From c86c039fbb0697aceac5c247bc48f01079230b97 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Sat, 4 Jul 2026 15:50:32 -0700 Subject: [PATCH 52/74] docs: implementation plans for P0-C (Fastly dispatch fidelity) + P0-D (app! app-state) --- ...4-edgezero-p0c-fastly-dispatch-fidelity.md | 773 ++++++++++++++++++ ...2026-07-04-edgezero-p0d-app-macro-state.md | 335 ++++++++ 2 files changed, 1108 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-04-edgezero-p0c-fastly-dispatch-fidelity.md create mode 100644 docs/superpowers/plans/2026-07-04-edgezero-p0d-app-macro-state.md diff --git a/docs/superpowers/plans/2026-07-04-edgezero-p0c-fastly-dispatch-fidelity.md b/docs/superpowers/plans/2026-07-04-edgezero-p0c-fastly-dispatch-fidelity.md new file mode 100644 index 00000000..dd54b3fd --- /dev/null +++ b/docs/superpowers/plans/2026-07-04-edgezero-p0c-fastly-dispatch-fidelity.md @@ -0,0 +1,773 @@ +# EdgeZero P0-C — Fastly `run_app` Dispatch Fidelity 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:** Bring Fastly's `run_app` to parity with hand-written custom dispatch: preserve multi-value response headers (`Set-Cookie`), let an app opt out of the adapter's logger init, and add a pre-dispatch hook that reads raw-`fastly::Request` signals (JA4 / H2 / client IP) into the core request's extensions. + +**Architecture:** Three independent Fastly-adapter changes plus one cross-adapter `Hooks` method. C1 swaps `set_header`→`append_header` on the (fresh) response and fixes the proxy-response conversion to append per value. C2 adds a platform-neutral `Hooks::owns_logging()` that every adapter's entrypoint gates its logger init on, with an `app!(owns_logging = …)` macro argument. C3 adds `run_app_with_request_extensions` that runs an app closure against a scratch `Extensions` **before** request conversion, then `extend`s it into the core request. + +**Tech Stack:** Rust 1.95, edition 2021. `edgezero-adapter-fastly` (behind the `fastly` feature), `edgezero-core` (`Hooks` trait, `http::Extensions`), `edgezero-macros` (`app!`). `http` crate `HeaderMap`/`Extensions` via `edgezero_core::http`. + +**Source spec:** `docs/superpowers/specs/2026-07-03-edgezero-p0cd-fastly-dispatch-and-appstate-design.md` (P0-C), verified against `65afbd3`. + +## Global Constraints + +- **Rust 1.95.0**, edition 2021. +- **Strict clippy gate** — `[workspace.lints.clippy] restriction = { level = "deny" }`. Every restriction lint is an ERROR. The ones that bite here: + - `missing_trait_methods` — an `impl Trait` may not inherit a defaulted method. Adding `Hooks::owns_logging()` forces the **macro-emitted** `impl Hooks` to emit it explicitly (the two in-file `Hooks` test stubs already carry `#[expect(clippy::missing_trait_methods)]`, so they need no change). + - `arbitrary_source_item_ordering` — module items / struct fields / enum variants must be alphabetical; place new test fns and struct fields in the correct position, don't append. + - `min_ident_chars` — no single-char identifiers. + - `absolute_paths` — import types; don't inline 3-segment paths. + - `impl_trait_in_params` — use named generics, not `impl Trait` params. + - `assertions_on_result_states` — in tests use `.unwrap()/.unwrap_err()/.expect()` or `assert_eq!`, not `assert!(x.is_ok())`. + - `needless_raw_strings` — plain string unless it needs `"`/`#`. +- **Test targets/features (critical — the crate compiles almost nothing on default features):** + - The `response`/`proxy`/`request`/`lib` modules are all `#[cfg(feature = "fastly")]`. **Host unit tests run only under `cargo test -p edgezero-adapter-fastly --features fastly`.** + - `crates/edgezero-adapter-fastly/tests/contract.rs` is `#![cfg(all(feature = "fastly", target_arch = "wasm32"))]` — **Viceroy/wasm-only; it does NOT run under host `cargo test`** and is out of scope for this plan's verification commands. + - Fastly **hostcalls** (`get_client_ip_addr`, `get_tls_ja4`, `send_async_streaming`, `Backend::builder`, `ConfigStore::try_open`) panic/abort off-platform. Host unit tests must avoid them. `FastlyResponse::from_status`, `set_body`/`append_header`/`get_header_all`, and `FastlyRequest::new`/`get_method`/`get_url_str`/`get_headers` work host-side. +- **CI gates (all must pass):** + 1. `cargo fmt --all -- --check` + 2. `cargo clippy --workspace --all-targets --all-features -- -D warnings` + 3. `cargo test --workspace --all-targets` + 4. `cargo check --workspace --all-targets --features "fastly cloudflare spin"` + 5. `cargo check -p edgezero-adapter-spin --target wasm32-wasip2 --features spin` +- **No backward-compat constraint** — prefer the cleanest breaking change; update every in-tree call site in the same PR. +- **Shared with P0-D:** Task 3 reworks the `app!` `AppArgs` grammar into keyword arguments (adding `owns_logging`). The sibling P0-D plan (`2026-07-04-edgezero-p0d-app-macro-state.md`) **extends that same grammar** with a `state` key — execute this P0-C plan first so P0-D builds on the keyword-arg framework. + +--- + +## File Structure + +| File | Responsibility | Task | +| ---- | -------------- | ---- | +| `crates/edgezero-adapter-fastly/src/response.rs` | `from_core_response`: `set_header`→`append_header` + host test | 1 | +| `crates/edgezero-adapter-fastly/src/proxy.rs` | `convert_response`: append per value; `build_fastly_request` request-side note + host tests | 2 | +| `crates/edgezero-core/src/app.rs` | `Hooks::owns_logging()` default + trait test | 3 | +| `crates/edgezero-macros/src/app.rs` | emit `fn owns_logging()`; then (Task 4) rework `AppArgs` to keyword grammar + `owns_logging =` | 3, 4 | +| `crates/edgezero-adapter-{fastly,cloudflare,axum,spin}/src/…` | gate each logger init on `!A::owns_logging()` | 3 | +| `crates/edgezero-adapter-fastly/src/lib.rs` | `run_app_with_request_extensions` + gate logger | 3, 5 | +| `crates/edgezero-adapter-fastly/src/request.rs` | thread the pre-dispatch closure; scratch-`Extensions`-then-`extend` around `into_core_request` + host test | 5 | + +--- + +## Task 1: C1 — multi-value response headers in `from_core_response` + +**Files:** +- Modify: `crates/edgezero-adapter-fastly/src/response.rs` (`from_core_response` at `response.rs:28-30`; add a host test in the existing `#[cfg(test)] mod tests`) + +**Interfaces:** +- Consumes: `from_core_response(response: edgezero_core::http::Response) -> Result` (existing); `response_builder()`, `Body` (test-module imports already present). +- Produces: unchanged signature; behavior now preserves duplicate header values. + +- [ ] **Step 1: Write the failing host test** + +Add to the `#[cfg(test)] mod tests` in `crates/edgezero-adapter-fastly/src/response.rs`, placed in alphabetical position among the test fns (before `stream_body_is_written_to_fastly_response`). The module already imports `super::*`, `Body`, `response_builder`. + +```rust + #[test] + fn multi_value_set_cookie_survives_conversion() { + // http::response::Builder::header APPENDS, so this is two Set-Cookie values. + let response = response_builder() + .status(200) + .header("set-cookie", "a=1") + .header("set-cookie", "b=2") + .body(Body::empty()) + .expect("response"); + + let fastly_response = from_core_response(response).expect("fastly response"); + + let cookies: Vec = fastly_response + .get_header_all("set-cookie") + .map(|value| value.to_str().expect("utf8").to_owned()) + .collect(); + assert_eq!(cookies, vec!["a=1".to_owned(), "b=2".to_owned()]); + } +``` + +- [ ] **Step 2: Run it — verify it fails** + +Run: `cargo test -p edgezero-adapter-fastly --features fastly --lib multi_value_set_cookie 2>&1 | tail -20` +Expected: FAIL — the collected `cookies` is `["b=2"]` (the loop's `set_header` replaced the first value). + +- [ ] **Step 3: Swap `set_header` → `append_header`** + +In `crates/edgezero-adapter-fastly/src/response.rs`, change the header loop (`response.rs:28-30`): + +```rust + for (name, value) in &parts.headers { + fastly_response.set_header(name.as_str(), value.as_bytes()); + } +``` + +to: + +```rust + // `append_header` preserves multi-value headers (e.g. N `Set-Cookie`). The + // response starts empty (`from_status`) and `http::HeaderMap` iteration + // yields one entry per value, so appending is unconditionally correct. + for (name, value) in &parts.headers { + fastly_response.append_header(name.as_str(), value.as_bytes()); + } +``` + +- [ ] **Step 4: Run it — verify it passes** + +Run: `cargo test -p edgezero-adapter-fastly --features fastly --lib multi_value_set_cookie 2>&1 | tail -8` +Expected: PASS. Also run the whole module: `cargo test -p edgezero-adapter-fastly --features fastly --lib response 2>&1 | tail -8` → all pass. + +- [ ] **Step 5: Lint** + +Run: `cargo clippy -p edgezero-adapter-fastly --all-targets --features fastly -- -D warnings 2>&1 | tail -5` +Expected: clean. + +- [ ] **Step 6: Commit** + +```bash +git add crates/edgezero-adapter-fastly/src/response.rs +git commit -m "fix(fastly): preserve multi-value response headers (Set-Cookie) in from_core_response" +``` + +--- + +## Task 2: C1 — multi-value headers in the proxy response conversion + +**Files:** +- Modify: `crates/edgezero-adapter-fastly/src/proxy.rs` (`convert_response` at `proxy.rs:67-71`; `build_fastly_request` at `proxy.rs:53`; add host tests) + +**Interfaces:** +- Consumes: `convert_response(fastly_response: &mut fastly::Response) -> edgezero_core::proxy::ProxyResponse` (existing, private); `HeaderName` from `edgezero_core::http`. +- Produces: `convert_response` preserving duplicate origin response headers. + +- [ ] **Step 1: Write the failing host test** + +Add to the `#[cfg(test)] mod tests` in `crates/edgezero-adapter-fastly/src/proxy.rs` (module already imports `super::*`, `block_on`). Place alphabetically among the existing `stream_handles_*` tests (before `stream_handles_brotli`). + +```rust + #[test] + fn convert_response_preserves_multi_value_set_cookie() { + let mut fastly_response = FastlyResponse::from_status(200); + fastly_response.append_header("set-cookie", "a=1"); + fastly_response.append_header("set-cookie", "b=2"); + + let proxy_response = convert_response(&mut fastly_response); + + let cookies: Vec = proxy_response + .headers() + .get_all("set-cookie") + .into_iter() + .map(|value| value.to_str().expect("utf8").to_owned()) + .collect(); + assert_eq!(cookies, vec!["a=1".to_owned(), "b=2".to_owned()]); + } +``` + +- [ ] **Step 2: Run it — verify it fails** + +Run: `cargo test -p edgezero-adapter-fastly --features fastly --lib convert_response_preserves 2>&1 | tail -20` +Expected: FAIL — `cookies` is `["a=1"]` (or one value): `get_header` returns the first value and `HeaderMap::insert` replaces. + +- [ ] **Step 3: Fix `convert_response` to append every value** + +In `crates/edgezero-adapter-fastly/src/proxy.rs`, change the header loop (`proxy.rs:67-71`): + +```rust + for header in fastly_response.get_header_names() { + if let Some(value) = fastly_response.get_header(header) { + proxy_response.headers_mut().insert(header, value.clone()); + } + } +``` + +to: + +```rust + // Preserve multi-value ORIGIN response headers (e.g. Set-Cookie): read ALL + // values per name and append, instead of first-value + insert (which + // replaced). `get_header_names()` yields `&HeaderName`, usable for both + // `get_header_all` and `append`. + for name in fastly_response.get_header_names() { + for value in fastly_response.get_header_all(name) { + proxy_response.headers_mut().append(name, value.clone()); + } + } +``` + +(If the installed `fastly` SDK's `get_header_names()` yields owned `HeaderName` rather than `&HeaderName`, bind `for name in …` then call `get_header_all(&name)` / `append(&name, …)`. Confirm by the compiler; behavior is identical.) + +- [ ] **Step 4: Run it — verify it passes** + +Run: `cargo test -p edgezero-adapter-fastly --features fastly --lib convert_response_preserves 2>&1 | tail -8` +Expected: PASS. Run the module: `cargo test -p edgezero-adapter-fastly --features fastly --lib proxy 2>&1 | tail -8` → all pass. + +- [ ] **Step 5: Request-side audit — switch to `append_header` for consistency** + +Origin-bound request duplicate headers are rare, but for consistency and to remove the same class of latent bug, change the request-build loop in `build_fastly_request` (`proxy.rs:53`): + +```rust + fastly_request.set_header(name.as_str(), value.clone()); +``` + +to: + +```rust + fastly_request.append_header(name.as_str(), value.clone()); +``` + +Leave the explicit `Host` line (`proxy.rs:57`) as `set_header` — it is a single computed value that must replace, not append. Add a one-line comment above the loop: + +```rust + // Append (not set) so a multi-value client header survives; `Host` below is + // set explicitly as a single value. +``` + +- [ ] **Step 6: Run + lint** + +Run: `cargo test -p edgezero-adapter-fastly --features fastly --lib proxy 2>&1 | tail -8` +Expected: PASS. +Run: `cargo clippy -p edgezero-adapter-fastly --all-targets --features fastly -- -D warnings 2>&1 | tail -5` +Expected: clean. + +- [ ] **Step 7: Commit** + +```bash +git add crates/edgezero-adapter-fastly/src/proxy.rs +git commit -m "fix(fastly): preserve multi-value headers in proxy response/request conversion" +``` + +--- + +## Task 3: C2 — `Hooks::owns_logging()` + gate every adapter's logger init + +This task adds the trait method and wires it everywhere **atomically** — because adding a defaulted `Hooks` method breaks the macro-emitted impl under `missing_trait_methods` until the macro emits it. The `app!(owns_logging = …)` *argument* (grammar rework) is Task 4; here the macro emits a hardcoded `false`. + +**Files:** +- Modify: `crates/edgezero-core/src/app.rs` (add `owns_logging()` to `Hooks`, `app.rs:104-143`; add a trait test) +- Modify: `crates/edgezero-macros/src/app.rs` (emit `fn owns_logging() -> bool { false }` in the `Hooks` impl, `app.rs:165-183`) +- Modify: `crates/edgezero-adapter-fastly/src/lib.rs` (`run_app:117`, `run_app_with_config:205-208`) +- Modify: `crates/edgezero-adapter-cloudflare/src/lib.rs` (`run_app:105`) +- Modify: `crates/edgezero-adapter-axum/src/dev_server.rs` (`run_app:343`) +- Modify: `crates/edgezero-adapter-spin/src/lib.rs` (`run_app:115`) + +**Interfaces:** +- Produces: `Hooks::owns_logging() -> bool` (default `false`). Consumed by all four `run_app` entrypoints and by Task 4 (macro argument). + +- [ ] **Step 1: Write the failing trait test** + +Add to the `#[cfg(test)] mod tests` in `crates/edgezero-core/src/app.rs`, placed alphabetically among the existing test fns. `DefaultHooks` (defined in that module, `app.rs:163`) overrides only `routes`/`stores`, so it should report the default. + +```rust + #[test] + fn default_hooks_do_not_own_logging() { + assert!(!DefaultHooks::owns_logging()); + } +``` + +- [ ] **Step 2: Run it — verify it fails** + +Run: `cargo test -p edgezero-core --lib default_hooks_do_not_own_logging 2>&1 | tail -15` +Expected: FAIL — `no method named owns_logging found`. + +- [ ] **Step 3: Add `owns_logging()` to the `Hooks` trait** + +In `crates/edgezero-core/src/app.rs`, add to the `Hooks` trait (after `configure`, keeping methods in the existing order — insert where it reads best; the trait is not alphabetized, but place it adjacent to `configure`): + +```rust + /// When `true`, an adapter's `run_app` skips its own logger initialization; + /// the app is responsible for installing a `log` backend. Default `false`. + #[must_use] + #[inline] + fn owns_logging() -> bool { + false + } +``` + +- [ ] **Step 4: Emit `owns_logging` from the `app!` macro** + +In `crates/edgezero-macros/src/app.rs`, add to the emitted `impl edgezero_core::app::Hooks` block (`app.rs:165-183`) — after `configure`, mirroring the explicit-defaults pattern the file already documents at `app.rs:154-158`: + +```rust + fn configure(_app: &mut edgezero_core::app::App) {} + + fn owns_logging() -> bool { + false + } +``` + +Update the `missing_trait_methods` comment at `app.rs:154-158` to include `owns_logging` in the list of explicitly-emitted defaults: + +```rust + // The emitted `Hooks` impl below explicitly defines `configure`, + // `owns_logging`, and `build_app` even though their bodies mirror the trait + // defaults. This is required because `missing_trait_methods` (restriction = + // deny) forbids relying on trait defaults in the impl. If those Hooks + // defaults change, update these emitted bodies to match. +``` + +- [ ] **Step 5: Gate the four adapter logger-init sites** + +Each adapter's `run_app` (and Fastly's `run_app_with_config`) must skip its logger init when `A::owns_logging()`: + +**Fastly** — `crates/edgezero-adapter-fastly/src/lib.rs`, `run_app` (`lib.rs:117`): +```rust + if logging.use_fastly_logger && !A::owns_logging() { + let endpoint = logging.endpoint.as_deref().unwrap_or("stdout"); + init_logger(endpoint, logging.level, logging.echo_stdout)?; + } +``` +and the identical block in `run_app_with_config` (`lib.rs:205-208`) — same `&& !A::owns_logging()`. + +**Cloudflare** — `crates/edgezero-adapter-cloudflare/src/lib.rs`, `run_app` (`lib.rs:105`): +```rust + if !A::owns_logging() { + drop(init_logger()); + } +``` + +**Axum** — `crates/edgezero-adapter-axum/src/dev_server.rs`, `run_app` (`dev_server.rs:343`): +```rust + if !A::owns_logging() { + let _logger_init = SimpleLogger::new().with_level(level).init(); + } +``` + +**Spin** — `crates/edgezero-adapter-spin/src/lib.rs`, `run_app` (`lib.rs:115`) — Spin's `init_logger` is a no-op, but gate it so the neutral flag is honest everywhere: +```rust + if !A::owns_logging() { + drop(init_logger()); + } +``` + +- [ ] **Step 6: Run the trait test + workspace build** + +Run: `cargo test -p edgezero-core --lib default_hooks_do_not_own_logging 2>&1 | tail -8` +Expected: PASS. +Run: `cargo check --workspace --all-targets --features "fastly cloudflare spin" 2>&1 | tail -5` +Expected: succeeds (macro emission satisfies `missing_trait_methods`; all four adapters compile). + +- [ ] **Step 7: Lint + WASM checks** + +Run: `cargo clippy --workspace --all-targets --all-features -- -D warnings 2>&1 | tail -5` +Expected: clean (the two `Hooks` test stubs already carry `#[expect(clippy::missing_trait_methods)]`, so the new defaulted method needs no change there). +Run: `cargo check -p edgezero-adapter-spin --target wasm32-wasip2 --features spin 2>&1 | tail -3` +Expected: succeeds. + +- [ ] **Step 8: Commit** + +```bash +git add crates/edgezero-core/src/app.rs crates/edgezero-macros/src/app.rs \ + crates/edgezero-adapter-fastly/src/lib.rs \ + crates/edgezero-adapter-cloudflare/src/lib.rs \ + crates/edgezero-adapter-axum/src/dev_server.rs \ + crates/edgezero-adapter-spin/src/lib.rs +git commit -m "feat: Hooks::owns_logging() opt-out gated in all four adapter run_app entrypoints" +``` + +--- + +## Task 4: C2 — `app!(owns_logging = )` argument (keyword-arg grammar) + +Rework `AppArgs` from "path + optional ident" into the keyword-argument grammar the spec defines (§`app! argument grammar`), implementing the `owns_logging` key. This is the **shared grammar framework** the P0-D `state` key extends. + +**Files:** +- Modify: `crates/edgezero-macros/src/app.rs` (`AppArgs` struct + `impl Parse`, `app.rs:12-31`; emit `fn owns_logging() -> bool { #bool }`, `app.rs:170`; add `AppArgs` unit tests to the `#[cfg(test)] mod tests`) + +**Interfaces:** +- Consumes: `syn::{LitStr, Ident, LitBool, Token}`, `syn::parse::{Parse, ParseStream}`. +- Produces: `struct AppArgs { path: LitStr, app_ident: Option, owns_logging: Option }` with a keyword-arg parser. **P0-D adds `state: Option` to this same struct and parser.** + +- [ ] **Step 1: Write the failing `AppArgs` unit tests** + +Add a focused unit-test module for the parser. Put it in the existing `#[cfg(test)] mod tests` in `crates/edgezero-macros/src/app.rs` (which currently imports only `parse_handler_path`). Add `use super::AppArgs;` and `use syn::parse_str;`. Tests (place alphabetically among the existing `parse_handler_path_*` fns): + +```rust + #[test] + fn app_args_parses_path_only() { + let args: AppArgs = parse_str(r#""edgezero.toml""#).expect("parse"); + assert_eq!(args.path.value(), "edgezero.toml"); + assert!(args.app_ident.is_none()); + assert_eq!(args.owns_logging, None); + } + + #[test] + fn app_args_parses_path_and_app_ident() { + let args: AppArgs = parse_str(r#""edgezero.toml", MyApp"#).expect("parse"); + assert_eq!(args.app_ident.map(|ident| ident.to_string()), Some("MyApp".to_owned())); + assert_eq!(args.owns_logging, None); + } + + #[test] + fn app_args_parses_owns_logging_true() { + let args: AppArgs = parse_str(r#""edgezero.toml", owns_logging = true"#).expect("parse"); + assert_eq!(args.owns_logging, Some(true)); + assert!(args.app_ident.is_none()); + } + + #[test] + fn app_args_parses_app_ident_then_keyword() { + let args: AppArgs = + parse_str(r#""edgezero.toml", MyApp, owns_logging = false"#).expect("parse"); + assert_eq!(args.app_ident.map(|ident| ident.to_string()), Some("MyApp".to_owned())); + assert_eq!(args.owns_logging, Some(false)); + } + + #[test] + fn app_args_rejects_unknown_key() { + let err = parse_str::(r#""edgezero.toml", bogus = true"#).expect_err("unknown key"); + assert!(err.to_string().contains("unknown `app!` argument `bogus`"), "got: {err}"); + } + + #[test] + fn app_args_rejects_duplicate_key() { + let err = parse_str::(r#""edgezero.toml", owns_logging = true, owns_logging = false"#) + .expect_err("duplicate"); + assert!(err.to_string().contains("duplicate `owns_logging`"), "got: {err}"); + } + + #[test] + fn app_args_rejects_ident_after_keyword() { + let err = parse_str::(r#""edgezero.toml", owns_logging = true, MyApp"#) + .expect_err("ident after keyword"); + assert!( + err.to_string().contains("must come immediately after the manifest path"), + "got: {err}" + ); + } +``` + +- [ ] **Step 2: Run — verify they fail** + +Run: `cargo test -p edgezero-macros --lib app_args_ 2>&1 | tail -20` +Expected: FAIL — the current `AppArgs` has no `owns_logging` field and rejects `owns_logging = true` with "unexpected tokens". + +- [ ] **Step 3: Rework `AppArgs` + `impl Parse`** + +Replace `crates/edgezero-macros/src/app.rs:12-31` with: + +```rust +struct AppArgs { + app_ident: Option, + owns_logging: Option, + path: LitStr, +} + +impl Parse for AppArgs { + fn parse(input: ParseStream) -> syn::Result { + let path: LitStr = input.parse()?; + let mut app_ident: Option = None; + let mut owns_logging: Option = None; + let mut seen_keyword = false; + + while input.peek(Token![,]) { + input.parse::()?; + + // Keyword argument: `Ident = Value`. + if input.peek(Ident) && input.peek2(Token![=]) { + let key: Ident = input.parse()?; + input.parse::()?; + seen_keyword = true; + match key.to_string().as_str() { + "owns_logging" => { + if owns_logging.is_some() { + return Err(syn::Error::new(key.span(), "duplicate `owns_logging` argument")); + } + let value: syn::LitBool = input.parse()?; + owns_logging = Some(value.value); + } + other => { + return Err(syn::Error::new( + key.span(), + format!("unknown `app!` argument `{other}`; expected `owns_logging`"), + )); + } + } + continue; + } + + // Bare identifier: the optional custom App type name, only before keywords. + if input.peek(Ident) { + if seen_keyword || app_ident.is_some() { + return Err(input.error( + "the custom App identifier must come immediately after the manifest path, before keyword arguments", + )); + } + app_ident = Some(input.parse::()?); + continue; + } + + return Err(input.error("expected a custom App identifier or `key = value` argument")); + } + + if !input.is_empty() { + return Err(input.error("unexpected tokens after app! macro arguments")); + } + Ok(Self { app_ident, owns_logging, path }) + } +} +``` + +Add `LitBool` isn't needed as an import (used as `syn::LitBool`); ensure `Ident`, `LitStr`, `Token`, `Parse`, `ParseStream` are already imported at the top of the file (they are — `use syn::{... Ident, LitStr, Token};` and `use syn::parse::{Parse, ParseStream};`). + +> **Note for P0-D:** the `match key.to_string()` arm is the extension point — P0-D adds a `"state" => { … }` arm and a `state: Option` field. The "expected `owns_logging`" message becomes "expected `state` or `owns_logging`" then. + +- [ ] **Step 4: Emit the parsed `owns_logging` value** + +In `expand_app`, before the `quote!` block, compute the bool literal (default `false`). Near the other `let …_lit` bindings (around `app.rs:130-152`): + +```rust + let owns_logging_lit = args.owns_logging.unwrap_or(false); +``` + +Change the emitted `fn owns_logging()` (added in Task 3, currently `{ false }`) to use it: + +```rust + fn owns_logging() -> bool { + #owns_logging_lit + } +``` + +(`bool` implements `quote::ToTokens`, so `#owns_logging_lit` emits `true`/`false`.) + +- [ ] **Step 5: Run the unit tests — verify they pass** + +Run: `cargo test -p edgezero-macros --lib app_args_ 2>&1 | tail -12` +Expected: PASS — all seven `app_args_*` tests. + +- [ ] **Step 6: Verify app-demo (real `app!`) still builds and emits owns_logging=false** + +app-demo's `app!("../../edgezero.toml")` (`examples/app-demo/crates/app-demo-core/src/lib.rs:11`) uses no keyword args, so its generated `owns_logging()` returns `false`. + +Run: `(cd examples/app-demo && cargo test -p app-demo-core 2>&1 | tail -5)` +Expected: PASS. + +Optional integration assertion (if you want a real-`app!` check beyond app-demo): the macros crate has no `app!` integration harness today; the `AppArgs` unit tests above are the deterministic coverage. Do not add a process-global logger test. + +- [ ] **Step 7: Lint + commit** + +Run: `cargo clippy -p edgezero-macros --all-targets --all-features -- -D warnings 2>&1 | tail -5` +Expected: clean. + +```bash +git add crates/edgezero-macros/src/app.rs +git commit -m "feat(macros): app!(owns_logging = ) keyword argument + AppArgs keyword grammar" +``` + +--- + +## Task 5: C3 — pre-dispatch raw-request hook (`run_app_with_request_extensions`) + +Add a Fastly `run_app` variant taking an app closure that reads the raw `fastly::Request` (JA4 / H2 / etc.) into a scratch `Extensions` **before** `into_core_request` consumes the request; the scratch bag is `extend`ed into the core request after conversion. `run_app` becomes the no-hook wrapper. + +**Files:** +- Modify: `crates/edgezero-adapter-fastly/src/request.rs` (thread the closure through `dispatch_with_registries`/`dispatch_with_handles`; scratch-`extend` around `into_core_request` at `request.rs:284`; add a host unit test) +- Modify: `crates/edgezero-adapter-fastly/src/lib.rs` (`run_app_with_request_extensions`; `run_app` delegates with a no-op) + +**Interfaces:** +- Consumes: `into_core_request(req: FastlyRequest) -> Result` (`request.rs:445`, consumes `req` by value); `edgezero_core::http::Extensions`. +- Produces: + - `request.rs`: `dispatch_with_registries(app, req, config_meta, kv_meta, secret_meta, env, extend: F) where F: FnOnce(&FastlyRequest, &mut Extensions)` — an added final `extend` parameter; `dispatch_with_handles` likewise. + - `lib.rs`: `pub fn run_app_with_request_extensions(req: fastly::Request, extend: F) -> Result where A: Hooks, F: FnOnce(&fastly::Request, &mut Extensions)`. + +- [ ] **Step 1: Write the failing host unit test (the scratch-bag mechanism)** + +The full end-to-end (closure value reaching a handler) requires `into_core_request`, which calls the `get_client_ip_addr()` **hostcall** — so it is Viceroy/wasm-only and lives in `contract.rs` (out of scope here). Host-test the closure/scratch-bag plumbing directly via a small extracted helper. + +Add to the `#[cfg(test)] mod synthesis_tests` in `crates/edgezero-adapter-fastly/src/request.rs` (host-side, runs under `--features fastly`). It builds a `FastlyRequest` host-side (`FastlyRequest::new` is not a hostcall) and asserts the closure populated the scratch bag: + +```rust + #[test] + fn apply_request_extend_populates_scratch_from_raw_request() { + use edgezero_core::http::Extensions; + + #[derive(Clone, Debug, PartialEq)] + struct Ja4(String); + + let raw = FastlyRequest::new(fastly::http::Method::GET, "http://example.test/"); + let scratch = apply_request_extend(&raw, |req, extensions| { + // A real closure would call req.get_tls_ja4(); here we derive from a + // host-safe signal (the URL) to avoid a hostcall in the unit test. + let marker = req.get_url_str().to_owned(); + extensions.insert(Ja4(marker)); + }); + + assert_eq!( + scratch.get::(), + Some(&Ja4("http://example.test/".to_owned())) + ); + } +``` + +- [ ] **Step 2: Run — verify it fails** + +Run: `cargo test -p edgezero-adapter-fastly --features fastly --lib apply_request_extend 2>&1 | tail -15` +Expected: FAIL — `apply_request_extend` does not exist. + +- [ ] **Step 3: Add the `apply_request_extend` helper + thread the closure through dispatch** + +In `crates/edgezero-adapter-fastly/src/request.rs`, add the helper near `dispatch_with_handles` (import `Extensions`: add `Extensions` to the existing `use edgezero_core::http::{request_builder, Request};` line at `request.rs:11`): + +```rust +/// Run an app-provided closure against a scratch `Extensions` populated from the +/// RAW `fastly::Request` (JA4 / H2 / etc.), BEFORE `into_core_request` consumes +/// the request. Returns the scratch bag to be `extend`ed into the core request. +fn apply_request_extend(req: &FastlyRequest, extend: F) -> Extensions +where + F: FnOnce(&FastlyRequest, &mut Extensions), +{ + let mut scratch = Extensions::default(); + extend(req, &mut scratch); + scratch +} +``` + +Change `dispatch_with_handles` (`request.rs:279-286`) to take the closure and merge the scratch bag after conversion: + +```rust +fn dispatch_with_handles( + app: &App, + req: FastlyRequest, + stores: Stores, + extend: F, +) -> Result +where + F: FnOnce(&FastlyRequest, &mut Extensions), +{ + // Read raw-request signals into a scratch bag BEFORE conversion consumes `req`. + let scratch = apply_request_extend(&req, extend); + let mut core_request = into_core_request(req).map_err(|err| map_edge_error(&err))?; + core_request.extensions_mut().extend(scratch); + dispatch_core_request(app, core_request, stores) +} +``` + +(`dispatch_core_request` is unchanged; it already takes `mut core_request` and inserts the registries + runs the router.) + +Change `dispatch_with_registries` (`request.rs:288-316`) to take and forward the closure: + +```rust +pub(crate) fn dispatch_with_registries( + app: &App, + req: FastlyRequest, + config_meta: Option, + kv_meta: Option, + secret_meta: Option, + env: &EnvConfig, + extend: F, +) -> Result +where + F: FnOnce(&FastlyRequest, &mut Extensions), +{ + let kv_registry = build_kv_registry(kv_meta, env)?; + let config_registry = build_config_registry(config_meta, env); + let secret_registry = build_secret_registry(secret_meta, env); + dispatch_with_handles( + app, + req, + Stores { + config_registry, + kv_registry, + secret_registry, + ..Default::default() + }, + extend, + ) +} +``` + +- [ ] **Step 4: Add `run_app_with_request_extensions` + make `run_app` delegate** + +In `crates/edgezero-adapter-fastly/src/lib.rs`, replace the `run_app` body's `dispatch_with_registries(...)` call (`lib.rs:122`) so `run_app` delegates to the new variant with a no-op closure, and add the new public fn. Import `Extensions`: add `use edgezero_core::http::Extensions;` near the other imports (guarded under the same `#[cfg(feature = "fastly")]` scope as `run_app`). + +```rust +#[cfg(feature = "fastly")] +#[inline] +pub fn run_app(req: fastly::Request) -> Result { + run_app_with_request_extensions::(req, |_req, _extensions| {}) +} + +/// Like [`run_app`], but runs `extend` against a scratch [`Extensions`] populated +/// from the raw `fastly::Request` (TLS JA4, H2 fingerprint, client IP, …) before +/// the request is converted; the scratch values are merged into the core +/// request's extensions and are visible to middleware and the `State`/extractor +/// layer. +#[cfg(feature = "fastly")] +#[inline] +pub fn run_app_with_request_extensions( + req: fastly::Request, + extend: F, +) -> Result +where + A: Hooks, + F: FnOnce(&fastly::Request, &mut Extensions), +{ + let stores = A::stores(); + let env = env_config_from_runtime_dictionary(stores); + let logging = logging_from_env(&env); + if logging.use_fastly_logger && !A::owns_logging() { + let endpoint = logging.endpoint.as_deref().unwrap_or("stdout"); + init_logger(endpoint, logging.level, logging.echo_stdout)?; + } + let app = A::build_app(); + request::dispatch_with_registries( + &app, + req, + stores.config, + stores.kv, + stores.secrets, + &env, + extend, + ) +} +``` + +(The logger gate here is the Task-3 `&& !A::owns_logging()`. `run_app_with_config` at `lib.rs:200` also calls `dispatch_with_registries` — update that call to pass a no-op closure `|_req, _extensions| {}` as the final arg so it compiles.) + +- [ ] **Step 5: Run the host test + build** + +Run: `cargo test -p edgezero-adapter-fastly --features fastly --lib apply_request_extend 2>&1 | tail -8` +Expected: PASS. +Run: `cargo test -p edgezero-adapter-fastly --features fastly --lib 2>&1 | tail -8` +Expected: all host unit tests PASS (existing `synthesis_tests`, response, proxy, lib). +Run: `cargo check --workspace --all-targets --features "fastly cloudflare spin" 2>&1 | tail -5` +Expected: succeeds (the `dispatch_with_registries` signature change is internal to the fastly crate; only `run_app`/`run_app_with_config` call it). + +- [ ] **Step 6: Lint + commit** + +Run: `cargo clippy -p edgezero-adapter-fastly --all-targets --features fastly -- -D warnings 2>&1 | tail -5` +Expected: clean. + +```bash +git add crates/edgezero-adapter-fastly/src/request.rs crates/edgezero-adapter-fastly/src/lib.rs +git commit -m "feat(fastly): run_app_with_request_extensions pre-dispatch hook for raw-request signals" +``` + +--- + +## Final verification (all P0-C tasks) + +- [ ] **Run every CI gate:** + +```bash +cargo fmt --all -- --check +cargo clippy --workspace --all-targets --all-features -- -D warnings +cargo test --workspace --all-targets +cargo test -p edgezero-adapter-fastly --features fastly # host unit tests for the gated modules +cargo check --workspace --all-targets --features "fastly cloudflare spin" +cargo check -p edgezero-adapter-spin --target wasm32-wasip2 --features spin +(cd examples/app-demo && cargo test) +``` + +Expected: all green. Note: `crates/edgezero-adapter-fastly/tests/contract.rs` (the Viceroy/wasm end-to-end incl. the C3 closure-reaches-handler path and full header round-trips) is `#![cfg(all(feature = "fastly", target_arch = "wasm32"))]` and is **not** run by the above; if a Viceroy toolchain is available, run it separately with `--features fastly --target wasm32-wasip1`. + +## Acceptance criteria (spec §P0-C acceptance) + +1. Multi-value `Set-Cookie` round-trips through `from_core_response` (Task 1) and `convert_response` (Task 2). +2. `Hooks::owns_logging()` gates logger init in all four adapters (Task 3); `app!(owns_logging = true)` emits `owns_logging() -> bool { true }` (Task 4). +3. A pre-dispatch closure populates a scratch `Extensions` from the raw `fastly::Request` and it is merged into the core request (Task 5); `run_app` (no-hook) behavior is unchanged. +4. `cargo fmt`/clippy clean; workspace + app-demo tests green; WASM checks pass. + +## Self-review notes (spec coverage) + +- C1 §26-51 (response append; proxy request + response) → Tasks 1, 2. +- C2 §53-91 (`Hooks::owns_logging()` neutral across 4 adapters + `missing_trait_methods` emission + macro arg) → Tasks 3, 4. +- C3 §93-125 (scratch-`Extensions`-before-conversion, `edgezero_core::http::Extensions`, thread through dispatch) → Task 5. +- Test target/feature clarity (spec review): every test step names `--features fastly` for host coverage and flags `contract.rs` as wasm/Viceroy-only; C2 uses deterministic `AppArgs` grammar tests + the macro-emission check, not a process-global logger test. diff --git a/docs/superpowers/plans/2026-07-04-edgezero-p0d-app-macro-state.md b/docs/superpowers/plans/2026-07-04-edgezero-p0d-app-macro-state.md new file mode 100644 index 00000000..e2699456 --- /dev/null +++ b/docs/superpowers/plans/2026-07-04-edgezero-p0d-app-macro-state.md @@ -0,0 +1,335 @@ +# EdgeZero P0-D — `app!` App-State Injection 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:** Let a fully-macro app provide app-owned shared state (`Arc`) to `#[action]` handlers via `app!("edgezero.toml", state = )`, so `State` works without a hand-written `Hooks::routes()`. + +**Architecture:** The router already owns per-request state injection (PR #306: `RouterBuilder::with_state` + a `state_extensions: Extensions` bag that dispatch `extend`s into every request). So P0-D is macro-only: the `app!`-generated `build_router()` calls `.with_state()` — one builder call next to its existing `.with_manifest_json(...)`. No adapter change, no new `Hooks` method, no state carrier. + +**Tech Stack:** Rust 1.95, edition 2021. `edgezero-macros` (`app!`), `examples/app-demo` (worked example). Reuses `edgezero-core` `RouterBuilder::with_state` / `State` unchanged. + +**Source spec:** `docs/superpowers/specs/2026-07-03-edgezero-p0cd-fastly-dispatch-and-appstate-design.md` (P0-D), verified against `65afbd3`. + +## Global Constraints + +- **Rust 1.95.0**, edition 2021. Strict clippy gate (`restriction = deny`): watch `arbitrary_source_item_ordering` (order struct fields / test fns), `min_ident_chars`, `absolute_paths`, `impl_trait_in_params`, `assertions_on_result_states`, `needless_raw_strings`, `missing_trait_methods`. +- **DEPENDS ON P0-C.** This plan **extends the `app!` `AppArgs` keyword-argument grammar** introduced by the P0-C plan (`2026-07-04-edgezero-p0c-fastly-dispatch-fidelity.md`, Task 4). Execute P0-C first. After P0-C, `AppArgs` is `struct AppArgs { app_ident: Option, owns_logging: Option, path: LitStr }` with a keyword-arg parser whose `match key.to_string()` has an `owns_logging` arm and an `_ => unknown key` arm; Task 1 below adds a `state` field + arm. If P0-C has not landed, do it first — do not reintroduce the grammar here. +- **Reused, unchanged API** (from PR #306, `crates/edgezero-core/src/router.rs`): `RouterBuilder::with_state(self, value: T) -> Self where T: Clone + Send + Sync + 'static`; the router injects `state_extensions` into every request at dispatch. `State` (`crates/edgezero-core/src/extractor.rs`) extracts it; an unregistered `T` → `500`. +- **CI gates (all must pass):** `cargo fmt --all -- --check`; `cargo clippy --workspace --all-targets --all-features -- -D warnings`; `cargo test --workspace --all-targets`; `cargo check --workspace --all-targets --features "fastly cloudflare spin"`; `cargo check -p edgezero-adapter-spin --target wasm32-wasip2 --features spin`; and `(cd examples/app-demo && cargo test)`. +- **No backward-compat constraint.** `state` is optional; omitting it leaves `build_router()` byte-identical to today. + +--- + +## File Structure + +| File | Responsibility | Task | +| ---- | -------------- | ---- | +| `crates/edgezero-macros/src/app.rs` | `AppArgs` gains `state: Option`; parser adds the `state` arm; `build_router()` emits `.with_state(#state)`; `AppArgs` unit tests | 1 | +| `examples/app-demo/crates/app-demo-core/src/state.rs` (new) | `DemoState` + `app_state()` constructor | 2 | +| `examples/app-demo/crates/app-demo-core/src/handlers.rs` | a `State>` `#[action]` handler + host test through `build_router()` | 2 | +| `examples/app-demo/crates/app-demo-core/src/lib.rs` | `app!(…, state = crate::app_state())`; `pub mod state;` | 2 | +| `examples/app-demo/edgezero.toml` | route for the state-demo handler | 2 | + +--- + +## Task 1: `app!(state = )` — parse + emit `.with_state(...)` + +**Files:** +- Modify: `crates/edgezero-macros/src/app.rs` (`AppArgs` struct + parser `state` arm; `expand_app` emits `.with_state(#state_expr)` in `build_router()`, `app.rs:185-191`; add `AppArgs` state unit tests) + +**Interfaces:** +- Consumes (from P0-C): the `AppArgs` keyword-arg parser with its `match key.to_string()` dispatch. +- Produces: `AppArgs.state: Option`; `build_router()` emits `builder = builder.with_state(#state_expr);` when `state` is present. + +- [ ] **Step 1: Write the failing `AppArgs` state unit tests** + +Add to the `#[cfg(test)] mod tests` in `crates/edgezero-macros/src/app.rs` (which after P0-C already has `use super::AppArgs;`, `use syn::parse_str;`, and the `app_args_*` tests). Place alphabetically among the `app_args_*` fns. To assert the parsed expression, render it with `quote`: + +```rust + #[test] + fn app_args_parses_state_expr() { + let args: AppArgs = parse_str(r#""edgezero.toml", state = crate::app_state()"#).expect("parse"); + let rendered = args.state.map(|expr| quote::quote!(#expr).to_string()); + assert_eq!(rendered, Some("crate :: app_state ()".to_owned())); + assert!(args.app_ident.is_none()); + assert_eq!(args.owns_logging, None); + } + + #[test] + fn app_args_parses_state_with_app_ident_and_owns_logging() { + let args: AppArgs = parse_str( + r#""edgezero.toml", MyApp, state = crate::app_state(), owns_logging = true"#, + ) + .expect("parse"); + assert_eq!(args.app_ident.map(|ident| ident.to_string()), Some("MyApp".to_owned())); + assert_eq!(args.owns_logging, Some(true)); + assert!(args.state.is_some()); + } + + #[test] + fn app_args_rejects_duplicate_state() { + let err = parse_str::(r#""edgezero.toml", state = a(), state = b()"#) + .expect_err("duplicate state"); + assert!(err.to_string().contains("duplicate `state`"), "got: {err}"); + } +``` + +- [ ] **Step 2: Run — verify they fail** + +Run: `cargo test -p edgezero-macros --lib app_args_parses_state 2>&1 | tail -20` +Expected: FAIL — `AppArgs` has no `state` field, and `state = …` hits the unknown-key arm ("unknown `app!` argument `state`"). + +- [ ] **Step 3: Add the `state` field + parser arm** + +In `crates/edgezero-macros/src/app.rs`, add `state` to the `AppArgs` struct (alphabetical field order: `app_ident`, `owns_logging`, `path`, `state`): + +```rust +struct AppArgs { + app_ident: Option, + owns_logging: Option, + path: LitStr, + state: Option, +} +``` + +In `impl Parse`, add a `state` local (`let mut state: Option = None;` next to the `owns_logging` local), add the `state` arm to the `match key.to_string().as_str()`, and include `state` in the returned `Self`: + +```rust + "state" => { + if state.is_some() { + return Err(syn::Error::new(key.span(), "duplicate `state` argument")); + } + state = Some(input.parse::()?); + } + "owns_logging" => { + if owns_logging.is_some() { + return Err(syn::Error::new(key.span(), "duplicate `owns_logging` argument")); + } + let value: syn::LitBool = input.parse()?; + owns_logging = Some(value.value); + } + other => { + return Err(syn::Error::new( + key.span(), + format!("unknown `app!` argument `{other}`; expected `state` or `owns_logging`"), + )); + } +``` + +and the constructor: + +```rust + Ok(Self { app_ident, owns_logging, path, state }) +``` + +- [ ] **Step 4: Emit `.with_state(...)` in `build_router()`** + +In `expand_app`, before the `quote!` block, turn the optional state expression into optional emitted tokens (place near the other `let …` bindings). Use `Option<&Expr>` mapped to a `TokenStream2` so the call is emitted only when present: + +```rust + let state_call = args.state.as_ref().map(|state_expr| { + quote! { builder = builder.with_state(#state_expr); } + }); +``` + +Then insert `#state_call` into the emitted `build_router()` (`app.rs:185-191`), right after the `with_manifest_json` line: + +```rust + pub fn build_router() -> edgezero_core::router::RouterService { + let mut builder = edgezero_core::router::RouterService::builder(); + builder = builder.with_manifest_json(#manifest_json_lit); + #state_call + #(#middleware_tokens)* + #(#route_tokens)* + builder.build() + } +``` + +(`Option` implements `ToTokens` — `None` emits nothing, so omitting `state` leaves `build_router()` unchanged.) + +- [ ] **Step 5: Run the unit tests — verify they pass** + +Run: `cargo test -p edgezero-macros --lib app_args_ 2>&1 | tail -12` +Expected: PASS — the new `app_args_parses_state*` / `app_args_rejects_duplicate_state` plus the P0-C `app_args_*` tests. + +- [ ] **Step 6: Confirm app-demo (no `state`) is unchanged + lint** + +Run: `(cd examples/app-demo && cargo test -p app-demo-core 2>&1 | tail -5)` +Expected: PASS (app-demo's `app!` has no `state` yet, so `build_router()` is byte-identical). +Run: `cargo clippy -p edgezero-macros --all-targets --all-features -- -D warnings 2>&1 | tail -5` +Expected: clean. + +- [ ] **Step 7: Commit** + +```bash +git add crates/edgezero-macros/src/app.rs +git commit -m "feat(macros): app!(state = ) emits RouterBuilder::with_state for macro apps" +``` + +--- + +## Task 2: app-demo worked example + end-to-end `State` test + +Prove the whole chain: `app!(state = crate::app_state())` → generated `build_router()` calls `.with_state(...)` → dispatch injects → a `State>` handler reads it. This runs **host-side** through `build_router()` (no adapter/hostcalls), mirroring the existing `crate::build_router()` test at `handlers.rs:436`. + +**Files:** +- Create: `examples/app-demo/crates/app-demo-core/src/state.rs` +- Modify: `examples/app-demo/crates/app-demo-core/src/lib.rs` (add `pub mod state;`, add `state = crate::app_state()` to `app!`) +- Modify: `examples/app-demo/crates/app-demo-core/src/handlers.rs` (add the `State` handler + host test) +- Modify: `examples/app-demo/edgezero.toml` (route for the handler) + +**Interfaces:** +- Consumes: `edgezero_core::extractor::State`, `edgezero_core::action`, `RouterService::oneshot` (from #306), `crate::build_router()` (macro-generated). +- Produces: `crate::state::{DemoState, app_state}`; `crate::handlers::state_demo` handler. + +- [ ] **Step 1: Create the state module** + +Create `examples/app-demo/crates/app-demo-core/src/state.rs`: + +```rust +//! App-owned shared state for the `app!(..., state = ...)` demonstration. + +use std::sync::Arc; + +/// Minimal app-owned state handed to handlers via `State>`. +#[derive(Debug)] +pub struct DemoState { + /// A greeting the handler echoes, proving the value reached the handler. + pub greeting: String, +} + +/// Constructs the shared app state. Referenced by `app!(..., state = crate::app_state())`. +#[must_use] +pub fn app_state() -> Arc { + Arc::new(DemoState { + greeting: "hello from app state".to_owned(), + }) +} +``` + +- [ ] **Step 2: Wire the module + `app!` state argument** + +In `examples/app-demo/crates/app-demo-core/src/lib.rs`: add `pub mod state;` alongside the other `pub mod` declarations (alphabetical position), and change the `app!` invocation (`lib.rs:11`): + +```rust +edgezero_core::app!("../../edgezero.toml", state = crate::app_state()); +``` + +Add a re-export so `crate::app_state()` resolves at the crate root where the macro emits it (the macro emits the call inside `build_router()` in this crate, so `crate::app_state` must be reachable). Re-export from the state module: + +```rust +pub use crate::state::app_state; +``` + +- [ ] **Step 3: Add the `State` handler** + +In `examples/app-demo/crates/app-demo-core/src/handlers.rs`, add a handler (place its `fn` in the correct position per `arbitrary_source_item_ordering`; import what it needs — mirror the existing handlers' imports of `edgezero_core::{action, ...}`): + +```rust +use edgezero_core::extractor::State; +use std::sync::Arc; + +#[edgezero_core::action] +pub async fn state_demo( + State(state): State>, +) -> Result { + Ok(state.greeting.clone()) +} +``` + +(If `handlers.rs` already imports `Arc` / an `action` alias, reuse those rather than re-importing — keep the file's existing style.) + +- [ ] **Step 4: Register the route in the manifest** + +In `examples/app-demo/edgezero.toml`, add an HTTP trigger for the handler, matching the exact key layout of the existing `[[triggers.http]]` entries in that file (read one first and copy its shape). It will look like: + +```toml +[[triggers.http]] +path = "/state-demo" +handler = "crate::handlers::state_demo" +method = "GET" +``` + +(Use whatever `method`/`handler`/`path` keys the file's existing entries use — the point is a GET route bound to `crate::handlers::state_demo`.) + +- [ ] **Step 5: Write the end-to-end host test** + +Add to the `#[cfg(test)] mod tests` in `examples/app-demo/crates/app-demo-core/src/handlers.rs` (the module that already contains the `crate::build_router()` test at `handlers.rs:436`; reuse its imports for `request_builder`/`Body`/`block_on` — mirror that test). Place the fn alphabetically. + +```rust + #[test] + fn state_demo_handler_reads_app_state_through_macro_router() { + use edgezero_core::body::Body; + use edgezero_core::http::{request_builder, Method, StatusCode}; + use futures::executor::block_on; + + // build_router() is macro-generated and now calls `.with_state(crate::app_state())`. + let service = crate::build_router(); + + let request = request_builder() + .method(Method::GET) + .uri("/state-demo") + .body(Body::empty()) + .expect("request"); + + let response = block_on(service.oneshot(request)).expect("response"); + assert_eq!(response.status(), StatusCode::OK); + assert_eq!( + response.body().as_bytes().expect("buffered"), + b"hello from app state" + ); + } +``` + +- [ ] **Step 6: Run the e2e test** + +Run: `(cd examples/app-demo && cargo test -p app-demo-core state_demo_handler_reads_app_state 2>&1 | tail -12)` +Expected: PASS — the macro-generated router injected `Arc`, the `State` extractor resolved it, and the handler echoed `greeting`. (First run may FAIL if the route/handler/state wiring is incomplete; fix until green.) + +- [ ] **Step 7: Full app-demo + workspace verification** + +Run: `(cd examples/app-demo && cargo test 2>&1 | tail -8)` +Expected: PASS (all four adapter example crates still build; app-demo-core tests green). +Run: `cargo test -p edgezero-macros 2>&1 | tail -5 && cargo clippy --workspace --all-targets --all-features -- -D warnings 2>&1 | tail -3` +Expected: macros tests green; clippy clean (including the app-demo workspace — the new handler's fields are read by the assertion, so no `dead_code` suppression is needed). + +- [ ] **Step 8: Commit** + +```bash +git add examples/app-demo/crates/app-demo-core/src/state.rs \ + examples/app-demo/crates/app-demo-core/src/lib.rs \ + examples/app-demo/crates/app-demo-core/src/handlers.rs \ + examples/app-demo/edgezero.toml +git commit -m "docs(app-demo): app!(state = ...) + State handler example with end-to-end test" +``` + +--- + +## Final verification (all P0-D tasks) + +- [ ] **Run every CI gate:** + +```bash +cargo fmt --all -- --check +cargo clippy --workspace --all-targets --all-features -- -D warnings +cargo test --workspace --all-targets +cargo check --workspace --all-targets --features "fastly cloudflare spin" +cargo check -p edgezero-adapter-spin --target wasm32-wasip2 --features spin +(cd examples/app-demo && cargo test) +``` + +Expected: all green. + +## Acceptance criteria (spec §P0-D acceptance) + +1. A macro app declaring `app!("...", state = )` can extract `State` (where `T` is the expression's value type) in an `#[action]` handler — proven host-side via app-demo's macro-generated `build_router()` (Task 2). Because the router's dispatch injection is platform-neutral, this holds on all four adapters with no adapter change. +2. An app that provides no `state` is unaffected — `build_router()` is byte-identical without the argument (Task 1 Step 6); `State` for an unregistered `T` still returns the existing 500. +3. `app-demo` gains a small `app!(..., state = ...)` + `State` handler example (Task 2). +4. **No `edgezero-core` `Hooks`/adapter change** — the diff is confined to `edgezero-macros` + the `app-demo` example (the acceptance signal that the simpler, router-reusing design was taken). + +## Self-review notes (spec coverage + type consistency) + +- §P0-D "The gap" / "Design (revised)" → Task 1 (macro-only `.with_state` emission, reusing #306's `state_extensions`). +- §`app!` argument grammar (`state = ` as `syn::Expr`, emitted verbatim; duplicate/unknown-key errors; coexistence with app ident + `owns_logging`) → Task 1, extending P0-C's parser. Field/arm names match P0-C's `AppArgs` (`app_ident`, `owns_logging`, `path`, `state`). +- §P0-D acceptance (macro app extracts `State`; no-state unaffected; app-demo example; no adapter/Hooks change) → Task 2 + the "no adapter change" acceptance line. +- `state = ` is an expression emitted verbatim (`state = crate::app_state()`, not a bare fn path) — consistent with Task 1's parser (`syn::Expr`) and the emitted `.with_state(#state_expr)`. From 2174a542b3c0b1495f2dfc241f3c8a94373f26be Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Sat, 4 Jul 2026 19:21:28 -0700 Subject: [PATCH 53/74] docs(p0c plan): cover FastlyService::dispatch caller; add app! owns_logging macro-emission integration test --- ...4-edgezero-p0c-fastly-dispatch-fidelity.md | 76 ++++++++++++++++--- 1 file changed, 66 insertions(+), 10 deletions(-) diff --git a/docs/superpowers/plans/2026-07-04-edgezero-p0c-fastly-dispatch-fidelity.md b/docs/superpowers/plans/2026-07-04-edgezero-p0c-fastly-dispatch-fidelity.md index dd54b3fd..5c468758 100644 --- a/docs/superpowers/plans/2026-07-04-edgezero-p0c-fastly-dispatch-fidelity.md +++ b/docs/superpowers/plans/2026-07-04-edgezero-p0c-fastly-dispatch-fidelity.md @@ -370,6 +370,8 @@ Rework `AppArgs` from "path + optional ident" into the keyword-argument grammar **Files:** - Modify: `crates/edgezero-macros/src/app.rs` (`AppArgs` struct + `impl Parse`, `app.rs:12-31`; emit `fn owns_logging() -> bool { #bool }`, `app.rs:170`; add `AppArgs` unit tests to the `#[cfg(test)] mod tests`) +- Create: `crates/edgezero-macros/tests/app_macro.rs` (real-`app!` integration test asserting the emitted `owns_logging()`) +- Create: `crates/edgezero-macros/tests/fixtures/owns_logging.toml` (minimal fixture manifest) **Interfaces:** - Consumes: `syn::{LitStr, Ident, LitBool, Token}`, `syn::parse::{Parse, ParseStream}`. @@ -532,22 +534,53 @@ Change the emitted `fn owns_logging()` (added in Task 3, currently `{ false }`) Run: `cargo test -p edgezero-macros --lib app_args_ 2>&1 | tail -12` Expected: PASS — all seven `app_args_*` tests. -- [ ] **Step 6: Verify app-demo (real `app!`) still builds and emits owns_logging=false** +- [ ] **Step 6: Add a real-`app!` macro-emission integration test (spec requirement)** + +The spec's C2 acceptance requires proving the macro *emits* `owns_logging() == true` for `app!(…, owns_logging = true)` — not just that the grammar parses. `edgezero-macros` dev-depends on `edgezero-core` (`crates/edgezero-macros/Cargo.toml:31`) which re-exports `app!` (`crates/edgezero-core/src/lib.rs:42`), and the macro resolves the manifest path against the invoking crate's `CARGO_MANIFEST_DIR` (`app.rs:243`), so an integration test can invoke `app!` with a checked-in fixture manifest. + +First create a minimal fixture manifest, `crates/edgezero-macros/tests/fixtures/owns_logging.toml`: + +```toml +[app] +name = "owns-logging-fixture" +``` + +Then create the integration test `crates/edgezero-macros/tests/app_macro.rs`: + +```rust +//! Integration coverage: `app!(..., owns_logging = true)` emits a `Hooks` impl +//! whose `owns_logging()` returns `true`. The manifest path resolves against +//! this crate's CARGO_MANIFEST_DIR, so the fixture is `tests/fixtures/...`. + +// The macro emits `pub struct OwnedLoggingApp;`, a `Hooks` impl, and a free +// `build_router()` at this module scope. +edgezero_core::app!("tests/fixtures/owns_logging.toml", OwnedLoggingApp, owns_logging = true); + +#[test] +fn app_macro_emits_owns_logging_true() { + assert!(::owns_logging()); +} +``` + +Run: `cargo test -p edgezero-macros --test app_macro 2>&1 | tail -12` +Expected: PASS — proves the macro emitted `fn owns_logging() -> bool { true }`. (Only one `app!` per test file — it emits a free `build_router()` that would collide across invocations; the default `owns_logging() == false` path is covered by app-demo below.) + +- [ ] **Step 7: Verify app-demo (real `app!`, no keyword args) still emits owns_logging=false** app-demo's `app!("../../edgezero.toml")` (`examples/app-demo/crates/app-demo-core/src/lib.rs:11`) uses no keyword args, so its generated `owns_logging()` returns `false`. Run: `(cd examples/app-demo && cargo test -p app-demo-core 2>&1 | tail -5)` -Expected: PASS. +Expected: PASS. (Do NOT add a process-global logger "call run_app twice" test — the macro-emission test above + the gate code review are the deterministic coverage.) -Optional integration assertion (if you want a real-`app!` check beyond app-demo): the macros crate has no `app!` integration harness today; the `AppArgs` unit tests above are the deterministic coverage. Do not add a process-global logger test. - -- [ ] **Step 7: Lint + commit** +- [ ] **Step 8: Lint + commit** Run: `cargo clippy -p edgezero-macros --all-targets --all-features -- -D warnings 2>&1 | tail -5` Expected: clean. ```bash -git add crates/edgezero-macros/src/app.rs +git add crates/edgezero-macros/src/app.rs \ + crates/edgezero-macros/tests/app_macro.rs \ + crates/edgezero-macros/tests/fixtures/owns_logging.toml git commit -m "feat(macros): app!(owns_logging = ) keyword argument + AppArgs keyword grammar" ``` @@ -558,8 +591,8 @@ git commit -m "feat(macros): app!(owns_logging = ) keyword argument + AppA Add a Fastly `run_app` variant taking an app closure that reads the raw `fastly::Request` (JA4 / H2 / etc.) into a scratch `Extensions` **before** `into_core_request` consumes the request; the scratch bag is `extend`ed into the core request after conversion. `run_app` becomes the no-hook wrapper. **Files:** -- Modify: `crates/edgezero-adapter-fastly/src/request.rs` (thread the closure through `dispatch_with_registries`/`dispatch_with_handles`; scratch-`extend` around `into_core_request` at `request.rs:284`; add a host unit test) -- Modify: `crates/edgezero-adapter-fastly/src/lib.rs` (`run_app_with_request_extensions`; `run_app` delegates with a no-op) +- Modify: `crates/edgezero-adapter-fastly/src/request.rs` (thread the closure through `dispatch_with_registries`/`dispatch_with_handles`; scratch-`extend` around `into_core_request` at `request.rs:284`; update the OTHER `dispatch_with_handles` caller `FastlyService::dispatch` at `request.rs:145` to pass a no-op; add a host unit test) +- Modify: `crates/edgezero-adapter-fastly/src/lib.rs` (`run_app_with_request_extensions`; `run_app` + `run_app_with_config` delegate with a no-op) **Interfaces:** - Consumes: `into_core_request(req: FastlyRequest) -> Result` (`request.rs:445`, consumes `req` by value); `edgezero_core::http::Extensions`. @@ -641,6 +674,24 @@ where (`dispatch_core_request` is unchanged; it already takes `mut core_request` and inserts the registries + runs the router.) +**`dispatch_with_handles` has a SECOND caller** — `FastlyService::dispatch` (`request.rs:145`, the service-builder API used by the wasm/Viceroy contract tests). It calls `dispatch_with_handles` directly and will not compile after the signature change. Update that call site (`request.rs:145-153`) to pass a no-op closure: + +```rust + dispatch_with_handles( + self.app, + req, + Stores { + config_store, + kv, + secrets, + ..Default::default() + }, + |_req, _extensions| {}, + ) +``` + +(`FastlyService` is the no-hook service API; it does not expose a raw-request hook, so a no-op is correct. If a service-level hook is ever wanted, add it as a separate change.) + Change `dispatch_with_registries` (`request.rs:288-316`) to take and forward the closure: ```rust @@ -721,14 +772,19 @@ where (The logger gate here is the Task-3 `&& !A::owns_logging()`. `run_app_with_config` at `lib.rs:200` also calls `dispatch_with_registries` — update that call to pass a no-op closure `|_req, _extensions| {}` as the final arg so it compiles.) -- [ ] **Step 5: Run the host test + build** +- [ ] **Step 5: Confirm every caller was updated, then run the host test + build** + +`dispatch_with_handles` and `dispatch_with_registries` are internal to the fastly crate but each has multiple callers. Grep to confirm none was missed before building: + +Run: `grep -rn "dispatch_with_handles\|dispatch_with_registries" crates/edgezero-adapter-fastly/src/` +Expected callers, all now passing an `extend` closure: `dispatch_with_handles` ← `FastlyService::dispatch` (`request.rs:145`, no-op) and `dispatch_with_registries` (`request.rs`); `dispatch_with_registries` ← `run_app`/`run_app_with_request_extensions` and `run_app_with_config` (`lib.rs`). If grep shows any call without a trailing closure arg, fix it. Run: `cargo test -p edgezero-adapter-fastly --features fastly --lib apply_request_extend 2>&1 | tail -8` Expected: PASS. Run: `cargo test -p edgezero-adapter-fastly --features fastly --lib 2>&1 | tail -8` Expected: all host unit tests PASS (existing `synthesis_tests`, response, proxy, lib). Run: `cargo check --workspace --all-targets --features "fastly cloudflare spin" 2>&1 | tail -5` -Expected: succeeds (the `dispatch_with_registries` signature change is internal to the fastly crate; only `run_app`/`run_app_with_config` call it). +Expected: succeeds (the signature changes are internal to the fastly crate; the three callers — `FastlyService::dispatch`, `run_app`/variants, `run_app_with_config` — are all updated). - [ ] **Step 6: Lint + commit** From 26f694cfeb4421c134eafe6c5b162c126996e453 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Sat, 4 Jul 2026 20:41:33 -0700 Subject: [PATCH 54/74] docs(p0 plans): C3 handler-visible test; app-demo clippy (separate workspace); Task 3->4 fix --- ...4-edgezero-p0c-fastly-dispatch-fidelity.md | 56 +++++++++++++++++-- ...2026-07-04-edgezero-p0d-app-macro-state.md | 12 +++- 2 files changed, 62 insertions(+), 6 deletions(-) diff --git a/docs/superpowers/plans/2026-07-04-edgezero-p0c-fastly-dispatch-fidelity.md b/docs/superpowers/plans/2026-07-04-edgezero-p0c-fastly-dispatch-fidelity.md index 5c468758..782974b5 100644 --- a/docs/superpowers/plans/2026-07-04-edgezero-p0c-fastly-dispatch-fidelity.md +++ b/docs/superpowers/plans/2026-07-04-edgezero-p0c-fastly-dispatch-fidelity.md @@ -32,7 +32,7 @@ 4. `cargo check --workspace --all-targets --features "fastly cloudflare spin"` 5. `cargo check -p edgezero-adapter-spin --target wasm32-wasip2 --features spin` - **No backward-compat constraint** — prefer the cleanest breaking change; update every in-tree call site in the same PR. -- **Shared with P0-D:** Task 3 reworks the `app!` `AppArgs` grammar into keyword arguments (adding `owns_logging`). The sibling P0-D plan (`2026-07-04-edgezero-p0d-app-macro-state.md`) **extends that same grammar** with a `state` key — execute this P0-C plan first so P0-D builds on the keyword-arg framework. +- **Shared with P0-D:** Task 4 reworks the `app!` `AppArgs` grammar into keyword arguments (adding `owns_logging`). The sibling P0-D plan (`2026-07-04-edgezero-p0d-app-macro-state.md`) **extends that same grammar** with a `state` key — execute this P0-C plan first so P0-D builds on the keyword-arg framework. --- @@ -629,10 +629,55 @@ Add to the `#[cfg(test)] mod synthesis_tests` in `crates/edgezero-adapter-fastly } ``` -- [ ] **Step 2: Run — verify it fails** +Also add a **handler-visible** host test — this proves the spec's requirement (§128) that a value stashed by the pre-dispatch hook is readable by a handler. It exercises the second half of the C3 chain host-side (the scratch bag `extend`ed into a core request → dispatched → handler reads it), which `dispatch_with_handles` can't be host-tested through because `into_core_request` calls the `get_client_ip_addr()` hostcall. Add alongside the test above: + +```rust + #[test] + fn extended_request_extensions_are_visible_to_handler() { + use edgezero_core::body::Body; + use edgezero_core::context::RequestContext; + use edgezero_core::error::EdgeError; + use edgezero_core::http::{request_builder, Extensions, Method, StatusCode}; + use edgezero_core::router::RouterService; + use futures::executor::block_on; + + #[derive(Clone)] + struct Ja4(String); + + async fn handler(ctx: RequestContext) -> Result { + let ja4 = ctx + .request() + .extensions() + .get::() + .map_or_else(|| "missing".to_owned(), |value| value.0.clone()); + Ok(ja4) + } + + // Mirror what `dispatch_with_handles` does: a scratch bag built from the + // raw request is `extend`ed into the core request before dispatch. + let mut scratch = Extensions::default(); + scratch.insert(Ja4("t13d1516h2".to_owned())); + + let mut request = request_builder() + .method(Method::GET) + .uri("/ja4") + .body(Body::empty()) + .expect("request"); + request.extensions_mut().extend(scratch); + + let service = RouterService::builder().get("/ja4", handler).build(); + let response = block_on(service.oneshot(request)).expect("response"); + assert_eq!(response.status(), StatusCode::OK); + assert_eq!(response.body().as_bytes().expect("buffered"), b"t13d1516h2"); + } +``` + +> **Complete-path coverage (wasm/Viceroy, optional in this plan):** the *full* raw-`fastly::Request` → `run_app_with_request_extensions` → `into_core_request` → handler path can only run under Viceroy (`into_core_request`'s `get_client_ip_addr()` hostcall). If a Viceroy toolchain is available, add a test to `crates/edgezero-adapter-fastly/tests/contract.rs` (already `#![cfg(all(feature = "fastly", target_arch = "wasm32"))]`) that dispatches a request through `run_app_with_request_extensions::(req, |raw, ext| ext.insert(Ja4(raw.get_url_str().into())))` and asserts a handler read the value. Mark it clearly as wasm/Viceroy-only; it is not run by the host `cargo test` gate. + +- [ ] **Step 2: Run — verify they fail** Run: `cargo test -p edgezero-adapter-fastly --features fastly --lib apply_request_extend 2>&1 | tail -15` -Expected: FAIL — `apply_request_extend` does not exist. +Expected: FAIL — `apply_request_extend` does not exist. (The `extended_request_extensions_are_visible_to_handler` test also compiles against the same feature set; it exercises only public core APIs and will pass once the crate builds — its value is documenting/locking the handler-visible contract.) - [ ] **Step 3: Add the `apply_request_extend` helper + thread the closure through dispatch** @@ -810,6 +855,9 @@ cargo test -p edgezero-adapter-fastly --features fastly # host unit tests cargo check --workspace --all-targets --features "fastly cloudflare spin" cargo check -p edgezero-adapter-spin --target wasm32-wasip2 --features spin (cd examples/app-demo && cargo test) +# app-demo is its OWN workspace (root `exclude`s it at Cargo.toml:12); the macro +# change regenerates its Hooks impl (owns_logging), so lint it separately: +(cd examples/app-demo && cargo clippy --workspace --all-targets --all-features -- -D warnings) ``` Expected: all green. Note: `crates/edgezero-adapter-fastly/tests/contract.rs` (the Viceroy/wasm end-to-end incl. the C3 closure-reaches-handler path and full header round-trips) is `#![cfg(all(feature = "fastly", target_arch = "wasm32"))]` and is **not** run by the above; if a Viceroy toolchain is available, run it separately with `--features fastly --target wasm32-wasip1`. @@ -818,7 +866,7 @@ Expected: all green. Note: `crates/edgezero-adapter-fastly/tests/contract.rs` (t 1. Multi-value `Set-Cookie` round-trips through `from_core_response` (Task 1) and `convert_response` (Task 2). 2. `Hooks::owns_logging()` gates logger init in all four adapters (Task 3); `app!(owns_logging = true)` emits `owns_logging() -> bool { true }` (Task 4). -3. A pre-dispatch closure populates a scratch `Extensions` from the raw `fastly::Request` and it is merged into the core request (Task 5); `run_app` (no-hook) behavior is unchanged. +3. A pre-dispatch closure populates a scratch `Extensions` from the raw `fastly::Request` (Task 5), the scratch is merged into the core request, and the merged value is **handler-visible** (`extended_request_extensions_are_visible_to_handler`); `run_app` (no-hook) behavior is unchanged. The full raw-request→handler path is additionally covered by an optional wasm/Viceroy `contract.rs` test. 4. `cargo fmt`/clippy clean; workspace + app-demo tests green; WASM checks pass. ## Self-review notes (spec coverage) diff --git a/docs/superpowers/plans/2026-07-04-edgezero-p0d-app-macro-state.md b/docs/superpowers/plans/2026-07-04-edgezero-p0d-app-macro-state.md index e2699456..f1f6e01e 100644 --- a/docs/superpowers/plans/2026-07-04-edgezero-p0d-app-macro-state.md +++ b/docs/superpowers/plans/2026-07-04-edgezero-p0d-app-macro-state.md @@ -290,8 +290,14 @@ Expected: PASS — the macro-generated router injected `Arc`, the `St Run: `(cd examples/app-demo && cargo test 2>&1 | tail -8)` Expected: PASS (all four adapter example crates still build; app-demo-core tests green). -Run: `cargo test -p edgezero-macros 2>&1 | tail -5 && cargo clippy --workspace --all-targets --all-features -- -D warnings 2>&1 | tail -3` -Expected: macros tests green; clippy clean (including the app-demo workspace — the new handler's fields are read by the assertion, so no `dead_code` suppression is needed). +Run: `cargo test -p edgezero-macros 2>&1 | tail -5` +Expected: macros tests green. +Run root clippy AND app-demo clippy separately — `examples/app-demo` is its **own workspace**, `exclude`d from the root workspace (`Cargo.toml:12`), so root `--workspace` does NOT lint it: +``` +cargo clippy --workspace --all-targets --all-features -- -D warnings +(cd examples/app-demo && cargo clippy --workspace --all-targets --all-features -- -D warnings) +``` +Expected: both clean (the new handler's fields are read by the assertion, so no `dead_code` suppression is needed). - [ ] **Step 8: Commit** @@ -316,6 +322,8 @@ cargo test --workspace --all-targets cargo check --workspace --all-targets --features "fastly cloudflare spin" cargo check -p edgezero-adapter-spin --target wasm32-wasip2 --features spin (cd examples/app-demo && cargo test) +# app-demo is its OWN workspace (root `exclude`s it), so lint it separately: +(cd examples/app-demo && cargo clippy --workspace --all-targets --all-features -- -D warnings) ``` Expected: all green. From a8b60466c6d8255b5fd3055c54a5f5205f564dc1 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Sat, 4 Jul 2026 21:45:03 -0700 Subject: [PATCH 55/74] docs(p0c plan): correct run_app_with_config dispatch path (via FastlyService, not dispatch_with_registries) --- .../2026-07-04-edgezero-p0c-fastly-dispatch-fidelity.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/superpowers/plans/2026-07-04-edgezero-p0c-fastly-dispatch-fidelity.md b/docs/superpowers/plans/2026-07-04-edgezero-p0c-fastly-dispatch-fidelity.md index 782974b5..73266e20 100644 --- a/docs/superpowers/plans/2026-07-04-edgezero-p0c-fastly-dispatch-fidelity.md +++ b/docs/superpowers/plans/2026-07-04-edgezero-p0c-fastly-dispatch-fidelity.md @@ -592,7 +592,7 @@ Add a Fastly `run_app` variant taking an app closure that reads the raw `fastly: **Files:** - Modify: `crates/edgezero-adapter-fastly/src/request.rs` (thread the closure through `dispatch_with_registries`/`dispatch_with_handles`; scratch-`extend` around `into_core_request` at `request.rs:284`; update the OTHER `dispatch_with_handles` caller `FastlyService::dispatch` at `request.rs:145` to pass a no-op; add a host unit test) -- Modify: `crates/edgezero-adapter-fastly/src/lib.rs` (`run_app_with_request_extensions`; `run_app` + `run_app_with_config` delegate with a no-op) +- Modify: `crates/edgezero-adapter-fastly/src/lib.rs` (`run_app_with_request_extensions`; `run_app` delegates to it with a no-op). `run_app_with_config` is unaffected by the closure threading — it dispatches via `FastlyService::dispatch` (updated in Step 3), not `dispatch_with_registries`. **Interfaces:** - Consumes: `into_core_request(req: FastlyRequest) -> Result` (`request.rs:445`, consumes `req` by value); `edgezero_core::http::Extensions`. @@ -815,21 +815,21 @@ where } ``` -(The logger gate here is the Task-3 `&& !A::owns_logging()`. `run_app_with_config` at `lib.rs:200` also calls `dispatch_with_registries` — update that call to pass a no-op closure `|_req, _extensions| {}` as the final arg so it compiles.) +(The logger gate here is the Task-3 `&& !A::owns_logging()`. Note: `run_app_with_config` (`lib.rs:200`) does **not** call `dispatch_with_registries` — it builds a `FastlyService` and calls `service.dispatch(req)` (`lib.rs:210-214`), which routes through `FastlyService::dispatch` → `dispatch_with_handles`, already updated with a no-op in Step 3. So `run_app_with_config` needs no change here beyond its own Task-3 logger gate. The **only** direct caller of `dispatch_with_registries` is `run_app` — now delegating through `run_app_with_request_extensions`.) - [ ] **Step 5: Confirm every caller was updated, then run the host test + build** `dispatch_with_handles` and `dispatch_with_registries` are internal to the fastly crate but each has multiple callers. Grep to confirm none was missed before building: Run: `grep -rn "dispatch_with_handles\|dispatch_with_registries" crates/edgezero-adapter-fastly/src/` -Expected callers, all now passing an `extend` closure: `dispatch_with_handles` ← `FastlyService::dispatch` (`request.rs:145`, no-op) and `dispatch_with_registries` (`request.rs`); `dispatch_with_registries` ← `run_app`/`run_app_with_request_extensions` and `run_app_with_config` (`lib.rs`). If grep shows any call without a trailing closure arg, fix it. +Expected callers, all now passing an `extend` closure: `dispatch_with_handles` ← `FastlyService::dispatch` (`request.rs:145`, no-op) and `dispatch_with_registries` (`request.rs`); `dispatch_with_registries` ← only `run_app_with_request_extensions` (`lib.rs`; `run_app` delegates to it). `run_app_with_config` reaches dispatch via `FastlyService::dispatch`, so it needs no closure change. If grep shows any `dispatch_with_handles`/`dispatch_with_registries` call without a trailing closure arg, fix it. Run: `cargo test -p edgezero-adapter-fastly --features fastly --lib apply_request_extend 2>&1 | tail -8` Expected: PASS. Run: `cargo test -p edgezero-adapter-fastly --features fastly --lib 2>&1 | tail -8` Expected: all host unit tests PASS (existing `synthesis_tests`, response, proxy, lib). Run: `cargo check --workspace --all-targets --features "fastly cloudflare spin" 2>&1 | tail -5` -Expected: succeeds (the signature changes are internal to the fastly crate; the three callers — `FastlyService::dispatch`, `run_app`/variants, `run_app_with_config` — are all updated). +Expected: succeeds (the signature changes are internal to the fastly crate; the two `dispatch_with_handles` callers — `FastlyService::dispatch` and `dispatch_with_registries` — and the one `dispatch_with_registries` caller, `run_app_with_request_extensions`, are all updated. `run_app_with_config` compiles unchanged: it dispatches via `FastlyService::dispatch`). - [ ] **Step 6: Lint + commit** From b11c71e3bacdc16cef799ec43043b6c4b3d4fc10 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Sat, 4 Jul 2026 21:54:22 -0700 Subject: [PATCH 56/74] fix(fastly): preserve multi-value response headers (Set-Cookie) in from_core_response --- .../edgezero-adapter-fastly/src/response.rs | 24 ++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/crates/edgezero-adapter-fastly/src/response.rs b/crates/edgezero-adapter-fastly/src/response.rs index 075b235a..f9eb4d02 100644 --- a/crates/edgezero-adapter-fastly/src/response.rs +++ b/crates/edgezero-adapter-fastly/src/response.rs @@ -25,8 +25,11 @@ pub fn from_core_response(response: Response) -> Result = fastly_response + .get_header_all("set-cookie") + .map(|value| value.to_str().expect("utf8").to_owned()) + .collect(); + assert_eq!(cookies, vec!["a=1".to_owned(), "b=2".to_owned()]); + } + #[test] fn stream_body_is_written_to_fastly_response() { let response = response_builder() From 2332aebe7c3320b125e43e5da489d5a3761fadaa Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Sat, 4 Jul 2026 21:56:24 -0700 Subject: [PATCH 57/74] docs(p0c plan): correct fastly test invocation (wasm32-wasip1 + Viceroy from crate dir, not host cargo test) --- ...4-edgezero-p0c-fastly-dispatch-fidelity.md | 22 +++++++++++++------ 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/docs/superpowers/plans/2026-07-04-edgezero-p0c-fastly-dispatch-fidelity.md b/docs/superpowers/plans/2026-07-04-edgezero-p0c-fastly-dispatch-fidelity.md index 73266e20..5e7e4ced 100644 --- a/docs/superpowers/plans/2026-07-04-edgezero-p0c-fastly-dispatch-fidelity.md +++ b/docs/superpowers/plans/2026-07-04-edgezero-p0c-fastly-dispatch-fidelity.md @@ -21,10 +21,15 @@ - `impl_trait_in_params` — use named generics, not `impl Trait` params. - `assertions_on_result_states` — in tests use `.unwrap()/.unwrap_err()/.expect()` or `assert_eq!`, not `assert!(x.is_ok())`. - `needless_raw_strings` — plain string unless it needs `"`/`#`. -- **Test targets/features (critical — the crate compiles almost nothing on default features):** - - The `response`/`proxy`/`request`/`lib` modules are all `#[cfg(feature = "fastly")]`. **Host unit tests run only under `cargo test -p edgezero-adapter-fastly --features fastly`.** - - `crates/edgezero-adapter-fastly/tests/contract.rs` is `#![cfg(all(feature = "fastly", target_arch = "wasm32"))]` — **Viceroy/wasm-only; it does NOT run under host `cargo test`** and is out of scope for this plan's verification commands. - - Fastly **hostcalls** (`get_client_ip_addr`, `get_tls_ja4`, `send_async_streaming`, `Backend::builder`, `ConfigStore::try_open`) panic/abort off-platform. Host unit tests must avoid them. `FastlyResponse::from_status`, `set_body`/`append_header`/`get_header_all`, and `FastlyRequest::new`/`get_method`/`get_url_str`/`get_headers` work host-side. +- **Test targets/features (CRITICAL — the fastly adapter is a wasm-only crate; verified empirically):** + - `--features fastly` pulls in `libfastly`, which references undefined Compute@Edge hostcall symbols on the host. **`cargo test -p edgezero-adapter-fastly --features fastly` from the workspace root FAILS TO LINK** (`ld: symbol(s) not found`). Do not use it. + - The crate ships a per-package `crates/edgezero-adapter-fastly/.cargo/config.toml` that sets `build.target = "wasm32-wasip1"` and a **Viceroy** runner — **but only when cargo is invoked from inside the crate directory.** So run all fastly-adapter tests as: + ``` + (cd crates/edgezero-adapter-fastly && cargo test --features fastly --lib ) + ``` + This builds to `wasm32-wasip1` and runs the test binary under Viceroy (0.17.0, pinned in `.tool-versions`), which provides the hostcalls — so `FastlyResponse::from_status`/`append_header`/`get_header_all`, `FastlyRequest::new`/`get_url_str`, and even `get_client_ip_addr` all work at runtime. **Every `cargo test -p edgezero-adapter-fastly --features fastly …` command in the tasks below MUST be run in this `(cd crates/edgezero-adapter-fastly && cargo test --features fastly …)` form** — the commands are written the short way for brevity. + - `crates/edgezero-adapter-fastly/tests/contract.rs` is `#![cfg(all(feature = "fastly", target_arch = "wasm32"))]` (the same wasm/Viceroy path). CI runs it via `cargo test -p edgezero-adapter-fastly --features fastly --target wasm32-wasip1 --test contract`. + - `cargo clippy`/`cargo check --features fastly` type-check on the **host** (no link), so clippy runs from the workspace root as usual. - **CI gates (all must pass):** 1. `cargo fmt --all -- --check` 2. `cargo clippy --workspace --all-targets --all-features -- -D warnings` @@ -602,9 +607,9 @@ Add a Fastly `run_app` variant taking an app closure that reads the raw `fastly: - [ ] **Step 1: Write the failing host unit test (the scratch-bag mechanism)** -The full end-to-end (closure value reaching a handler) requires `into_core_request`, which calls the `get_client_ip_addr()` **hostcall** — so it is Viceroy/wasm-only and lives in `contract.rs` (out of scope here). Host-test the closure/scratch-bag plumbing directly via a small extracted helper. +Unit-test the closure/scratch-bag seam directly via a small extracted helper. (This runs under Viceroy from the crate dir, per Global Constraints; the *full* `run_app_with_request_extensions` → `into_core_request` → handler integration is best covered by a `contract.rs` test — see the note after the handler-visible test.) -Add to the `#[cfg(test)] mod synthesis_tests` in `crates/edgezero-adapter-fastly/src/request.rs` (host-side, runs under `--features fastly`). It builds a `FastlyRequest` host-side (`FastlyRequest::new` is not a hostcall) and asserts the closure populated the scratch bag: +Add to the `#[cfg(test)] mod synthesis_tests` in `crates/edgezero-adapter-fastly/src/request.rs`. It builds a `FastlyRequest` and asserts the closure populated the scratch bag: ```rust #[test] @@ -851,7 +856,10 @@ git commit -m "feat(fastly): run_app_with_request_extensions pre-dispatch hook f cargo fmt --all -- --check cargo clippy --workspace --all-targets --all-features -- -D warnings cargo test --workspace --all-targets -cargo test -p edgezero-adapter-fastly --features fastly # host unit tests for the gated modules +# fastly adapter is wasm-only: run its tests via Viceroy FROM THE CRATE DIR +# (root `cargo test -p edgezero-adapter-fastly --features fastly` fails to link): +(cd crates/edgezero-adapter-fastly && cargo test --features fastly --lib) +(cd crates/edgezero-adapter-fastly && cargo test --features fastly --target wasm32-wasip1 --test contract) # if Viceroy present cargo check --workspace --all-targets --features "fastly cloudflare spin" cargo check -p edgezero-adapter-spin --target wasm32-wasip2 --features spin (cd examples/app-demo && cargo test) From 02a0292847d40941f09a1bd8f45a79330103b0f1 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Sat, 4 Jul 2026 21:57:39 -0700 Subject: [PATCH 58/74] fix(fastly): preserve multi-value headers in proxy response/request conversion --- crates/edgezero-adapter-fastly/src/proxy.rs | 31 ++++++++++++++++++--- 1 file changed, 27 insertions(+), 4 deletions(-) diff --git a/crates/edgezero-adapter-fastly/src/proxy.rs b/crates/edgezero-adapter-fastly/src/proxy.rs index 2947f33b..b65fa0e6 100644 --- a/crates/edgezero-adapter-fastly/src/proxy.rs +++ b/crates/edgezero-adapter-fastly/src/proxy.rs @@ -46,11 +46,13 @@ fn build_fastly_request(method: Method, uri: &Uri, headers: &HeaderMap) -> Fastl let mut fastly_request = FastlyRequest::new(method.clone(), uri.to_string()); fastly_request.set_method(method); + // Append (not set) so a multi-value client header survives; `Host` below is + // set explicitly as a single value. for (name, value) in headers { if name.as_str().eq_ignore_ascii_case("host") { continue; } - fastly_request.set_header(name.as_str(), value.clone()); + fastly_request.append_header(name.as_str(), value.clone()); } if let Some(host) = uri.host() { @@ -64,9 +66,13 @@ fn convert_response(fastly_response: &mut FastlyResponse) -> ProxyResponse { let status = fastly_response.get_status(); let mut proxy_response = ProxyResponse::new(status, Body::empty()); - for header in fastly_response.get_header_names() { - if let Some(value) = fastly_response.get_header(header) { - proxy_response.headers_mut().insert(header, value.clone()); + // Preserve multi-value ORIGIN response headers (e.g. Set-Cookie): read ALL + // values per name and append, instead of first-value + insert (which + // replaced). `get_header_names()` yields `&HeaderName`, usable for both + // `get_header_all` and `append`. + for name in fastly_response.get_header_names() { + for value in fastly_response.get_header_all(name) { + proxy_response.headers_mut().append(name, value.clone()); } } @@ -212,6 +218,23 @@ mod tests { } } + #[test] + fn convert_response_preserves_multi_value_set_cookie() { + let mut fastly_response = FastlyResponse::from_status(200); + fastly_response.append_header("set-cookie", "a=1"); + fastly_response.append_header("set-cookie", "b=2"); + + let proxy_response = convert_response(&mut fastly_response); + + let cookies: Vec = proxy_response + .headers() + .get_all("set-cookie") + .into_iter() + .map(|value| value.to_str().expect("utf8").to_owned()) + .collect(); + assert_eq!(cookies, vec!["a=1".to_owned(), "b=2".to_owned()]); + } + #[test] fn stream_handles_brotli() { let mut compressed = Vec::new(); From fe5b675e2d19aff11076df23fdb81ce2beb72740 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Sat, 4 Jul 2026 22:04:16 -0700 Subject: [PATCH 59/74] feat: Hooks::owns_logging() opt-out gated in all four adapter run_app entrypoints --- crates/edgezero-adapter-axum/src/dev_server.rs | 4 +++- crates/edgezero-adapter-cloudflare/src/lib.rs | 7 +++++-- crates/edgezero-adapter-fastly/src/lib.rs | 4 ++-- crates/edgezero-adapter-spin/src/lib.rs | 7 +++++-- crates/edgezero-core/src/app.rs | 13 +++++++++++++ crates/edgezero-macros/src/app.rs | 14 +++++++++----- 6 files changed, 37 insertions(+), 12 deletions(-) diff --git a/crates/edgezero-adapter-axum/src/dev_server.rs b/crates/edgezero-adapter-axum/src/dev_server.rs index 36486719..147e1658 100644 --- a/crates/edgezero-adapter-axum/src/dev_server.rs +++ b/crates/edgezero-adapter-axum/src/dev_server.rs @@ -340,7 +340,9 @@ pub fn run_app() -> anyhow::Result<()> { .and_then(|raw| LevelFilter::from_str(raw).ok()) .unwrap_or(LevelFilter::Info); - let _logger_init = SimpleLogger::new().with_level(level).init(); + if !A::owns_logging() { + let _logger_init = SimpleLogger::new().with_level(level).init(); + } let resolution = resolve_addr(&env); for warning in &resolution.warnings { diff --git a/crates/edgezero-adapter-cloudflare/src/lib.rs b/crates/edgezero-adapter-cloudflare/src/lib.rs index c630cc1c..edd9224f 100644 --- a/crates/edgezero-adapter-cloudflare/src/lib.rs +++ b/crates/edgezero-adapter-cloudflare/src/lib.rs @@ -101,8 +101,11 @@ pub async fn run_app( ctx: Context, ) -> Result { // Best-effort: if a logger is already installed, ignore the error rather - // than panicking — every Worker request re-enters this function. - drop(init_logger()); + // than panicking — every Worker request re-enters this function. Skipped + // entirely when the app owns logging. + if !A::owns_logging() { + drop(init_logger()); + } let stores = A::stores(); let env_config = env_config_from_worker(&env, stores); let app = A::build_app(); diff --git a/crates/edgezero-adapter-fastly/src/lib.rs b/crates/edgezero-adapter-fastly/src/lib.rs index a05a02f6..cc3233c6 100644 --- a/crates/edgezero-adapter-fastly/src/lib.rs +++ b/crates/edgezero-adapter-fastly/src/lib.rs @@ -114,7 +114,7 @@ pub fn run_app(req: fastly::Request) -> Result( req: fastly::Request, config_store_name: Option<&str>, ) -> Result { - if logging.use_fastly_logger { + if logging.use_fastly_logger && !A::owns_logging() { let endpoint = logging.endpoint.as_deref().unwrap_or("stdout"); init_logger(endpoint, logging.level, logging.echo_stdout)?; } diff --git a/crates/edgezero-adapter-spin/src/lib.rs b/crates/edgezero-adapter-spin/src/lib.rs index b8234fe5..db282c05 100644 --- a/crates/edgezero-adapter-spin/src/lib.rs +++ b/crates/edgezero-adapter-spin/src/lib.rs @@ -111,8 +111,11 @@ pub fn init_logger() -> Result<(), log::SetLoggerError> { pub async fn run_app(req: SpinRequest) -> anyhow::Result { // Best-effort: every Spin `#[http_service]` re-enters this function, so a // second `log::set_logger` call returns Err — drop the result instead of - // `.expect()` to avoid panicking on every subsequent request. - drop(init_logger()); + // `.expect()` to avoid panicking on every subsequent request. Skipped + // entirely when the app owns logging. + if !A::owns_logging() { + drop(init_logger()); + } let env = EnvConfig::from_env(); let stores = A::stores(); let app = A::build_app(); diff --git a/crates/edgezero-core/src/app.rs b/crates/edgezero-core/src/app.rs index ea5ac161..7fc32b50 100644 --- a/crates/edgezero-core/src/app.rs +++ b/crates/edgezero-core/src/app.rs @@ -127,6 +127,14 @@ pub trait Hooks { App::default_name() } + /// When `true`, an adapter's `run_app` skips its own logger initialization; + /// the app is responsible for installing a `log` backend. Default `false`. + #[must_use] + #[inline] + fn owns_logging() -> bool { + false + } + /// Build the router service for the application. fn routes() -> RouterService; @@ -240,6 +248,11 @@ mod tests { assert_eq!(app.name(), App::default_name()); } + #[test] + fn default_hooks_do_not_own_logging() { + assert!(!DefaultHooks::owns_logging()); + } + #[test] fn default_hooks_use_default_name_and_into_router() { let app = DefaultHooks::build_app(); diff --git a/crates/edgezero-macros/src/app.rs b/crates/edgezero-macros/src/app.rs index 9d52e3a8..2bdb6afa 100644 --- a/crates/edgezero-macros/src/app.rs +++ b/crates/edgezero-macros/src/app.rs @@ -151,11 +151,11 @@ pub fn expand_app(input: TokenStream) -> TokenStream { let manifest_path_lit = LitStr::new(&manifest_path.to_string_lossy(), Span::call_site()); - // The emitted `Hooks` impl below explicitly defines `configure` and - // `build_app` even though their bodies mirror the trait defaults. This is - // required because `missing_trait_methods` (restriction = deny) forbids - // relying on trait defaults in the impl. If `Hooks::configure` or - // `Hooks::build_app` defaults change, update these emitted bodies to match. + // The emitted `Hooks` impl below explicitly defines `configure`, + // `owns_logging`, and `build_app` even though their bodies mirror the trait + // defaults. This is required because `missing_trait_methods` (restriction = + // deny) forbids relying on trait defaults in the impl. If those `Hooks` + // defaults change, update these emitted bodies to match. let output = quote! { // Force a rebuild when the manifest file changes (include_bytes tracks it as a build input). const _: &[u8] = include_bytes!(#manifest_path_lit); @@ -169,6 +169,10 @@ pub fn expand_app(input: TokenStream) -> TokenStream { fn configure(_app: &mut edgezero_core::app::App) {} + fn owns_logging() -> bool { + false + } + fn name() -> &'static str { #app_name_lit } From 5795fe2e43a0c8f1e057eb8de1c9da77aa2566ec Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Sat, 4 Jul 2026 22:06:02 -0700 Subject: [PATCH 60/74] docs(p0c plan): targeted fastly Viceroy filters (blanket --lib aborts); trait method alphabetical ordering --- .../2026-07-04-edgezero-p0c-fastly-dispatch-fidelity.md | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/docs/superpowers/plans/2026-07-04-edgezero-p0c-fastly-dispatch-fidelity.md b/docs/superpowers/plans/2026-07-04-edgezero-p0c-fastly-dispatch-fidelity.md index 5e7e4ced..08eb5ec8 100644 --- a/docs/superpowers/plans/2026-07-04-edgezero-p0c-fastly-dispatch-fidelity.md +++ b/docs/superpowers/plans/2026-07-04-edgezero-p0c-fastly-dispatch-fidelity.md @@ -274,7 +274,7 @@ Expected: FAIL — `no method named owns_logging found`. - [ ] **Step 3: Add `owns_logging()` to the `Hooks` trait** -In `crates/edgezero-core/src/app.rs`, add to the `Hooks` trait (after `configure`, keeping methods in the existing order — insert where it reads best; the trait is not alphabetized, but place it adjacent to `configure`): +In `crates/edgezero-core/src/app.rs`, add to the `Hooks` trait. **`arbitrary_source_item_ordering` (restriction = deny) enforces alphabetical trait methods**, so place `owns_logging` between `name` and `routes` (order: `build_app`, `configure`, `name`, `owns_logging`, `routes`, `stores`) — not adjacent to `configure`: ```rust /// When `true`, an adapter's `run_app` skips its own logger initialization; @@ -857,8 +857,11 @@ cargo fmt --all -- --check cargo clippy --workspace --all-targets --all-features -- -D warnings cargo test --workspace --all-targets # fastly adapter is wasm-only: run its tests via Viceroy FROM THE CRATE DIR -# (root `cargo test -p edgezero-adapter-fastly --features fastly` fails to link): -(cd crates/edgezero-adapter-fastly && cargo test --features fastly --lib) +# (root `cargo test -p edgezero-adapter-fastly --features fastly` fails to link). +# Use TARGETED module filters — a blanket `--lib` run aborts (exit 134) on +# pre-existing store/hostcall tests that need specific Viceroy backend config: +(cd crates/edgezero-adapter-fastly && cargo test --features fastly --lib -- response:: proxy::) +(cd crates/edgezero-adapter-fastly && cargo test --features fastly --lib -- synthesis apply_request_extend extended_request_extensions) (cd crates/edgezero-adapter-fastly && cargo test --features fastly --target wasm32-wasip1 --test contract) # if Viceroy present cargo check --workspace --all-targets --features "fastly cloudflare spin" cargo check -p edgezero-adapter-spin --target wasm32-wasip2 --features spin From 3518fc3bc497298be3397117dad20a0145b2bcc2 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Sat, 4 Jul 2026 22:10:24 -0700 Subject: [PATCH 61/74] feat(macros): app!(owns_logging = ) keyword argument + AppArgs keyword grammar --- crates/edgezero-macros/src/app.rs | 133 ++++++++++++++++-- crates/edgezero-macros/tests/app_macro.rs | 21 +++ .../tests/fixtures/owns_logging.toml | 2 + 3 files changed, 148 insertions(+), 8 deletions(-) create mode 100644 crates/edgezero-macros/tests/app_macro.rs create mode 100644 crates/edgezero-macros/tests/fixtures/owns_logging.toml diff --git a/crates/edgezero-macros/src/app.rs b/crates/edgezero-macros/src/app.rs index 2bdb6afa..ba1e5fa7 100644 --- a/crates/edgezero-macros/src/app.rs +++ b/crates/edgezero-macros/src/app.rs @@ -9,24 +9,71 @@ use syn::parse::{Parse, ParseStream}; use syn::{parse_macro_input, Ident, LitStr, Token}; use validator::Validate as _; +#[derive(Debug)] struct AppArgs { app_ident: Option, + owns_logging: Option, path: LitStr, } impl Parse for AppArgs { fn parse(input: ParseStream) -> syn::Result { let path: LitStr = input.parse()?; - let app_ident = if input.peek(Token![,]) { + let mut app_ident: Option = None; + let mut owns_logging: Option = None; + let mut seen_keyword = false; + + while input.peek(Token![,]) { input.parse::()?; - Some(input.parse::()?) - } else { - None - }; + + // Keyword argument: `Ident = Value`. + if input.peek(Ident) && input.peek2(Token![=]) { + let key: Ident = input.parse()?; + input.parse::()?; + seen_keyword = true; + match key.to_string().as_str() { + "owns_logging" => { + if owns_logging.is_some() { + return Err(syn::Error::new( + key.span(), + "duplicate `owns_logging` argument", + )); + } + let value: syn::LitBool = input.parse()?; + owns_logging = Some(value.value); + } + other => { + return Err(syn::Error::new( + key.span(), + format!("unknown `app!` argument `{other}`; expected `owns_logging`"), + )); + } + } + continue; + } + + // Bare identifier: the optional custom App type name, only before keywords. + if input.peek(Ident) { + if seen_keyword || app_ident.is_some() { + return Err(input.error( + "the custom App identifier must come immediately after the manifest path, before keyword arguments", + )); + } + app_ident = Some(input.parse::()?); + continue; + } + + return Err(input.error("expected a custom App identifier or `key = value` argument")); + } + if !input.is_empty() { return Err(input.error("unexpected tokens after app! macro arguments")); } - Ok(Self { app_ident, path }) + Ok(Self { + app_ident, + owns_logging, + path, + }) } } @@ -150,6 +197,7 @@ pub fn expand_app(input: TokenStream) -> TokenStream { let stores_tokens = build_stores_tokens(&manifest); let manifest_path_lit = LitStr::new(&manifest_path.to_string_lossy(), Span::call_site()); + let owns_logging_lit = args.owns_logging.unwrap_or(false); // The emitted `Hooks` impl below explicitly defines `configure`, // `owns_logging`, and `build_app` even though their bodies mirror the trait @@ -170,7 +218,7 @@ pub fn expand_app(input: TokenStream) -> TokenStream { fn configure(_app: &mut edgezero_core::app::App) {} fn owns_logging() -> bool { - false + #owns_logging_lit } fn name() -> &'static str { @@ -270,7 +318,76 @@ fn route_for_method(method: &str, path: &LitStr, handler: &syn::ExprPath) -> Tok #[cfg(test)] mod tests { - use super::parse_handler_path; + use super::{parse_handler_path, AppArgs}; + use syn::parse_str; + + #[test] + fn app_args_parses_app_ident_then_keyword() { + let args: AppArgs = + parse_str(r#""edgezero.toml", MyApp, owns_logging = false"#).expect("parse"); + assert_eq!( + args.app_ident.map(|ident| ident.to_string()), + Some("MyApp".to_owned()) + ); + assert_eq!(args.owns_logging, Some(false)); + } + + #[test] + fn app_args_parses_owns_logging_true() { + let args: AppArgs = parse_str(r#""edgezero.toml", owns_logging = true"#).expect("parse"); + assert_eq!(args.owns_logging, Some(true)); + assert!(args.app_ident.is_none()); + } + + #[test] + fn app_args_parses_path_and_app_ident() { + let args: AppArgs = parse_str(r#""edgezero.toml", MyApp"#).expect("parse"); + assert_eq!( + args.app_ident.map(|ident| ident.to_string()), + Some("MyApp".to_owned()) + ); + assert_eq!(args.owns_logging, None); + } + + #[test] + fn app_args_parses_path_only() { + let args: AppArgs = parse_str(r#""edgezero.toml""#).expect("parse"); + assert_eq!(args.path.value(), "edgezero.toml"); + assert!(args.app_ident.is_none()); + assert_eq!(args.owns_logging, None); + } + + #[test] + fn app_args_rejects_duplicate_key() { + let err = + parse_str::(r#""edgezero.toml", owns_logging = true, owns_logging = false"#) + .expect_err("duplicate"); + assert!( + err.to_string().contains("duplicate `owns_logging`"), + "got: {err}" + ); + } + + #[test] + fn app_args_rejects_ident_after_keyword() { + let err = parse_str::(r#""edgezero.toml", owns_logging = true, MyApp"#) + .expect_err("ident after keyword"); + assert!( + err.to_string() + .contains("must come immediately after the manifest path"), + "got: {err}" + ); + } + + #[test] + fn app_args_rejects_unknown_key() { + let err = + parse_str::(r#""edgezero.toml", bogus = true"#).expect_err("unknown key"); + assert!( + err.to_string().contains("unknown `app!` argument `bogus`"), + "got: {err}" + ); + } #[test] fn parse_handler_path_accepts_absolute_crate_path() { diff --git a/crates/edgezero-macros/tests/app_macro.rs b/crates/edgezero-macros/tests/app_macro.rs new file mode 100644 index 00000000..58185135 --- /dev/null +++ b/crates/edgezero-macros/tests/app_macro.rs @@ -0,0 +1,21 @@ +//! Integration coverage: `app!(..., owns_logging = true)` emits a `Hooks` impl +//! whose `owns_logging()` returns `true`. The manifest path resolves against +//! this crate's `CARGO_MANIFEST_DIR`, so the fixture is `tests/fixtures/...`. + +// The macro emits `pub struct OwnedLoggingApp;`, a `Hooks` impl, and a free +// `build_router()` at this module scope. +edgezero_core::app!( + "tests/fixtures/owns_logging.toml", + OwnedLoggingApp, + owns_logging = true +); + +#[cfg(test)] +mod tests { + use edgezero_core::app::Hooks as _; + + #[test] + fn app_macro_emits_owns_logging_true() { + assert!(super::OwnedLoggingApp::owns_logging()); + } +} diff --git a/crates/edgezero-macros/tests/fixtures/owns_logging.toml b/crates/edgezero-macros/tests/fixtures/owns_logging.toml new file mode 100644 index 00000000..2b009868 --- /dev/null +++ b/crates/edgezero-macros/tests/fixtures/owns_logging.toml @@ -0,0 +1,2 @@ +[app] +name = "owns-logging-fixture" From 44bd0d073b08ce65953f4ea98e80765273a524e0 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Sat, 4 Jul 2026 22:17:45 -0700 Subject: [PATCH 62/74] feat(fastly): run_app_with_request_extensions pre-dispatch hook for raw-request signals --- crates/edgezero-adapter-fastly/src/lib.rs | 33 ++++++- crates/edgezero-adapter-fastly/src/request.rs | 96 +++++++++++++++++-- 2 files changed, 122 insertions(+), 7 deletions(-) diff --git a/crates/edgezero-adapter-fastly/src/lib.rs b/crates/edgezero-adapter-fastly/src/lib.rs index cc3233c6..e3c40e4a 100644 --- a/crates/edgezero-adapter-fastly/src/lib.rs +++ b/crates/edgezero-adapter-fastly/src/lib.rs @@ -25,6 +25,8 @@ use edgezero_core::app::{Hooks, StoresMetadata}; #[cfg(feature = "fastly")] use edgezero_core::env_config::EnvConfig; #[cfg(feature = "fastly")] +use edgezero_core::http::Extensions; +#[cfg(feature = "fastly")] use edgezero_core::manifest::ResolvedLoggingConfig; #[cfg(feature = "fastly")] #[derive(Debug, Clone)] @@ -111,6 +113,27 @@ fn logging_from_env(env: &EnvConfig) -> FastlyLogging { #[cfg(feature = "fastly")] #[inline] pub fn run_app(req: fastly::Request) -> Result { + run_app_with_request_extensions::(req, |_req, _extensions| {}) +} + +/// Like [`run_app`], but runs `extend` against a scratch +/// [`Extensions`](edgezero_core::http::Extensions) populated from the raw +/// `fastly::Request` (TLS JA4, H2 fingerprint, client IP, …) before the request +/// is converted; the scratch values are merged into the core request's +/// extensions and are visible to middleware and the `State`/extractor layer. +/// +/// # Errors +/// Returns an error if logger setup fails or any required store cannot be opened. +#[cfg(feature = "fastly")] +#[inline] +pub fn run_app_with_request_extensions( + req: fastly::Request, + extend: F, +) -> Result +where + A: Hooks, + F: FnOnce(&fastly::Request, &mut Extensions), +{ let stores = A::stores(); let env = env_config_from_runtime_dictionary(stores); let logging = logging_from_env(&env); @@ -119,7 +142,15 @@ pub fn run_app(req: fastly::Request) -> Result FastlyService<'app> { secrets, ..Default::default() }, + |_req, _extensions| {}, ) } @@ -276,12 +277,31 @@ fn dispatch_core_request( from_core_response(response).map_err(|err| map_edge_error(&err)) } -fn dispatch_with_handles( +/// Run an app-provided closure against a scratch `Extensions` populated from the +/// RAW `fastly::Request` (JA4 / H2 / etc.), BEFORE `into_core_request` consumes +/// the request. Returns the scratch bag to be `extend`ed into the core request. +fn apply_request_extend(req: &FastlyRequest, extend: F) -> Extensions +where + F: FnOnce(&FastlyRequest, &mut Extensions), +{ + let mut scratch = Extensions::default(); + extend(req, &mut scratch); + scratch +} + +fn dispatch_with_handles( app: &App, req: FastlyRequest, stores: Stores, -) -> Result { - let core_request = into_core_request(req).map_err(|err| map_edge_error(&err))?; + extend: F, +) -> Result +where + F: FnOnce(&FastlyRequest, &mut Extensions), +{ + // Read raw-request signals into a scratch bag BEFORE conversion consumes `req`. + let scratch = apply_request_extend(&req, extend); + let mut core_request = into_core_request(req).map_err(|err| map_edge_error(&err))?; + core_request.extensions_mut().extend(scratch); dispatch_core_request(app, core_request, stores) } @@ -292,14 +312,18 @@ fn dispatch_with_handles( /// id default). KV failures escalate via [`resolve_kv_handle`]'s /// `kv_required=true` path; missing config / secret stores degrade silently /// with a one-time warning. -pub(crate) fn dispatch_with_registries( +pub(crate) fn dispatch_with_registries( app: &App, req: FastlyRequest, config_meta: Option, kv_meta: Option, secret_meta: Option, env: &EnvConfig, -) -> Result { + extend: F, +) -> Result +where + F: FnOnce(&FastlyRequest, &mut Extensions), +{ let kv_registry = build_kv_registry(kv_meta, env)?; let config_registry = build_config_registry(config_meta, env); let secret_registry = build_secret_registry(secret_meta, env); @@ -312,6 +336,7 @@ pub(crate) fn dispatch_with_registries( secret_registry, ..Default::default() }, + extend, ) } @@ -573,6 +598,65 @@ mod synthesis_tests { SecretHandle::new(Arc::new(NoopSecretStore)) } + #[test] + fn apply_request_extend_populates_scratch_from_raw_request() { + use edgezero_core::http::Method; + + #[derive(Clone, Debug, PartialEq)] + struct Ja4(String); + + let raw = FastlyRequest::new(Method::GET, "http://example.test/"); + let scratch = apply_request_extend(&raw, |req, extensions| { + // A real closure would call req.get_tls_ja4(); deriving from the URL + // keeps the assertion deterministic under Viceroy. + let marker = req.get_url_str().to_owned(); + extensions.insert(Ja4(marker)); + }); + + assert_eq!( + scratch.get::(), + Some(&Ja4("http://example.test/".to_owned())) + ); + } + + #[test] + fn extended_request_extensions_are_visible_to_handler() { + use edgezero_core::body::Body; + use edgezero_core::context::RequestContext; + use edgezero_core::http::{request_builder, Method, StatusCode}; + use edgezero_core::router::RouterService; + use futures::executor::block_on; + + #[derive(Clone)] + struct Ja4(String); + + async fn handler(ctx: RequestContext) -> Result { + let ja4 = ctx + .request() + .extensions() + .get::() + .map_or_else(|| "missing".to_owned(), |value| value.0.clone()); + Ok(ja4) + } + + // Mirror what `dispatch_with_handles` does: a scratch bag built from the + // raw request is `extend`ed into the core request before dispatch. + let mut scratch = Extensions::default(); + scratch.insert(Ja4("t13d1516h2".to_owned())); + + let mut core_request = request_builder() + .method(Method::GET) + .uri("/ja4") + .body(Body::empty()) + .expect("request"); + core_request.extensions_mut().extend(scratch); + + let service = RouterService::builder().get("/ja4", handler).build(); + let response = block_on(service.oneshot(core_request)).expect("response"); + assert_eq!(response.status(), StatusCode::OK); + assert_eq!(response.body().as_bytes().expect("buffered"), b"t13d1516h2"); + } + #[test] fn synthesis_wraps_bare_kv_handle_under_default_when_no_registry() { let stores = Stores { From 77b001a075bcef9b4ebad0bde3bbc5bafd67ea3c Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Sat, 4 Jul 2026 22:22:52 -0700 Subject: [PATCH 63/74] feat(macros): app!(state = ) emits RouterBuilder::with_state for macro apps --- crates/edgezero-macros/src/app.rs | 50 ++++++++++++++++++++++++++++++- 1 file changed, 49 insertions(+), 1 deletion(-) diff --git a/crates/edgezero-macros/src/app.rs b/crates/edgezero-macros/src/app.rs index ba1e5fa7..d9f2cc73 100644 --- a/crates/edgezero-macros/src/app.rs +++ b/crates/edgezero-macros/src/app.rs @@ -14,6 +14,7 @@ struct AppArgs { app_ident: Option, owns_logging: Option, path: LitStr, + state: Option, } impl Parse for AppArgs { @@ -21,6 +22,7 @@ impl Parse for AppArgs { let path: LitStr = input.parse()?; let mut app_ident: Option = None; let mut owns_logging: Option = None; + let mut state: Option = None; let mut seen_keyword = false; while input.peek(Token![,]) { @@ -42,10 +44,18 @@ impl Parse for AppArgs { let value: syn::LitBool = input.parse()?; owns_logging = Some(value.value); } + "state" => { + if state.is_some() { + return Err(syn::Error::new(key.span(), "duplicate `state` argument")); + } + state = Some(input.parse::()?); + } other => { return Err(syn::Error::new( key.span(), - format!("unknown `app!` argument `{other}`; expected `owns_logging`"), + format!( + "unknown `app!` argument `{other}`; expected `state` or `owns_logging`" + ), )); } } @@ -73,6 +83,7 @@ impl Parse for AppArgs { app_ident, owns_logging, path, + state, }) } } @@ -198,6 +209,11 @@ pub fn expand_app(input: TokenStream) -> TokenStream { let manifest_path_lit = LitStr::new(&manifest_path.to_string_lossy(), Span::call_site()); let owns_logging_lit = args.owns_logging.unwrap_or(false); + // Emitted only when `state = ` is given; `Option: ToTokens` + // renders `None` as nothing, so an app without `state` is unchanged. + let state_call = args.state.as_ref().map(|state_expr| { + quote! { builder = builder.with_state(#state_expr); } + }); // The emitted `Hooks` impl below explicitly defines `configure`, // `owns_logging`, and `build_app` even though their bodies mirror the trait @@ -237,6 +253,7 @@ pub fn expand_app(input: TokenStream) -> TokenStream { pub fn build_router() -> edgezero_core::router::RouterService { let mut builder = edgezero_core::router::RouterService::builder(); builder = builder.with_manifest_json(#manifest_json_lit); + #state_call #(#middleware_tokens)* #(#route_tokens)* builder.build() @@ -355,6 +372,37 @@ mod tests { assert_eq!(args.path.value(), "edgezero.toml"); assert!(args.app_ident.is_none()); assert_eq!(args.owns_logging, None); + assert!(args.state.is_none()); + } + + #[test] + fn app_args_parses_state_expr() { + let args: AppArgs = + parse_str(r#""edgezero.toml", state = crate::app_state()"#).expect("parse"); + let rendered = args.state.map(|expr| quote::quote!(#expr).to_string()); + assert_eq!(rendered, Some("crate :: app_state ()".to_owned())); + assert!(args.app_ident.is_none()); + assert_eq!(args.owns_logging, None); + } + + #[test] + fn app_args_parses_state_with_app_ident_and_owns_logging() { + let args: AppArgs = + parse_str(r#""edgezero.toml", MyApp, state = crate::app_state(), owns_logging = true"#) + .expect("parse"); + assert_eq!( + args.app_ident.map(|ident| ident.to_string()), + Some("MyApp".to_owned()) + ); + assert_eq!(args.owns_logging, Some(true)); + assert!(args.state.is_some()); + } + + #[test] + fn app_args_rejects_duplicate_state() { + let err = parse_str::(r#""edgezero.toml", state = a(), state = b()"#) + .expect_err("duplicate state"); + assert!(err.to_string().contains("duplicate `state`"), "got: {err}"); } #[test] From 6feed2ade05933c847401b053675906d226b8661 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Sat, 4 Jul 2026 22:31:46 -0700 Subject: [PATCH 64/74] docs(app-demo): app!(state = ...) + State handler example with end-to-end test --- .../crates/app-demo-core/src/handlers.rs | 34 ++++++++++++++++++- .../app-demo/crates/app-demo-core/src/lib.rs | 21 +++++++++++- examples/app-demo/edgezero.toml | 8 +++++ 3 files changed, 61 insertions(+), 2 deletions(-) diff --git a/examples/app-demo/crates/app-demo-core/src/handlers.rs b/examples/app-demo/crates/app-demo-core/src/handlers.rs index 8358ac5c..ae112669 100644 --- a/examples/app-demo/crates/app-demo-core/src/handlers.rs +++ b/examples/app-demo/crates/app-demo-core/src/handlers.rs @@ -1,11 +1,14 @@ use std::env; +use std::sync::Arc; use bytes::Bytes; use edgezero_core::action; use edgezero_core::body::Body; use edgezero_core::context::RequestContext; use edgezero_core::error::EdgeError; -use edgezero_core::extractor::{AppConfig, Headers, Json, Kv, Path, Query, Secrets, ValidatedPath}; +use edgezero_core::extractor::{ + AppConfig, Headers, Json, Kv, Path, Query, Secrets, State, ValidatedPath, +}; use edgezero_core::http::{self, Response, StatusCode, Uri}; use edgezero_core::proxy::ProxyRequest; use edgezero_core::response::Text; @@ -304,6 +307,16 @@ pub async fn secrets_echo( Ok(Text::new(value)) } +/// Demonstrates app-owned shared state injected via `app!(..., state = ...)`: +/// the `State>` extractor resolves the value the macro-generated +/// router registered with `RouterBuilder::with_state`. +#[action] +pub async fn state_demo( + State(state): State>, +) -> Result, EdgeError> { + Ok(Text::new(state.greeting.clone())) +} + #[cfg(test)] mod tests { use super::*; @@ -1009,4 +1022,23 @@ mod tests { "chunk 0\nchunk 1\nchunk 2\n" ); } + + #[test] + fn state_demo_handler_reads_app_state_through_macro_router() { + // build_router() is macro-generated and now calls `.with_state(crate::app_state())`. + let service = crate::build_router(); + + let request = request_builder() + .method(Method::GET) + .uri("/state-demo") + .body(Body::empty()) + .expect("request"); + + let response = block_on(service.oneshot(request)).expect("response"); + assert_eq!(response.status(), StatusCode::OK); + assert_eq!( + response.body().as_bytes().expect("buffered"), + b"hello from app state" + ); + } } diff --git a/examples/app-demo/crates/app-demo-core/src/lib.rs b/examples/app-demo/crates/app-demo-core/src/lib.rs index d3c87003..59892387 100644 --- a/examples/app-demo/crates/app-demo-core/src/lib.rs +++ b/examples/app-demo/crates/app-demo-core/src/lib.rs @@ -8,4 +8,23 @@ pub mod config; // internally; pub visibility is purely additive. pub mod handlers; -edgezero_core::app!("../../edgezero.toml"); +use std::sync::Arc; + +/// App-owned shared state for the `app!(..., state = ...)` demonstration, +/// handed to handlers via `State>`. +#[derive(Debug)] +pub struct DemoState { + /// A greeting the handler echoes, proving the value reached the handler. + pub greeting: String, +} + +/// Constructs the shared app state. Referenced by `app!(..., state = crate::app_state())`. +#[must_use] +#[inline] +pub fn app_state() -> Arc { + Arc::new(DemoState { + greeting: "hello from app state".to_owned(), + }) +} + +edgezero_core::app!("../../edgezero.toml", state = crate::app_state()); diff --git a/examples/app-demo/edgezero.toml b/examples/app-demo/edgezero.toml index f5462639..123e5903 100644 --- a/examples/app-demo/edgezero.toml +++ b/examples/app-demo/edgezero.toml @@ -22,6 +22,14 @@ methods = ["GET"] handler = "app_demo_core::handlers::echo" adapters = ["axum", "cloudflare", "fastly", "spin"] +[[triggers.http]] +id = "state-demo" +path = "/state-demo" +methods = ["GET"] +handler = "app_demo_core::handlers::state_demo" +adapters = ["axum", "cloudflare", "fastly", "spin"] +description = "Reads app-owned state via State> (app!(state = ...))" + [[triggers.http]] id = "stream" path = "/stream" From c01c490ee01330e8c650d803f9f54725d86b1f39 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Sun, 5 Jul 2026 00:03:58 -0700 Subject: [PATCH 65/74] docs(p0 plans): reconcile with implementation (AppArgs Debug, mod tests, crate-root app-demo state, real route TOML) --- ...4-edgezero-p0c-fastly-dispatch-fidelity.md | 19 +++-- ...2026-07-04-edgezero-p0d-app-macro-state.md | 71 ++++++++----------- 2 files changed, 45 insertions(+), 45 deletions(-) diff --git a/docs/superpowers/plans/2026-07-04-edgezero-p0c-fastly-dispatch-fidelity.md b/docs/superpowers/plans/2026-07-04-edgezero-p0c-fastly-dispatch-fidelity.md index 08eb5ec8..1b109283 100644 --- a/docs/superpowers/plans/2026-07-04-edgezero-p0c-fastly-dispatch-fidelity.md +++ b/docs/superpowers/plans/2026-07-04-edgezero-p0c-fastly-dispatch-fidelity.md @@ -451,6 +451,9 @@ Expected: FAIL — the current `AppArgs` has no `owns_logging` field and rejects Replace `crates/edgezero-macros/src/app.rs:12-31` with: ```rust +// `#[derive(Debug)]` is required: the `app_args_rejects_*` unit tests use +// `.expect_err(..)`, whose `Ok` arm (`AppArgs`) must be `Debug`. +#[derive(Debug)] struct AppArgs { app_ident: Option, owns_logging: Option, @@ -555,15 +558,23 @@ Then create the integration test `crates/edgezero-macros/tests/app_macro.rs`: ```rust //! Integration coverage: `app!(..., owns_logging = true)` emits a `Hooks` impl //! whose `owns_logging()` returns `true`. The manifest path resolves against -//! this crate's CARGO_MANIFEST_DIR, so the fixture is `tests/fixtures/...`. +//! this crate's `CARGO_MANIFEST_DIR` (backticks required — `doc_markdown`), so +//! the fixture is `tests/fixtures/...`. // The macro emits `pub struct OwnedLoggingApp;`, a `Hooks` impl, and a free // `build_router()` at this module scope. edgezero_core::app!("tests/fixtures/owns_logging.toml", OwnedLoggingApp, owns_logging = true); -#[test] -fn app_macro_emits_owns_logging_true() { - assert!(::owns_logging()); +// `#[test]` must live in a `#[cfg(test)] mod tests` (the `tests_outside_test_module` +// restriction lint), and `Hooks` must be imported (not a 3-segment path — `absolute_paths`). +#[cfg(test)] +mod tests { + use edgezero_core::app::Hooks as _; + + #[test] + fn app_macro_emits_owns_logging_true() { + assert!(super::OwnedLoggingApp::owns_logging()); + } } ``` diff --git a/docs/superpowers/plans/2026-07-04-edgezero-p0d-app-macro-state.md b/docs/superpowers/plans/2026-07-04-edgezero-p0d-app-macro-state.md index f1f6e01e..ee83e0f7 100644 --- a/docs/superpowers/plans/2026-07-04-edgezero-p0d-app-macro-state.md +++ b/docs/superpowers/plans/2026-07-04-edgezero-p0d-app-macro-state.md @@ -25,9 +25,8 @@ | File | Responsibility | Task | | ---- | -------------- | ---- | | `crates/edgezero-macros/src/app.rs` | `AppArgs` gains `state: Option`; parser adds the `state` arm; `build_router()` emits `.with_state(#state)`; `AppArgs` unit tests | 1 | -| `examples/app-demo/crates/app-demo-core/src/state.rs` (new) | `DemoState` + `app_state()` constructor | 2 | +| `examples/app-demo/crates/app-demo-core/src/lib.rs` | crate-root `DemoState` + `app_state()`; `app!(…, state = crate::app_state())` | 2 | | `examples/app-demo/crates/app-demo-core/src/handlers.rs` | a `State>` `#[action]` handler + host test through `build_router()` | 2 | -| `examples/app-demo/crates/app-demo-core/src/lib.rs` | `app!(…, state = crate::app_state())`; `pub mod state;` | 2 | | `examples/app-demo/edgezero.toml` | route for the state-demo handler | 2 | --- @@ -173,25 +172,27 @@ git commit -m "feat(macros): app!(state = ) emits RouterBuilder::with_stat Prove the whole chain: `app!(state = crate::app_state())` → generated `build_router()` calls `.with_state(...)` → dispatch injects → a `State>` handler reads it. This runs **host-side** through `build_router()` (no adapter/hostcalls), mirroring the existing `crate::build_router()` test at `handlers.rs:436`. **Files:** -- Create: `examples/app-demo/crates/app-demo-core/src/state.rs` -- Modify: `examples/app-demo/crates/app-demo-core/src/lib.rs` (add `pub mod state;`, add `state = crate::app_state()` to `app!`) +- Modify: `examples/app-demo/crates/app-demo-core/src/lib.rs` (define `DemoState` + `app_state()` at the **crate root**; add `state = crate::app_state()` to `app!`) - Modify: `examples/app-demo/crates/app-demo-core/src/handlers.rs` (add the `State` handler + host test) - Modify: `examples/app-demo/edgezero.toml` (route for the handler) **Interfaces:** - Consumes: `edgezero_core::extractor::State`, `edgezero_core::action`, `RouterService::oneshot` (from #306), `crate::build_router()` (macro-generated). -- Produces: `crate::state::{DemoState, app_state}`; `crate::handlers::state_demo` handler. +- Produces: crate-root `crate::{DemoState, app_state}`; `crate::handlers::state_demo` handler. -- [ ] **Step 1: Create the state module** +> **Design note (why crate root, not a `state` module):** app-demo is its own workspace with a stricter lint set — `pub_use` and `module_name_repetitions` are **denied** (unlike the root workspace which allows them). A `pub mod state;` + `pub use crate::state::app_state;` trips `pub_use`, and `DemoState`/`app_state` inside a module named `state` trip `module_name_repetitions`. Defining both at the crate root (in `lib.rs`) avoids all three with no `#[allow]`, and `crate::app_state()` / `crate::DemoState` resolve directly where the macro emits them. -Create `examples/app-demo/crates/app-demo-core/src/state.rs`: +- [ ] **Step 1: Define `DemoState` + `app_state()` at the crate root and wire `app!`** + +In `examples/app-demo/crates/app-demo-core/src/lib.rs`, add the state types at the crate root (after the `pub mod` declarations) and add the `state` argument to the `app!` invocation (`lib.rs:11`): ```rust -//! App-owned shared state for the `app!(..., state = ...)` demonstration. +pub mod handlers; use std::sync::Arc; -/// Minimal app-owned state handed to handlers via `State>`. +/// App-owned shared state for the `app!(..., state = ...)` demonstration, +/// handed to handlers via `State>`. #[derive(Debug)] pub struct DemoState { /// A greeting the handler echoes, proving the value reached the handler. @@ -200,59 +201,48 @@ pub struct DemoState { /// Constructs the shared app state. Referenced by `app!(..., state = crate::app_state())`. #[must_use] +#[inline] pub fn app_state() -> Arc { Arc::new(DemoState { greeting: "hello from app state".to_owned(), }) } -``` - -- [ ] **Step 2: Wire the module + `app!` state argument** -In `examples/app-demo/crates/app-demo-core/src/lib.rs`: add `pub mod state;` alongside the other `pub mod` declarations (alphabetical position), and change the `app!` invocation (`lib.rs:11`): - -```rust edgezero_core::app!("../../edgezero.toml", state = crate::app_state()); ``` -Add a re-export so `crate::app_state()` resolves at the crate root where the macro emits it (the macro emits the call inside `build_router()` in this crate, so `crate::app_state` must be reachable). Re-export from the state module: - -```rust -pub use crate::state::app_state; -``` +(`#[inline]` is required by `missing_inline_in_public_items`; `#[derive(Debug)]` keeps the public struct debuggable.) -- [ ] **Step 3: Add the `State` handler** +- [ ] **Step 2: Add the `State` handler** -In `examples/app-demo/crates/app-demo-core/src/handlers.rs`, add a handler (place its `fn` in the correct position per `arbitrary_source_item_ordering`; import what it needs — mirror the existing handlers' imports of `edgezero_core::{action, ...}`): +In `examples/app-demo/crates/app-demo-core/src/handlers.rs`, add `State` to the existing `edgezero_core::extractor::{…}` import and `use std::sync::Arc;`, then add the handler (`crate::DemoState` is a 2-segment path — fine under `absolute_paths`). The return type is `Result, EdgeError>` to match the file's other text handlers (e.g. `secrets_echo`); `#[action]` wraps it via `Responder`: ```rust -use edgezero_core::extractor::State; -use std::sync::Arc; - -#[edgezero_core::action] +#[action] pub async fn state_demo( - State(state): State>, -) -> Result { - Ok(state.greeting.clone()) + State(state): State>, +) -> Result, EdgeError> { + Ok(Text::new(state.greeting.clone())) } ``` (If `handlers.rs` already imports `Arc` / an `action` alias, reuse those rather than re-importing — keep the file's existing style.) -- [ ] **Step 4: Register the route in the manifest** +- [ ] **Step 3: Register the route in the manifest** -In `examples/app-demo/edgezero.toml`, add an HTTP trigger for the handler, matching the exact key layout of the existing `[[triggers.http]]` entries in that file (read one first and copy its shape). It will look like: +In `examples/app-demo/edgezero.toml`, add an HTTP trigger for the handler, matching the existing `[[triggers.http]]` entries' exact keys (`id`, `path`, `methods`, `handler`, `adapters`): ```toml [[triggers.http]] +id = "state-demo" path = "/state-demo" -handler = "crate::handlers::state_demo" -method = "GET" +methods = ["GET"] +handler = "app_demo_core::handlers::state_demo" +adapters = ["axum", "cloudflare", "fastly", "spin"] +description = "Reads app-owned state via State> (app!(state = ...))" ``` -(Use whatever `method`/`handler`/`path` keys the file's existing entries use — the point is a GET route bound to `crate::handlers::state_demo`.) - -- [ ] **Step 5: Write the end-to-end host test** +- [ ] **Step 4: Write the end-to-end host test** Add to the `#[cfg(test)] mod tests` in `examples/app-demo/crates/app-demo-core/src/handlers.rs` (the module that already contains the `crate::build_router()` test at `handlers.rs:436`; reuse its imports for `request_builder`/`Body`/`block_on` — mirror that test). Place the fn alphabetically. @@ -281,12 +271,12 @@ Add to the `#[cfg(test)] mod tests` in `examples/app-demo/crates/app-demo-core/s } ``` -- [ ] **Step 6: Run the e2e test** +- [ ] **Step 5: Run the e2e test** Run: `(cd examples/app-demo && cargo test -p app-demo-core state_demo_handler_reads_app_state 2>&1 | tail -12)` Expected: PASS — the macro-generated router injected `Arc`, the `State` extractor resolved it, and the handler echoed `greeting`. (First run may FAIL if the route/handler/state wiring is incomplete; fix until green.) -- [ ] **Step 7: Full app-demo + workspace verification** +- [ ] **Step 6: Full app-demo + workspace verification** Run: `(cd examples/app-demo && cargo test 2>&1 | tail -8)` Expected: PASS (all four adapter example crates still build; app-demo-core tests green). @@ -299,11 +289,10 @@ cargo clippy --workspace --all-targets --all-features -- -D warnings ``` Expected: both clean (the new handler's fields are read by the assertion, so no `dead_code` suppression is needed). -- [ ] **Step 8: Commit** +- [ ] **Step 7: Commit** ```bash -git add examples/app-demo/crates/app-demo-core/src/state.rs \ - examples/app-demo/crates/app-demo-core/src/lib.rs \ +git add examples/app-demo/crates/app-demo-core/src/lib.rs \ examples/app-demo/crates/app-demo-core/src/handlers.rs \ examples/app-demo/edgezero.toml git commit -m "docs(app-demo): app!(state = ...) + State handler example with end-to-end test" From d8f71a4a8e201abf685f40c7cbd4276614bc11ee Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Sun, 5 Jul 2026 00:41:02 -0700 Subject: [PATCH 66/74] fix(ci): nested-AppConfig guard footer states the #[app_config(nested)] opt-in; check off completed plan boxes --- .../src/bin/check_no_nested_app_config.rs | 6 +- .../2026-07-02-edgezero-nested-secrets.md | 92 +++++++++---------- .../2026-07-02-edgezero-state-extractor.md | 46 +++++----- ...4-edgezero-p0c-fastly-dispatch-fidelity.md | 72 +++++++-------- ...2026-07-04-edgezero-p0d-app-macro-state.md | 30 +++--- 5 files changed, 123 insertions(+), 123 deletions(-) diff --git a/crates/edgezero-cli/src/bin/check_no_nested_app_config.rs b/crates/edgezero-cli/src/bin/check_no_nested_app_config.rs index a9f5c26f..62151e0e 100644 --- a/crates/edgezero-cli/src/bin/check_no_nested_app_config.rs +++ b/crates/edgezero-cli/src/bin/check_no_nested_app_config.rs @@ -370,9 +370,9 @@ fn main() { if violations > 0 { eprintln!( "\n{violations} nested-AppConfig violation(s). \ - A struct with #[derive(AppConfig)] must not contain fields whose \ - type resolves to another #[derive(AppConfig)] struct, even through \ - Option/Vec/Box wrappers (spec \u{00a7}3.3)." + A field whose type resolves to another #[derive(AppConfig)] struct \ + (even through Option/Vec/Box wrappers) must opt in with \ + #[app_config(nested)]; otherwise nesting is rejected (spec \u{00a7}3.3)." ); process::exit(1); } diff --git a/docs/superpowers/plans/2026-07-02-edgezero-nested-secrets.md b/docs/superpowers/plans/2026-07-02-edgezero-nested-secrets.md index d0976a8d..81087e39 100644 --- a/docs/superpowers/plans/2026-07-02-edgezero-nested-secrets.md +++ b/docs/superpowers/plans/2026-07-02-edgezero-nested-secrets.md @@ -76,7 +76,7 @@ pub trait AppConfigMeta { fn secret_fields() -> Vec; } // was: cons // SecretKind is UNCHANGED (still Copy, store_ref_field: &'static str). ``` -- [ ] **Step 1: Write the failing metadata unit tests** +- [x] **Step 1: Write the failing metadata unit tests** Append to `crates/edgezero-core/src/app_config.rs`'s `#[cfg(test)] mod tests` (module starts near `app_config.rs:599`; it already imports `SecretField`, `SecretKind`): @@ -117,12 +117,12 @@ Append to `crates/edgezero-core/src/app_config.rs`'s `#[cfg(test)] mod tests` (m } ``` -- [ ] **Step 2: Run it (fails to compile)** +- [x] **Step 2: Run it (fails to compile)** Run: `cargo test -p edgezero-core --lib dotted_path_renders 2>&1 | tail -15` Expected: FAIL — `SecretPathSegment` / `SecretField.path` / `dotted_path` do not exist. -- [ ] **Step 3: Reshape the metadata types + trait in `app_config.rs`** +- [x] **Step 3: Reshape the metadata types + trait in `app_config.rs`** Add `use std::borrow::Cow;` near the top of `crates/edgezero-core/src/app_config.rs` (with the other `use`s). Replace the `AppConfigMeta` trait (`app_config.rs:34-37`), the `SecretField` struct (`app_config.rs:41-48`), and add `SecretPathSegment` + `dotted_path`. `SecretKind` (`app_config.rs:53-69`) is **unchanged**. @@ -186,12 +186,12 @@ pub trait AppConfigMeta { Note: `SecretField` and `SecretPathSegment` are **no longer `Copy`** (they own a `Vec`/`Cow`). This is intentional per §8 [B, BLOCKER]. `SecretKind` stays `Copy`. -- [ ] **Step 4: Run the metadata test (passes)** +- [x] **Step 4: Run the metadata test (passes)** Run: `cargo test -p edgezero-core --lib dotted_path_renders 2>&1 | tail -15` Expected: PASS. (The crate will not fully build yet — consumers still reference the old shape. Fix them in the next steps.) -- [ ] **Step 5: Update `validate_excluding_secrets` to the new shape (flat behavior preserved)** +- [x] **Step 5: Update `validate_excluding_secrets` to the new shape (flat behavior preserved)** In `crates/edgezero-core/src/app_config.rs:204-226`, the loop currently does `bag.remove(field.name)`. For Task 1, keep flat removal but source the key from the length-1 path. (Task 3 replaces this with nested/list-aware pruning.) Change the loop body: @@ -211,7 +211,7 @@ In `crates/edgezero-core/src/app_config.rs:204-226`, the loop currently does `ba Add `use SecretPathSegment` access (it's in the same module, reference as `SecretPathSegment::Field`). -- [ ] **Step 6: Update `secret_walk` to the new shape (top-level behavior preserved)** +- [x] **Step 6: Update `secret_walk` to the new shape (top-level behavior preserved)** In `crates/edgezero-core/src/extractor.rs:827-894`, change the import at `extractor.rs:8` to also bring in the path segment, and change the loop to source the key/field name from the length-1 path. (Task 2 replaces the whole body with a recursive navigator.) Minimal Task-1 change: replace `for field in C::SECRET_FIELDS` with `for field in C::secret_fields()`, and replace each `field.name` use with a locally computed `let leaf = field.dotted_path();` for error hints and `let leaf_key = match field.path.last() { Some(SecretPathSegment::Field(n)) => n.as_ref(), _ => /* length-1 guaranteed in Task 1 */ };` for the `data_obj.get(...)`/`insert(...)` calls. Concretely, at the top of the loop: @@ -234,7 +234,7 @@ In `crates/edgezero-core/src/extractor.rs:827-894`, change the import at `extrac Update `crates/edgezero-core/src/extractor.rs:8` from `use crate::app_config::{AppConfigMeta, SecretKind};` to `use crate::app_config::{AppConfigMeta, SecretKind, SecretPathSegment};`. Apply the `leaf_key`/`hint` substitution throughout the existing loop body (the `data_obj.get(field.name)`, the `data_obj.insert(field.name.to_owned(), ...)`, and every `field.name.to_owned()` error arg become `leaf_key.as_str()` / `hint.clone()` respectively; `store_ref_field` handling is unchanged — it's still a top-level sibling in Task 1). -- [ ] **Step 7: Flip the emitter in `edgezero-macros/src/app_config.rs` to `fn` + length-1 paths** +- [x] **Step 7: Flip the emitter in `edgezero-macros/src/app_config.rs` to `fn` + length-1 paths** In `crates/edgezero-macros/src/app_config.rs`, change the per-entry emission (`app_config.rs:128-150`) and the impl block (`app_config.rs:152-166`). The entries currently emit `SecretField { name: #name_lit, kind: #kind_tokens }`; change to owned length-1 paths: @@ -290,7 +290,7 @@ And the impl block (`app_config.rs:152-166`) — change the `const` to a `fn`: } ``` -- [ ] **Step 8: Make `TypedSecretEntry.field_name` owned** +- [x] **Step 8: Make `TypedSecretEntry.field_name` owned** In `crates/edgezero-adapter/src/registry.rs:174-198`, change `field_name: &'entry str` to owned `String`. **Keep `new`'s param generic as `impl Into`** so the 7 existing `&str`-literal call sites in the Spin tests (`adapter-spin/src/cli.rs:1292/1307/1327/1328/1344/1345/1357/1389/1390`) keep compiling unchanged, while the CLI callers pass owned dotted labels: @@ -324,7 +324,7 @@ impl<'entry> TypedSecretEntry<'entry> { Because `&str: Into` and `String: Into`, no `TypedSecretEntry::new` call site needs editing for the signature change (only the CLI callers change *what* they pass — a dotted label — in Task 6). -- [ ] **Step 9: Update the Spin collision check to the owned label** +- [x] **Step 9: Update the Spin collision check to the owned label** In `crates/edgezero-adapter-spin/src/cli.rs:514-552`, the logic keys on `entry.key_value` (the secret value) — unchanged. Only the `seen` map value type shifts from `&str` (borrowing a `&'static` name) to a borrow of the owned `String`. Change the map value binding so it borrows `entry.field_name`: @@ -352,7 +352,7 @@ In `crates/edgezero-adapter-spin/src/cli.rs:514-552`, the logic keys on `entry.k (Only two edits vs. today: `entry.field_name` is now a `String` so it interpolates the same in `format!`, and the `seen.insert(..., entry.field_name)` becomes `entry.field_name.as_str()`.) -- [ ] **Step 10: Update the CLI consumers + all test `impl AppConfigMeta` sites** +- [x] **Step 10: Update the CLI consumers + all test `impl AppConfigMeta` sites** In `crates/edgezero-cli/src/config.rs`, update the two runtime consumers to the new shape (still flat/length-1 in Task 1 — full path navigation lands in Task 6): @@ -410,7 +410,7 @@ App-demo assertion at `examples/app-demo/crates/app-demo-core/src/config.rs:124- The derive assertions in `app_config_derive.rs:71-126` change from comparing `SECRET_FIELDS` slices to comparing `secret_fields()` `Vec`s against `SecretField { kind, path: vec![SecretPathSegment::Field(Cow::Borrowed("..."))], optional: false }` literals (or, more simply, assert `dotted_path()` + `kind` + `optional` per entry). -- [ ] **Step 11: Build + test the whole workspace (green, behavior identical)** +- [x] **Step 11: Build + test the whole workspace (green, behavior identical)** Run: `cargo build --workspace --all-targets 2>&1 | tail -20` Expected: compiles. @@ -421,7 +421,7 @@ Expected: PASS — all existing secret tests still green (top-level behavior unc Run: `(cd examples/app-demo && cargo test 2>&1 | tail -15)` Expected: PASS (`secret_fields_metadata_matches_declarations`, round-trip, config-flow). -- [ ] **Step 12: Lint + commit** +- [x] **Step 12: Lint + commit** Run: `cargo fmt --all && cargo clippy --workspace --all-targets --all-features -- -D warnings 2>&1 | tail -15` Expected: clean. @@ -448,7 +448,7 @@ Replace `secret_walk`'s top-level loop with a recursive navigator that descends - Consumes: `SecretField { kind, path, optional }`, `SecretPathSegment` (Task 1); `SecretKind` (unchanged); `ctx.secret_store_default()` / `ctx.secret_store(id)` / `bound.require_str(key)` / `map_secret_error` (existing, `extractor.rs:896`); `first_violating_field`'s `[{idx}]` convention. - Produces: a `secret_walk::` that resolves nested/array leaves. Consumed by Task 7 (E2E). -- [ ] **Step 1: Write failing nested/array `secret_walk` tests** +- [x] **Step 1: Write failing nested/array `secret_walk` tests** Append to `crates/edgezero-core/src/extractor.rs` `#[cfg(test)] mod tests`. Mirror the existing `app_config_secret_walk_resolves_key_in_default_store` test (`extractor.rs:2170`) for store setup (`InMemorySecretStore`, `StoreRegistry`, inserting the secret registry into request extensions, building `ctx`). Add three fixtures + tests: @@ -592,12 +592,12 @@ Append to `crates/edgezero-core/src/extractor.rs` `#[cfg(test)] mod tests`. Mirr Add the small test helpers near the existing secret-walk test scaffolding (mirror `extractor.rs:2170`'s store construction). `ctx_with_default_secret_store(key, value)` builds an `InMemorySecretStore` mapping `default/{key}` → `value`, wraps it in a `StoreRegistry` with default id `"default"`, inserts the registry into a request's extensions, and returns the `RequestContext`. `ctx_with_default_secret_store_map(&[(k, v), ...])` is the multi-entry variant. `ctx_with_named_secret_store(store_id, key, value)` registers an `InMemorySecretStore` under `store_id` (mapping `{store_id}/{key}` → `value`) in the registry so `ctx.secret_store(store_id)` resolves — used by the `KeyInNamedStore` tests. (`EdgeError::config_out_of_date` → `StatusCode::SERVICE_UNAVAILABLE` per `error.rs:183`, confirmed.) -- [ ] **Step 2: Run (fails)** +- [x] **Step 2: Run (fails)** Run: `cargo test -p edgezero-core --lib secret_walk_ 2>&1 | tail -25` Expected: FAIL — nested/array data is not navigated (current walk only reads/writes top-level keys); missing-leaf message lacks the dotted path. -- [ ] **Step 3: Rewrite `secret_walk` as a recursive navigator** +- [x] **Step 3: Rewrite `secret_walk` as a recursive navigator** Replace the body of `secret_walk` (`crates/edgezero-core/src/extractor.rs:827-894`) with a path navigator. Keep the signature (`async fn secret_walk(ctx: &RequestContext, data: &mut serde_json::Value) -> Result<(), EdgeError> where C: AppConfigMeta`). New body: @@ -756,7 +756,7 @@ Notes: - The recursion uses a boxed future (WASM-safe; matches the crate's `?Send` async style) because async fns can't recurse directly. - `KeyInNamedStore` resolves `store_ref_field` in `parent_obj` — the **innermost** parent, satisfying the sibling scoping rule for nested secrets. -- [ ] **Step 4: Run (passes)** +- [x] **Step 4: Run (passes)** Run: `cargo test -p edgezero-core --lib secret_walk_ 2>&1 | tail -25` Expected: PASS (nested object, array-each, absent-optional-skip, missing-nested-dotted-error). Also confirm the pre-existing top-level walk tests (`extractor.rs:2170`, `:2198`) still pass: @@ -764,7 +764,7 @@ Expected: PASS (nested object, array-each, absent-optional-skip, missing-nested- Run: `cargo test -p edgezero-core --lib app_config_secret_walk 2>&1 | tail -15` Expected: PASS. -- [ ] **Step 5: Lint + commit** +- [x] **Step 5: Lint + commit** Run: `cargo clippy -p edgezero-core --all-targets --all-features -- -D warnings 2>&1 | tail -15` @@ -786,7 +786,7 @@ Push time must skip the per-field validator of a nested/array secret leaf, whose - Consumes: `C::secret_fields()`, `SecretField.path`, `SecretPathSegment`, validator 0.20 `ValidationErrors`/`ValidationErrorsKind`. - Produces: nested-aware pruning. Consumed by Task 6 (CLI push over nested config) + Task 7. -- [ ] **Step 1: Write failing nested-pruning tests** +- [x] **Step 1: Write failing nested-pruning tests** Append to `app_config.rs` `#[cfg(test)] mod tests`. Model on `validate_excluding_secrets_skips_secret_field_rules` (`app_config.rs:1148`) but with a nested struct fixture whose nested secret leaf has a failing validator (e.g. `#[validate(length(min = 100))]` on the key-name string, which is short at push time). Assert `validate_excluding_secrets` returns `Ok(())` (the nested secret's validator was pruned) while a **non-secret** nested failure still surfaces `Err`. @@ -958,12 +958,12 @@ Append to `app_config.rs` `#[cfg(test)] mod tests`. Model on `validate_excluding Note on the array tests: together they prove the `ValidationErrorsKind::List` branch of `prune_secret_leaf` (Step 3) both (a) removes the secret leaf from **each** indexed element while leaving non-secret siblings, and (b) fully collapses to `Ok(())` when every element's only failure is the secret leaf (the `items.retain(..)` + `clear = items.is_empty()` path) — the `#[secret]`-in-`Vec` case the plan commits to from day one. -- [ ] **Step 2: Run (fails)** +- [x] **Step 2: Run (fails)** Run: `cargo test -p edgezero-core --lib validate_excluding_secrets_prunes_nested 2>&1 | tail -20` Expected: FAIL — the flat `bag.remove(first_segment)` removes the top-level `integrations` entry entirely (over-pruning) or fails to prune the nested leaf, so the assertion is wrong. (Either way the Task-1 flat impl is incorrect for nesting.) -- [ ] **Step 3: Implement nested-aware pruning** +- [x] **Step 3: Implement nested-aware pruning** Replace `validate_excluding_secrets`'s loop (`app_config.rs:204-226`) with a path-aware pruner that navigates `ValidationErrorsKind::Struct`/`List` down each secret field's path, removes the leaf validator, and prunes now-empty containers so a fully-cleared branch disappears (rather than leaving an empty `Struct`/`List` marker that would keep `errors` non-empty). The loop: @@ -1030,12 +1030,12 @@ fn prune_secret_leaf(errors: &mut validator::ValidationErrors, path: &[SecretPat } ``` -- [ ] **Step 4: Run (passes)** +- [x] **Step 4: Run (passes)** Run: `cargo test -p edgezero-core --lib validate_excluding_secrets 2>&1 | tail -20` Expected: PASS — both new tests plus the four pre-existing `validate_excluding_secrets_*` tests (`app_config.rs:1132/1148/1173/1195`). -- [ ] **Step 5: Lint + commit** +- [x] **Step 5: Lint + commit** Run: `cargo clippy -p edgezero-core --all-targets --all-features -- -D warnings 2>&1 | tail -15` @@ -1059,7 +1059,7 @@ Make the derive actually emit nested/array/optional metadata: register the `app_ - Consumes: the Task-1 emitter shape (`fn secret_fields()` returning `Vec` with owned paths + `optional`). - Produces: nested/array/optional metadata for real derived structs. Consumed by Tasks 6 & 7 and app-demo (unchanged top-level app-demo still emits length-1). -- [ ] **Step 1: Register the `app_config` helper attribute** +- [x] **Step 1: Register the `app_config` helper attribute** In `crates/edgezero-macros/src/lib.rs:20`, change: @@ -1073,7 +1073,7 @@ to: #[proc_macro_derive(AppConfig, attributes(secret, app_config))] ``` -- [ ] **Step 2: Write failing derive/UI tests** +- [x] **Step 2: Write failing derive/UI tests** Add happy-path assertions to `crates/edgezero-macros/tests/app_config_derive.rs` (a nested object emits the expected 3-segment path; a `Vec` nested field emits `Field`+`ArrayEach`; `Option` sets `optional: true`). Example: @@ -1143,12 +1143,12 @@ Naming caution: the existing harness globs `compile_fail("tests/ui/secret_*.rs") Note: `app_config_derive.rs` runs `trybuild` only in that single `#[test]`; also add an `Option` **pass** assertion (that it compiles + sets `optional: true`) inside the happy-path `mod tests` above — not as a UI fixture. -- [ ] **Step 3: Run (fails)** +- [x] **Step 3: Run (fails)** Run: `cargo test -p edgezero-macros --test app_config_derive 2>&1 | tail -30` Expected: FAIL — `#[app_config(nested)]` is not parsed (unknown attribute or ignored); `Option` rejected by `is_scalar_string_type`; nested paths not emitted. -- [ ] **Step 4: Parse `#[app_config(nested)]` and classify fields** +- [x] **Step 4: Parse `#[app_config(nested)]` and classify fields** In `crates/edgezero-macros/src/app_config.rs`, add a helper that detects the opt-in and extracts the recursion child type. A field is a **nested recursion** field iff it carries `#[app_config(nested)]`. Determine object-vs-array from the field type: a `Vec`/`[T]` → array (child `T`, emit `Field(field)` + `ArrayEach`); anything else → object (child = the field type, emit `Field(field)`). @@ -1200,7 +1200,7 @@ Modify the main field loop in `expand` (`app_config.rs:62-66`) so that for each **Guard the nested parent field's serde attrs.** The emitter writes `Field(Cow::Borrowed(field_name))` using the Rust field name verbatim, so a `#[serde(rename = "...")]` (or `flatten`/`skip*`) on the `#[app_config(nested)]` field itself would desync the emitted path segment from the serialized key — the exact hazard the spec forbids "anywhere on a secret path" (spec §4.3 point 3, line 282). The existing `enforce_no_disallowed_serde_attrs(field)?` (`app_config.rs:363`) already bans `rename`/`flatten`/`skip`/`skip_deserializing`/`skip_serializing`/`skip_serializing_if`. Call it on every nested-recursion field (it currently runs only on `#[secret]` fields via `scan_field`). Each struct on the path self-enforces this for its own fields, so `rename` at any level along the path is rejected by whichever struct declares that field. -- [ ] **Step 5: Emit recursion into `secret_fields()`** +- [x] **Step 5: Emit recursion into `secret_fields()`** Extend the emitter (Task 1's `entries`) to also emit, for each nested descriptor, a loop that prepends segments onto the child's `secret_fields()`. Change the `fn secret_fields()` body emission to build a `Vec` imperatively: @@ -1271,7 +1271,7 @@ Additionally, emit an explicit **`AppConfigRoot`** assertion per nested child (s where `#nested_child_tys` is the list of the recursion child types (the object field type, or the `Vec`/slice element type). A nested field whose type does not derive `AppConfig` fails with "the trait bound `Child: AppConfigRoot` is not satisfied" — the `app_config_nested_on_non_appconfig.rs` UI fixture pins this message. -- [ ] **Step 6: Relax scalar rule to accept `Option`; set `optional`** +- [x] **Step 6: Relax scalar rule to accept `Option`; set `optional`** Change `is_scalar_string_type`/`enforce_scalar_string_type` (`app_config.rs:265-284`) to also accept `Option`, and have `scan_field` (`app_config.rs:195-219`) report whether the secret type was optional so the emitter sets `optional`. Add: @@ -1321,7 +1321,7 @@ Replace `enforce_scalar_string_type(field)?;` (`app_config.rs:215`, called from and thread `optional` into `FieldAnnotation` (add a `bool` field), then into the direct-entry emission (`optional: #optional_lit`). Keep rejecting `Vec`, `Cow<'_, str>`, non-string scalars (they yield `None`). Note the runtime walk already early-returns for `StoreRef` regardless of `optional`; this compile-time guard removes the ambiguity at the source so CLI validation and the walk never see an optional store id. -- [ ] **Step 7: Extend the `rename_all` guard to nested-only parents** +- [x] **Step 7: Extend the `rename_all` guard to nested-only parents** The guard fires today only when `!annotations.is_empty()` (`app_config.rs:75-77`, direct `#[secret]` fields present). A parent whose secrets are all in `#[app_config(nested)]` children has no direct annotations but its own `rename_all` would still desync the emitted `Field(parent_field)` segment. Change the gate to also fire when any nested descriptor exists: @@ -1331,12 +1331,12 @@ The guard fires today only when `!annotations.is_empty()` (`app_config.rs:75-77` } ``` -- [ ] **Step 8: Run (passes)** +- [x] **Step 8: Run (passes)** Run: `cargo test -p edgezero-macros --test app_config_derive 2>&1 | tail -30` Expected: PASS — happy-path nested/array/optional emission + all UI compile-fail fixtures match their `.stderr`. Regenerate `.stderr` with `TRYBUILD=overwrite cargo test -p edgezero-macros --test app_config_derive` if messages differ, then inspect the diffs for correctness before committing. -- [ ] **Step 9: Lint + commit** +- [x] **Step 9: Lint + commit** Run: `cargo clippy -p edgezero-macros --all-targets --all-features -- -D warnings 2>&1 | tail -15` @@ -1359,7 +1359,7 @@ The `check_no_nested_app_config` binary currently rejects **any** `AppConfig` st - Consumes: `syn` field attributes (the binary already parses structs with `syn::visit`). - Produces: a guard that permits opted-in nesting; still fails on un-opted-in nesting. -- [ ] **Step 1: Write failing guard tests** +- [x] **Step 1: Write failing guard tests** Add a `#[cfg(test)] mod tests` to `crates/edgezero-cli/src/bin/check_no_nested_app_config.rs`. The binary is behind `#![cfg(feature = "nested-app-config-check")]`, so tests run only with that feature. Test the pure helpers by parsing source snippets with `syn::parse_file` and running the collector + visitor: @@ -1414,12 +1414,12 @@ mod tests { (If the collector/visitor don't currently expose `default()`/`new()`/public fields for construction in tests, add minimal `#[derive(Default)]` / a `new` constructor / `pub(crate)` visibility as part of this task — they're in the same binary crate.) -- [ ] **Step 2: Run (fails)** +- [x] **Step 2: Run (fails)** Run: `cargo test -p edgezero-cli --features nested-app-config-check --bin check_no_nested_app_config 2>&1 | tail -20` Expected: FAIL — `allows_nesting_with_opt_in` sees 1 violation (the guard flags all nesting today). -- [ ] **Step 3: Teach the visitor to honor `#[app_config(nested)]`** +- [x] **Step 3: Teach the visitor to honor `#[app_config(nested)]`** In `NestedAppConfigVisitor::visit_item_struct` (`check_no_nested_app_config.rs:156-181`), before reporting a violation for a field whose type contains an `AppConfig` struct, skip it if the field carries `#[app_config(nested)]`. Add a helper mirroring the macro's detection and guard the report: @@ -1465,7 +1465,7 @@ and in the field loop, wrap the existing `if let Some(inner_name) = type_contain (Adjust the `report` call to match the current signature at `check_no_nested_app_config.rs:138-149`.) -- [ ] **Step 4: Run (passes) + run the guard over the real trees** +- [x] **Step 4: Run (passes) + run the guard over the real trees** Run: `cargo test -p edgezero-cli --features nested-app-config-check --bin check_no_nested_app_config 2>&1 | tail -20` Expected: PASS. @@ -1473,7 +1473,7 @@ Expected: PASS. Run: `cargo run -q -p edgezero-cli --bin check_no_nested_app_config --features nested-app-config-check -- examples/app-demo crates/edgezero-cli/src/templates 2>&1 | tail -5` Expected: `check_no_nested_app_config: OK` (app-demo has no opted-in nesting yet; still clean). -- [ ] **Step 5: Lint + commit** +- [x] **Step 5: Lint + commit** Run: `cargo clippy -p edgezero-cli --features nested-app-config-check --bin check_no_nested_app_config -- -D warnings 2>&1 | tail -15` @@ -1495,7 +1495,7 @@ Task 1 made the CLI consumers compile against the new shape but only navigate to - Consumes: `SecretField.path`/`optional`, `toml::Value`, `TypedSecretEntry::new(store_id, String, key_value)` (Task 1). - Produces: path-aware validate/push/diff. Consumed by acceptance (nested config validates/pushes). -- [ ] **Step 1: Write failing CLI navigation tests** +- [x] **Step 1: Write failing CLI navigation tests** Add tests to `crates/edgezero-cli/src/config.rs` `#[cfg(test)] mod tests`, driven through the **public** `run_config_validate_typed::` entry point (which calls both `typed_secret_checks` and `run_adapter_typed_checks`). `ValidationContext` has private fields and a `ManifestLoader` that is impractical to build by hand, so mirror the existing harness: write a manifest + `demo-app.toml` to a tempdir with `setup_project(manifest, app_config)` (`config.rs:1662`, returns `(TempDir, manifest_path, app_config_path)`) and pass `args_for(&manifest_path)` (`config.rs:1671`). The config type is a **real** nested `#[derive(AppConfig)]` (Task 4 is complete by Task 6), which also proves derive→CLI integration. @@ -1718,7 +1718,7 @@ vault = "named" } ``` -- [ ] **Step 2: Factor a TOML path leaf-collector** +- [x] **Step 2: Factor a TOML path leaf-collector** Add a helper that, given the raw `&toml::Value` table and a `&SecretField`, yields each resolved leaf as `(label: String, value: &str, store_ref_value: Option<&str>)`, where `label` uses concrete `[n]` indices and `store_ref_value` is resolved from the leaf's innermost parent table (for `KeyInNamedStore`). Absent optional leaves yield nothing; missing required leaves yield an error carrying the dotted label. @@ -1790,7 +1790,7 @@ fn collect_secret_leaves<'a>( } ``` -- [ ] **Step 3: Rewrite the two consumers to use the collector** +- [x] **Step 3: Rewrite the two consumers to use the collector** Replace the flat lookups in `run_adapter_typed_checks` (`config.rs:1295-1333`) and `typed_secret_checks` (`config.rs:1339-1412`): @@ -1799,12 +1799,12 @@ Replace the flat lookups in `run_adapter_typed_checks` (`config.rs:1295-1333`) a Note the collector takes `&toml::Value` (the whole raw config) — `run_adapter_typed_checks`/`typed_secret_checks` currently start from `raw_table = ctx.raw_config.as_table()`; pass `&ctx.raw_config` to the collector instead (it does the `as_table` internally). -- [ ] **Step 4: Run (passes) + full CLI tests** +- [x] **Step 4: Run (passes) + full CLI tests** Run: `cargo test -p edgezero-cli --lib config 2>&1 | tail -25` Expected: PASS — new nested tests + all pre-existing config tests (top-level fixtures still length-1 paths). -- [ ] **Step 5: Lint + commit** +- [x] **Step 5: Lint + commit** Run: `cargo clippy -p edgezero-cli --all-targets --all-features -- -D warnings 2>&1 | tail -15` @@ -1827,7 +1827,7 @@ Prove the whole chain with a real `#[derive(AppConfig)]` config that has a 2-lev - Consumes: everything from Tasks 1–6. - Produces: acceptance evidence; docs. -- [ ] **Step 1: Write the failing E2E test** +- [x] **Step 1: Write the failing E2E test** Create `crates/edgezero-macros/tests/nested_secrets_e2e.rs`. Define a real nested config with `#[derive(AppConfig, Deserialize, Validate)]`, including one `KeyInDefault` nested leaf and one `KeyInNamedStore` nested leaf whose `store_ref` sibling lives in the same inner struct. Build a `serde_json::Value` blob holding key NAMES, run `secret_walk` (via the public `AppConfig` extraction path or by calling the crate's extraction entry point), and assert the resolved values. @@ -1867,16 +1867,16 @@ struct Settings { // ... run extraction; assert cfg.datadome.server_side_key == "DD" and cfg.vaulted.token == "TOK". ``` -- [ ] **Step 2: Run (fails, then passes)** +- [x] **Step 2: Run (fails, then passes)** Run: `cargo test -p edgezero-macros --test nested_secrets_e2e 2>&1 | tail -25` Expected: FAIL first (fixture/wiring), then PASS once assertions match resolved values. (If any Task 1–6 gap surfaces here, fix in the owning task's file and re-run.) -- [ ] **Step 3: Docs** +- [x] **Step 3: Docs** Append to `docs/guide/configuration.md` a "Nested and array secrets" section documenting: the `#[app_config(nested)]` opt-in (mirrors `#[validate(nested)]`; the nested type must itself derive `AppConfig`), `#[secret]` on `Option` (absent → skipped at runtime), object nesting and `Vec<_>` arrays (`partners[*].api_key`), the `store_ref` sibling scoping rule (resolved within the innermost containing object), and the dotted-path error format (`integrations.datadome.server_side_key`, `partners[3].api_key`). Include a worked `Settings`/`Integrations`/`Partner` example. -- [ ] **Step 4: Full workspace verification (all CI gates)** +- [x] **Step 4: Full workspace verification (all CI gates)** ```bash cargo fmt --all -- --check @@ -1889,7 +1889,7 @@ cargo run -q -p edgezero-cli --bin check_no_nested_app_config --features nested- ``` Expected: all green; app-demo top-level `#[secret]` still resolves; the guard prints `OK`. -- [ ] **Step 5: Commit** +- [x] **Step 5: Commit** ```bash git add crates/edgezero-macros/tests/nested_secrets_e2e.rs docs/guide/configuration.md diff --git a/docs/superpowers/plans/2026-07-02-edgezero-state-extractor.md b/docs/superpowers/plans/2026-07-02-edgezero-state-extractor.md index 48175778..2eb86314 100644 --- a/docs/superpowers/plans/2026-07-02-edgezero-state-extractor.md +++ b/docs/superpowers/plans/2026-07-02-edgezero-state-extractor.md @@ -51,7 +51,7 @@ - Consumes: `crate::context::RequestContext` (has `pub(crate) fn extension(&self) -> Option where T: Clone + Send + Sync + 'static` at `context.rs:77`), `crate::error::EdgeError` (`EdgeError::internal(anyhow::Error) -> 500`; `err.status() -> StatusCode`), the `FromRequest` trait (`extractor.rs:21`), `std::ops::{Deref, DerefMut}` (already imported at `extractor.rs:1`). - Produces: `pub struct State(pub T)` with `impl FromRequest for State`, plus `Deref`/`DerefMut`/`into_inner`. Consumed by Task 2 (router tests) and Task 3 (macro composition). -- [ ] **Step 1: Write the failing tests** +- [x] **Step 1: Write the failing tests** Append to the `#[cfg(test)] mod tests` block at the end of `crates/edgezero-core/src/extractor.rs`. The module already imports `request_builder, Method, StatusCode` (from `crate::http`), `RequestContext`, `PathParams`, `Body`, `block_on`, and `std::sync::Arc`. @@ -126,12 +126,12 @@ Append to the `#[cfg(test)] mod tests` block at the end of `crates/edgezero-core } ``` -- [ ] **Step 2: Run tests to verify they fail** +- [x] **Step 2: Run tests to verify they fail** Run: `cargo test -p edgezero-core --lib state_extractor 2>&1 | tail -20` Expected: FAIL — compile error `cannot find type/struct State in this scope` (the extractor does not exist yet). -- [ ] **Step 3: Write the extractor** +- [x] **Step 3: Write the extractor** Insert into `crates/edgezero-core/src/extractor.rs` immediately after the `Kv` extractor block (after the `impl Kv { ... }` that ends around `extractor.rs:529`), before the next extractor. `anyhow` is already used in this file; `core::any::type_name` needs no import. @@ -197,17 +197,17 @@ impl State { } ``` -- [ ] **Step 4: Run tests to verify they pass** +- [x] **Step 4: Run tests to verify they pass** Run: `cargo test -p edgezero-core --lib state_extractor 2>&1 | tail -20` Expected: PASS — 3 tests (`state_extractor_resolves_registered_value`, `state_extractor_missing_registration_is_internal_error`, `state_extractor_deref_and_into_inner`). -- [ ] **Step 5: Lint** +- [x] **Step 5: Lint** Run: `cargo clippy -p edgezero-core --all-targets --all-features -- -D warnings 2>&1 | tail -20` Expected: no warnings. -- [ ] **Step 6: Commit** +- [x] **Step 6: Commit** ```bash git add crates/edgezero-core/src/extractor.rs @@ -237,7 +237,7 @@ git commit -m "feat(core): add State extractor for app-owned shared state" - Consumes: `State` from Task 1 (`crate::extractor::State`), `crate::http::Extensions` (facade alias), `std::sync::Arc` (imported at `router.rs:2`). - Produces: `RouterBuilder::with_state(self, value: T) -> Self where T: Clone + Send + Sync + 'static`. Consumed by Task 3. -- [ ] **Step 1: Write the failing router tests** +- [x] **Step 1: Write the failing router tests** Append to `crates/edgezero-core/src/router.rs`'s main `#[cfg(test)] mod tests` (the block whose imports are at `router.rs:476`, which already imports `Arc, Mutex`, `block_on`, `noop_waker_ref`, `Context, Poll`, `request_builder, Method, StatusCode`, `Body`, `RequestContext`, `EdgeError`). @@ -385,7 +385,7 @@ Append to `crates/edgezero-core/src/router.rs`'s main `#[cfg(test)] mod tests` ( } ``` -- [ ] **Step 2: Run tests to verify they fail** +- [x] **Step 2: Run tests to verify they fail** Run: `cargo test -p edgezero-core --lib with_state 2>&1 | tail -20` Expected: FAIL — `no method named with_state found for struct RouterBuilder`. @@ -398,7 +398,7 @@ Expected: FAIL — `no method named with_state found for struct RouterBuilder`. > `crates/edgezero-core/src/router.rs` for the final shape; don't follow the > `state_inserters`/`StateInserter` snippets below literally. -- [ ] **Step 3: Add the `StateInserter` type alias** *(superseded — see note above)* +- [x] **Step 3: Add the `StateInserter` type alias** *(superseded — see note above)* In `crates/edgezero-core/src/router.rs`, add just above `pub struct RouterBuilder` (which is at `router.rs:71`, under its `#[derive(Default)]` at `router.rs:70`): @@ -408,7 +408,7 @@ In `crates/edgezero-core/src/router.rs`, add just above `pub struct RouterBuilde type StateInserter = Arc; ``` -- [ ] **Step 4: Add the `state_inserters` field to `RouterBuilder`** +- [x] **Step 4: Add the `state_inserters` field to `RouterBuilder`** Change the struct at `router.rs:70-76` from: @@ -435,7 +435,7 @@ pub struct RouterBuilder { } ``` -- [ ] **Step 5: Add the `with_state` method** +- [x] **Step 5: Add the `with_state` method** In the `impl RouterBuilder` block, add immediately after `with_manifest_json` (which is at `router.rs:190-195`): @@ -463,7 +463,7 @@ In the `impl RouterBuilder` block, add immediately after `with_manifest_json` (w } ``` -- [ ] **Step 6: Thread `state_inserters` through `build()`** +- [x] **Step 6: Thread `state_inserters` through `build()`** Change `build()` at `router.rs:108-119` from: @@ -496,7 +496,7 @@ to (add the 5th argument): } ``` -- [ ] **Step 7: Add the field to `RouterInner` and the param to `RouterService::new`** +- [x] **Step 7: Add the field to `RouterInner` and the param to `RouterService::new`** Change `RouterInner` at `router.rs:198-203` from: @@ -563,7 +563,7 @@ to: } ``` -- [ ] **Step 8: Apply the inserters in `dispatch`** +- [x] **Step 8: Apply the inserters in `dispatch`** In `RouterInner::dispatch` (`router.rs:206-237`), inside the `RouteMatch::Found(entry, params)` arm, add the state-insertion loop after the `needs.routes` block and before `let ctx = RequestContext::new(request, params);` (currently `router.rs:227`). The arm becomes: @@ -596,17 +596,17 @@ In `RouterInner::dispatch` (`router.rs:206-237`), inside the `RouteMatch::Found( } ``` -- [ ] **Step 9: Run tests to verify they pass** +- [x] **Step 9: Run tests to verify they pass** Run: `cargo test -p edgezero-core --lib with_state 2>&1 | tail -20` Expected: PASS — 4 tests (`with_state_exposes_value_to_handler`, `with_state_supports_multiple_distinct_types`, `with_state_same_type_is_last_write_wins`, `with_state_no_cross_request_bleed`). -- [ ] **Step 10: Full crate test + lint** +- [x] **Step 10: Full crate test + lint** Run: `cargo test -p edgezero-core 2>&1 | tail -20 && cargo clippy -p edgezero-core --all-targets --all-features -- -D warnings 2>&1 | tail -20` Expected: all existing + new tests PASS; clippy clean (proves the router restructure did not regress introspection tests). -- [ ] **Step 11: Commit** +- [x] **Step 11: Commit** ```bash git add crates/edgezero-core/src/router.rs @@ -626,7 +626,7 @@ git commit -m "feat(core): RouterBuilder::with_state injects app state into requ - Consumes: `State` (Task 1), `RouterBuilder::with_state` (Task 2), `#[action]` (unchanged — `crates/edgezero-macros/src/action.rs:183` emits `<#ty as ::edgezero_core::extractor::FromRequest>::from_request(&__ctx).await?` for every non-`RequestContext` arg), `RouterService::oneshot` (`router.rs:316`), `Query` extractor (`edgezero_core::extractor::Query`). - Produces: nothing consumed downstream; this is the acceptance proof that the macro composes `State` with another extractor. -- [ ] **Step 1: Add the `futures` dev-dependency to the macros crate** +- [x] **Step 1: Add the `futures` dev-dependency to the macros crate** Confirm `futures` is a workspace dependency: @@ -649,7 +649,7 @@ trybuild = { workspace = true } (If `grep` shows `futures` is not workspace-managed, use `futures = "0.3"` with `features = ["std", "executor"]` instead.) -- [ ] **Step 2: Write the failing integration test** +- [x] **Step 2: Write the failing integration test** Create `crates/edgezero-macros/tests/action_state.rs`: @@ -712,12 +712,12 @@ mod tests { } ``` -- [ ] **Step 3: Run the integration test** +- [x] **Step 3: Run the integration test** Run: `cargo test -p edgezero-macros --test action_state 2>&1 | tail -20` Expected: PASS — `action_composes_state_and_query`. (This simultaneously proves the macro needs no change: `State` is dispatched by the same generic `FromRequest` line as `Query`.) -- [ ] **Step 4: Add the docs section** +- [x] **Step 4: Add the docs section** Append to `docs/guide/handlers.md` a new section (place it after the existing extractor documentation, before any "Next steps"/footer): @@ -774,7 +774,7 @@ a handler asks for a `State` that was never registered, extraction fails with a `500` — register it before `build()`. ``` -- [ ] **Step 5: Full verification** +- [x] **Step 5: Full verification** Run: `cargo test --workspace --all-targets 2>&1 | tail -20` Expected: PASS. @@ -785,7 +785,7 @@ Expected: formatted; clippy clean. Run: `cargo check --workspace --all-targets --features "fastly cloudflare spin" 2>&1 | tail -5 && cargo check -p edgezero-adapter-spin --target wasm32-wasip2 --features spin 2>&1 | tail -5` Expected: both succeed (proves the core change is WASM-clean across adapters). -- [ ] **Step 6: Commit** +- [x] **Step 6: Commit** ```bash git add crates/edgezero-macros/tests/action_state.rs crates/edgezero-macros/Cargo.toml docs/guide/handlers.md diff --git a/docs/superpowers/plans/2026-07-04-edgezero-p0c-fastly-dispatch-fidelity.md b/docs/superpowers/plans/2026-07-04-edgezero-p0c-fastly-dispatch-fidelity.md index 1b109283..acf2ddac 100644 --- a/docs/superpowers/plans/2026-07-04-edgezero-p0c-fastly-dispatch-fidelity.md +++ b/docs/superpowers/plans/2026-07-04-edgezero-p0c-fastly-dispatch-fidelity.md @@ -64,7 +64,7 @@ - Consumes: `from_core_response(response: edgezero_core::http::Response) -> Result` (existing); `response_builder()`, `Body` (test-module imports already present). - Produces: unchanged signature; behavior now preserves duplicate header values. -- [ ] **Step 1: Write the failing host test** +- [x] **Step 1: Write the failing host test** Add to the `#[cfg(test)] mod tests` in `crates/edgezero-adapter-fastly/src/response.rs`, placed in alphabetical position among the test fns (before `stream_body_is_written_to_fastly_response`). The module already imports `super::*`, `Body`, `response_builder`. @@ -89,12 +89,12 @@ Add to the `#[cfg(test)] mod tests` in `crates/edgezero-adapter-fastly/src/respo } ``` -- [ ] **Step 2: Run it — verify it fails** +- [x] **Step 2: Run it — verify it fails** Run: `cargo test -p edgezero-adapter-fastly --features fastly --lib multi_value_set_cookie 2>&1 | tail -20` Expected: FAIL — the collected `cookies` is `["b=2"]` (the loop's `set_header` replaced the first value). -- [ ] **Step 3: Swap `set_header` → `append_header`** +- [x] **Step 3: Swap `set_header` → `append_header`** In `crates/edgezero-adapter-fastly/src/response.rs`, change the header loop (`response.rs:28-30`): @@ -115,17 +115,17 @@ to: } ``` -- [ ] **Step 4: Run it — verify it passes** +- [x] **Step 4: Run it — verify it passes** Run: `cargo test -p edgezero-adapter-fastly --features fastly --lib multi_value_set_cookie 2>&1 | tail -8` Expected: PASS. Also run the whole module: `cargo test -p edgezero-adapter-fastly --features fastly --lib response 2>&1 | tail -8` → all pass. -- [ ] **Step 5: Lint** +- [x] **Step 5: Lint** Run: `cargo clippy -p edgezero-adapter-fastly --all-targets --features fastly -- -D warnings 2>&1 | tail -5` Expected: clean. -- [ ] **Step 6: Commit** +- [x] **Step 6: Commit** ```bash git add crates/edgezero-adapter-fastly/src/response.rs @@ -143,7 +143,7 @@ git commit -m "fix(fastly): preserve multi-value response headers (Set-Cookie) i - Consumes: `convert_response(fastly_response: &mut fastly::Response) -> edgezero_core::proxy::ProxyResponse` (existing, private); `HeaderName` from `edgezero_core::http`. - Produces: `convert_response` preserving duplicate origin response headers. -- [ ] **Step 1: Write the failing host test** +- [x] **Step 1: Write the failing host test** Add to the `#[cfg(test)] mod tests` in `crates/edgezero-adapter-fastly/src/proxy.rs` (module already imports `super::*`, `block_on`). Place alphabetically among the existing `stream_handles_*` tests (before `stream_handles_brotli`). @@ -166,12 +166,12 @@ Add to the `#[cfg(test)] mod tests` in `crates/edgezero-adapter-fastly/src/proxy } ``` -- [ ] **Step 2: Run it — verify it fails** +- [x] **Step 2: Run it — verify it fails** Run: `cargo test -p edgezero-adapter-fastly --features fastly --lib convert_response_preserves 2>&1 | tail -20` Expected: FAIL — `cookies` is `["a=1"]` (or one value): `get_header` returns the first value and `HeaderMap::insert` replaces. -- [ ] **Step 3: Fix `convert_response` to append every value** +- [x] **Step 3: Fix `convert_response` to append every value** In `crates/edgezero-adapter-fastly/src/proxy.rs`, change the header loop (`proxy.rs:67-71`): @@ -199,12 +199,12 @@ to: (If the installed `fastly` SDK's `get_header_names()` yields owned `HeaderName` rather than `&HeaderName`, bind `for name in …` then call `get_header_all(&name)` / `append(&name, …)`. Confirm by the compiler; behavior is identical.) -- [ ] **Step 4: Run it — verify it passes** +- [x] **Step 4: Run it — verify it passes** Run: `cargo test -p edgezero-adapter-fastly --features fastly --lib convert_response_preserves 2>&1 | tail -8` Expected: PASS. Run the module: `cargo test -p edgezero-adapter-fastly --features fastly --lib proxy 2>&1 | tail -8` → all pass. -- [ ] **Step 5: Request-side audit — switch to `append_header` for consistency** +- [x] **Step 5: Request-side audit — switch to `append_header` for consistency** Origin-bound request duplicate headers are rare, but for consistency and to remove the same class of latent bug, change the request-build loop in `build_fastly_request` (`proxy.rs:53`): @@ -225,14 +225,14 @@ Leave the explicit `Host` line (`proxy.rs:57`) as `set_header` — it is a singl // set explicitly as a single value. ``` -- [ ] **Step 6: Run + lint** +- [x] **Step 6: Run + lint** Run: `cargo test -p edgezero-adapter-fastly --features fastly --lib proxy 2>&1 | tail -8` Expected: PASS. Run: `cargo clippy -p edgezero-adapter-fastly --all-targets --features fastly -- -D warnings 2>&1 | tail -5` Expected: clean. -- [ ] **Step 7: Commit** +- [x] **Step 7: Commit** ```bash git add crates/edgezero-adapter-fastly/src/proxy.rs @@ -256,7 +256,7 @@ This task adds the trait method and wires it everywhere **atomically** — becau **Interfaces:** - Produces: `Hooks::owns_logging() -> bool` (default `false`). Consumed by all four `run_app` entrypoints and by Task 4 (macro argument). -- [ ] **Step 1: Write the failing trait test** +- [x] **Step 1: Write the failing trait test** Add to the `#[cfg(test)] mod tests` in `crates/edgezero-core/src/app.rs`, placed alphabetically among the existing test fns. `DefaultHooks` (defined in that module, `app.rs:163`) overrides only `routes`/`stores`, so it should report the default. @@ -267,12 +267,12 @@ Add to the `#[cfg(test)] mod tests` in `crates/edgezero-core/src/app.rs`, placed } ``` -- [ ] **Step 2: Run it — verify it fails** +- [x] **Step 2: Run it — verify it fails** Run: `cargo test -p edgezero-core --lib default_hooks_do_not_own_logging 2>&1 | tail -15` Expected: FAIL — `no method named owns_logging found`. -- [ ] **Step 3: Add `owns_logging()` to the `Hooks` trait** +- [x] **Step 3: Add `owns_logging()` to the `Hooks` trait** In `crates/edgezero-core/src/app.rs`, add to the `Hooks` trait. **`arbitrary_source_item_ordering` (restriction = deny) enforces alphabetical trait methods**, so place `owns_logging` between `name` and `routes` (order: `build_app`, `configure`, `name`, `owns_logging`, `routes`, `stores`) — not adjacent to `configure`: @@ -286,7 +286,7 @@ In `crates/edgezero-core/src/app.rs`, add to the `Hooks` trait. **`arbitrary_sou } ``` -- [ ] **Step 4: Emit `owns_logging` from the `app!` macro** +- [x] **Step 4: Emit `owns_logging` from the `app!` macro** In `crates/edgezero-macros/src/app.rs`, add to the emitted `impl edgezero_core::app::Hooks` block (`app.rs:165-183`) — after `configure`, mirroring the explicit-defaults pattern the file already documents at `app.rs:154-158`: @@ -308,7 +308,7 @@ Update the `missing_trait_methods` comment at `app.rs:154-158` to include `owns_ // defaults change, update these emitted bodies to match. ``` -- [ ] **Step 5: Gate the four adapter logger-init sites** +- [x] **Step 5: Gate the four adapter logger-init sites** Each adapter's `run_app` (and Fastly's `run_app_with_config`) must skip its logger init when `A::owns_logging()`: @@ -342,21 +342,21 @@ and the identical block in `run_app_with_config` (`lib.rs:205-208`) — same `&& } ``` -- [ ] **Step 6: Run the trait test + workspace build** +- [x] **Step 6: Run the trait test + workspace build** Run: `cargo test -p edgezero-core --lib default_hooks_do_not_own_logging 2>&1 | tail -8` Expected: PASS. Run: `cargo check --workspace --all-targets --features "fastly cloudflare spin" 2>&1 | tail -5` Expected: succeeds (macro emission satisfies `missing_trait_methods`; all four adapters compile). -- [ ] **Step 7: Lint + WASM checks** +- [x] **Step 7: Lint + WASM checks** Run: `cargo clippy --workspace --all-targets --all-features -- -D warnings 2>&1 | tail -5` Expected: clean (the two `Hooks` test stubs already carry `#[expect(clippy::missing_trait_methods)]`, so the new defaulted method needs no change there). Run: `cargo check -p edgezero-adapter-spin --target wasm32-wasip2 --features spin 2>&1 | tail -3` Expected: succeeds. -- [ ] **Step 8: Commit** +- [x] **Step 8: Commit** ```bash git add crates/edgezero-core/src/app.rs crates/edgezero-macros/src/app.rs \ @@ -382,7 +382,7 @@ Rework `AppArgs` from "path + optional ident" into the keyword-argument grammar - Consumes: `syn::{LitStr, Ident, LitBool, Token}`, `syn::parse::{Parse, ParseStream}`. - Produces: `struct AppArgs { path: LitStr, app_ident: Option, owns_logging: Option }` with a keyword-arg parser. **P0-D adds `state: Option` to this same struct and parser.** -- [ ] **Step 1: Write the failing `AppArgs` unit tests** +- [x] **Step 1: Write the failing `AppArgs` unit tests** Add a focused unit-test module for the parser. Put it in the existing `#[cfg(test)] mod tests` in `crates/edgezero-macros/src/app.rs` (which currently imports only `parse_handler_path`). Add `use super::AppArgs;` and `use syn::parse_str;`. Tests (place alphabetically among the existing `parse_handler_path_*` fns): @@ -441,12 +441,12 @@ Add a focused unit-test module for the parser. Put it in the existing `#[cfg(tes } ``` -- [ ] **Step 2: Run — verify they fail** +- [x] **Step 2: Run — verify they fail** Run: `cargo test -p edgezero-macros --lib app_args_ 2>&1 | tail -20` Expected: FAIL — the current `AppArgs` has no `owns_logging` field and rejects `owns_logging = true` with "unexpected tokens". -- [ ] **Step 3: Rework `AppArgs` + `impl Parse`** +- [x] **Step 3: Rework `AppArgs` + `impl Parse`** Replace `crates/edgezero-macros/src/app.rs:12-31` with: @@ -519,7 +519,7 @@ Add `LitBool` isn't needed as an import (used as `syn::LitBool`); ensure `Ident` > **Note for P0-D:** the `match key.to_string()` arm is the extension point — P0-D adds a `"state" => { … }` arm and a `state: Option` field. The "expected `owns_logging`" message becomes "expected `state` or `owns_logging`" then. -- [ ] **Step 4: Emit the parsed `owns_logging` value** +- [x] **Step 4: Emit the parsed `owns_logging` value** In `expand_app`, before the `quote!` block, compute the bool literal (default `false`). Near the other `let …_lit` bindings (around `app.rs:130-152`): @@ -537,12 +537,12 @@ Change the emitted `fn owns_logging()` (added in Task 3, currently `{ false }`) (`bool` implements `quote::ToTokens`, so `#owns_logging_lit` emits `true`/`false`.) -- [ ] **Step 5: Run the unit tests — verify they pass** +- [x] **Step 5: Run the unit tests — verify they pass** Run: `cargo test -p edgezero-macros --lib app_args_ 2>&1 | tail -12` Expected: PASS — all seven `app_args_*` tests. -- [ ] **Step 6: Add a real-`app!` macro-emission integration test (spec requirement)** +- [x] **Step 6: Add a real-`app!` macro-emission integration test (spec requirement)** The spec's C2 acceptance requires proving the macro *emits* `owns_logging() == true` for `app!(…, owns_logging = true)` — not just that the grammar parses. `edgezero-macros` dev-depends on `edgezero-core` (`crates/edgezero-macros/Cargo.toml:31`) which re-exports `app!` (`crates/edgezero-core/src/lib.rs:42`), and the macro resolves the manifest path against the invoking crate's `CARGO_MANIFEST_DIR` (`app.rs:243`), so an integration test can invoke `app!` with a checked-in fixture manifest. @@ -581,14 +581,14 @@ mod tests { Run: `cargo test -p edgezero-macros --test app_macro 2>&1 | tail -12` Expected: PASS — proves the macro emitted `fn owns_logging() -> bool { true }`. (Only one `app!` per test file — it emits a free `build_router()` that would collide across invocations; the default `owns_logging() == false` path is covered by app-demo below.) -- [ ] **Step 7: Verify app-demo (real `app!`, no keyword args) still emits owns_logging=false** +- [x] **Step 7: Verify app-demo (real `app!`, no keyword args) still emits owns_logging=false** app-demo's `app!("../../edgezero.toml")` (`examples/app-demo/crates/app-demo-core/src/lib.rs:11`) uses no keyword args, so its generated `owns_logging()` returns `false`. Run: `(cd examples/app-demo && cargo test -p app-demo-core 2>&1 | tail -5)` Expected: PASS. (Do NOT add a process-global logger "call run_app twice" test — the macro-emission test above + the gate code review are the deterministic coverage.) -- [ ] **Step 8: Lint + commit** +- [x] **Step 8: Lint + commit** Run: `cargo clippy -p edgezero-macros --all-targets --all-features -- -D warnings 2>&1 | tail -5` Expected: clean. @@ -616,7 +616,7 @@ Add a Fastly `run_app` variant taking an app closure that reads the raw `fastly: - `request.rs`: `dispatch_with_registries(app, req, config_meta, kv_meta, secret_meta, env, extend: F) where F: FnOnce(&FastlyRequest, &mut Extensions)` — an added final `extend` parameter; `dispatch_with_handles` likewise. - `lib.rs`: `pub fn run_app_with_request_extensions(req: fastly::Request, extend: F) -> Result where A: Hooks, F: FnOnce(&fastly::Request, &mut Extensions)`. -- [ ] **Step 1: Write the failing host unit test (the scratch-bag mechanism)** +- [x] **Step 1: Write the failing host unit test (the scratch-bag mechanism)** Unit-test the closure/scratch-bag seam directly via a small extracted helper. (This runs under Viceroy from the crate dir, per Global Constraints; the *full* `run_app_with_request_extensions` → `into_core_request` → handler integration is best covered by a `contract.rs` test — see the note after the handler-visible test.) @@ -690,12 +690,12 @@ Also add a **handler-visible** host test — this proves the spec's requirement > **Complete-path coverage (wasm/Viceroy, optional in this plan):** the *full* raw-`fastly::Request` → `run_app_with_request_extensions` → `into_core_request` → handler path can only run under Viceroy (`into_core_request`'s `get_client_ip_addr()` hostcall). If a Viceroy toolchain is available, add a test to `crates/edgezero-adapter-fastly/tests/contract.rs` (already `#![cfg(all(feature = "fastly", target_arch = "wasm32"))]`) that dispatches a request through `run_app_with_request_extensions::(req, |raw, ext| ext.insert(Ja4(raw.get_url_str().into())))` and asserts a handler read the value. Mark it clearly as wasm/Viceroy-only; it is not run by the host `cargo test` gate. -- [ ] **Step 2: Run — verify they fail** +- [x] **Step 2: Run — verify they fail** Run: `cargo test -p edgezero-adapter-fastly --features fastly --lib apply_request_extend 2>&1 | tail -15` Expected: FAIL — `apply_request_extend` does not exist. (The `extended_request_extensions_are_visible_to_handler` test also compiles against the same feature set; it exercises only public core APIs and will pass once the crate builds — its value is documenting/locking the handler-visible contract.) -- [ ] **Step 3: Add the `apply_request_extend` helper + thread the closure through dispatch** +- [x] **Step 3: Add the `apply_request_extend` helper + thread the closure through dispatch** In `crates/edgezero-adapter-fastly/src/request.rs`, add the helper near `dispatch_with_handles` (import `Extensions`: add `Extensions` to the existing `use edgezero_core::http::{request_builder, Request};` line at `request.rs:11`): @@ -785,7 +785,7 @@ where } ``` -- [ ] **Step 4: Add `run_app_with_request_extensions` + make `run_app` delegate** +- [x] **Step 4: Add `run_app_with_request_extensions` + make `run_app` delegate** In `crates/edgezero-adapter-fastly/src/lib.rs`, replace the `run_app` body's `dispatch_with_registries(...)` call (`lib.rs:122`) so `run_app` delegates to the new variant with a no-op closure, and add the new public fn. Import `Extensions`: add `use edgezero_core::http::Extensions;` near the other imports (guarded under the same `#[cfg(feature = "fastly")]` scope as `run_app`). @@ -833,7 +833,7 @@ where (The logger gate here is the Task-3 `&& !A::owns_logging()`. Note: `run_app_with_config` (`lib.rs:200`) does **not** call `dispatch_with_registries` — it builds a `FastlyService` and calls `service.dispatch(req)` (`lib.rs:210-214`), which routes through `FastlyService::dispatch` → `dispatch_with_handles`, already updated with a no-op in Step 3. So `run_app_with_config` needs no change here beyond its own Task-3 logger gate. The **only** direct caller of `dispatch_with_registries` is `run_app` — now delegating through `run_app_with_request_extensions`.) -- [ ] **Step 5: Confirm every caller was updated, then run the host test + build** +- [x] **Step 5: Confirm every caller was updated, then run the host test + build** `dispatch_with_handles` and `dispatch_with_registries` are internal to the fastly crate but each has multiple callers. Grep to confirm none was missed before building: @@ -847,7 +847,7 @@ Expected: all host unit tests PASS (existing `synthesis_tests`, response, proxy, Run: `cargo check --workspace --all-targets --features "fastly cloudflare spin" 2>&1 | tail -5` Expected: succeeds (the signature changes are internal to the fastly crate; the two `dispatch_with_handles` callers — `FastlyService::dispatch` and `dispatch_with_registries` — and the one `dispatch_with_registries` caller, `run_app_with_request_extensions`, are all updated. `run_app_with_config` compiles unchanged: it dispatches via `FastlyService::dispatch`). -- [ ] **Step 6: Lint + commit** +- [x] **Step 6: Lint + commit** Run: `cargo clippy -p edgezero-adapter-fastly --all-targets --features fastly -- -D warnings 2>&1 | tail -5` Expected: clean. @@ -861,7 +861,7 @@ git commit -m "feat(fastly): run_app_with_request_extensions pre-dispatch hook f ## Final verification (all P0-C tasks) -- [ ] **Run every CI gate:** +- [x] **Run every CI gate:** ```bash cargo fmt --all -- --check diff --git a/docs/superpowers/plans/2026-07-04-edgezero-p0d-app-macro-state.md b/docs/superpowers/plans/2026-07-04-edgezero-p0d-app-macro-state.md index ee83e0f7..a12f8f12 100644 --- a/docs/superpowers/plans/2026-07-04-edgezero-p0d-app-macro-state.md +++ b/docs/superpowers/plans/2026-07-04-edgezero-p0d-app-macro-state.md @@ -40,7 +40,7 @@ - Consumes (from P0-C): the `AppArgs` keyword-arg parser with its `match key.to_string()` dispatch. - Produces: `AppArgs.state: Option`; `build_router()` emits `builder = builder.with_state(#state_expr);` when `state` is present. -- [ ] **Step 1: Write the failing `AppArgs` state unit tests** +- [x] **Step 1: Write the failing `AppArgs` state unit tests** Add to the `#[cfg(test)] mod tests` in `crates/edgezero-macros/src/app.rs` (which after P0-C already has `use super::AppArgs;`, `use syn::parse_str;`, and the `app_args_*` tests). Place alphabetically among the `app_args_*` fns. To assert the parsed expression, render it with `quote`: @@ -73,12 +73,12 @@ Add to the `#[cfg(test)] mod tests` in `crates/edgezero-macros/src/app.rs` (whic } ``` -- [ ] **Step 2: Run — verify they fail** +- [x] **Step 2: Run — verify they fail** Run: `cargo test -p edgezero-macros --lib app_args_parses_state 2>&1 | tail -20` Expected: FAIL — `AppArgs` has no `state` field, and `state = …` hits the unknown-key arm ("unknown `app!` argument `state`"). -- [ ] **Step 3: Add the `state` field + parser arm** +- [x] **Step 3: Add the `state` field + parser arm** In `crates/edgezero-macros/src/app.rs`, add `state` to the `AppArgs` struct (alphabetical field order: `app_ident`, `owns_logging`, `path`, `state`): @@ -121,7 +121,7 @@ and the constructor: Ok(Self { app_ident, owns_logging, path, state }) ``` -- [ ] **Step 4: Emit `.with_state(...)` in `build_router()`** +- [x] **Step 4: Emit `.with_state(...)` in `build_router()`** In `expand_app`, before the `quote!` block, turn the optional state expression into optional emitted tokens (place near the other `let …` bindings). Use `Option<&Expr>` mapped to a `TokenStream2` so the call is emitted only when present: @@ -146,19 +146,19 @@ Then insert `#state_call` into the emitted `build_router()` (`app.rs:185-191`), (`Option` implements `ToTokens` — `None` emits nothing, so omitting `state` leaves `build_router()` unchanged.) -- [ ] **Step 5: Run the unit tests — verify they pass** +- [x] **Step 5: Run the unit tests — verify they pass** Run: `cargo test -p edgezero-macros --lib app_args_ 2>&1 | tail -12` Expected: PASS — the new `app_args_parses_state*` / `app_args_rejects_duplicate_state` plus the P0-C `app_args_*` tests. -- [ ] **Step 6: Confirm app-demo (no `state`) is unchanged + lint** +- [x] **Step 6: Confirm app-demo (no `state`) is unchanged + lint** Run: `(cd examples/app-demo && cargo test -p app-demo-core 2>&1 | tail -5)` Expected: PASS (app-demo's `app!` has no `state` yet, so `build_router()` is byte-identical). Run: `cargo clippy -p edgezero-macros --all-targets --all-features -- -D warnings 2>&1 | tail -5` Expected: clean. -- [ ] **Step 7: Commit** +- [x] **Step 7: Commit** ```bash git add crates/edgezero-macros/src/app.rs @@ -182,7 +182,7 @@ Prove the whole chain: `app!(state = crate::app_state())` → generated `build_r > **Design note (why crate root, not a `state` module):** app-demo is its own workspace with a stricter lint set — `pub_use` and `module_name_repetitions` are **denied** (unlike the root workspace which allows them). A `pub mod state;` + `pub use crate::state::app_state;` trips `pub_use`, and `DemoState`/`app_state` inside a module named `state` trip `module_name_repetitions`. Defining both at the crate root (in `lib.rs`) avoids all three with no `#[allow]`, and `crate::app_state()` / `crate::DemoState` resolve directly where the macro emits them. -- [ ] **Step 1: Define `DemoState` + `app_state()` at the crate root and wire `app!`** +- [x] **Step 1: Define `DemoState` + `app_state()` at the crate root and wire `app!`** In `examples/app-demo/crates/app-demo-core/src/lib.rs`, add the state types at the crate root (after the `pub mod` declarations) and add the `state` argument to the `app!` invocation (`lib.rs:11`): @@ -213,7 +213,7 @@ edgezero_core::app!("../../edgezero.toml", state = crate::app_state()); (`#[inline]` is required by `missing_inline_in_public_items`; `#[derive(Debug)]` keeps the public struct debuggable.) -- [ ] **Step 2: Add the `State` handler** +- [x] **Step 2: Add the `State` handler** In `examples/app-demo/crates/app-demo-core/src/handlers.rs`, add `State` to the existing `edgezero_core::extractor::{…}` import and `use std::sync::Arc;`, then add the handler (`crate::DemoState` is a 2-segment path — fine under `absolute_paths`). The return type is `Result, EdgeError>` to match the file's other text handlers (e.g. `secrets_echo`); `#[action]` wraps it via `Responder`: @@ -228,7 +228,7 @@ pub async fn state_demo( (If `handlers.rs` already imports `Arc` / an `action` alias, reuse those rather than re-importing — keep the file's existing style.) -- [ ] **Step 3: Register the route in the manifest** +- [x] **Step 3: Register the route in the manifest** In `examples/app-demo/edgezero.toml`, add an HTTP trigger for the handler, matching the existing `[[triggers.http]]` entries' exact keys (`id`, `path`, `methods`, `handler`, `adapters`): @@ -242,7 +242,7 @@ adapters = ["axum", "cloudflare", "fastly", "spin"] description = "Reads app-owned state via State> (app!(state = ...))" ``` -- [ ] **Step 4: Write the end-to-end host test** +- [x] **Step 4: Write the end-to-end host test** Add to the `#[cfg(test)] mod tests` in `examples/app-demo/crates/app-demo-core/src/handlers.rs` (the module that already contains the `crate::build_router()` test at `handlers.rs:436`; reuse its imports for `request_builder`/`Body`/`block_on` — mirror that test). Place the fn alphabetically. @@ -271,12 +271,12 @@ Add to the `#[cfg(test)] mod tests` in `examples/app-demo/crates/app-demo-core/s } ``` -- [ ] **Step 5: Run the e2e test** +- [x] **Step 5: Run the e2e test** Run: `(cd examples/app-demo && cargo test -p app-demo-core state_demo_handler_reads_app_state 2>&1 | tail -12)` Expected: PASS — the macro-generated router injected `Arc`, the `State` extractor resolved it, and the handler echoed `greeting`. (First run may FAIL if the route/handler/state wiring is incomplete; fix until green.) -- [ ] **Step 6: Full app-demo + workspace verification** +- [x] **Step 6: Full app-demo + workspace verification** Run: `(cd examples/app-demo && cargo test 2>&1 | tail -8)` Expected: PASS (all four adapter example crates still build; app-demo-core tests green). @@ -289,7 +289,7 @@ cargo clippy --workspace --all-targets --all-features -- -D warnings ``` Expected: both clean (the new handler's fields are read by the assertion, so no `dead_code` suppression is needed). -- [ ] **Step 7: Commit** +- [x] **Step 7: Commit** ```bash git add examples/app-demo/crates/app-demo-core/src/lib.rs \ @@ -302,7 +302,7 @@ git commit -m "docs(app-demo): app!(state = ...) + State handler example with ## Final verification (all P0-D tasks) -- [ ] **Run every CI gate:** +- [x] **Run every CI gate:** ```bash cargo fmt --all -- --check From bac9f8a7648a0cb58b1b52357da025891b53a24c Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Sun, 5 Jul 2026 13:07:06 -0700 Subject: [PATCH 67/74] docs: document app!(state=)/owns_logging + run_app_with_request_extensions; model cached app_state (OnceLock) Finding 1: app!(state = ) runs the state expression each time build_router() is called (per request on Fastly), so the demo's app_state() now caches via OnceLock and the guide warns to build heavy state once + hand out Arc clones. Finding 2: handlers.md gains the app!(state = ...) macro path; fastly.md documents run_app_with_request_extensions (JA4/H2/client-IP hook) and owns_logging opt-out. --- docs/guide/adapters/fastly.md | 42 +++++++++++++++++++ docs/guide/handlers.md | 34 +++++++++++++++ .../app-demo/crates/app-demo-core/src/lib.rs | 21 +++++++--- 3 files changed, 92 insertions(+), 5 deletions(-) diff --git a/docs/guide/adapters/fastly.md b/docs/guide/adapters/fastly.md index 59d34c7d..0437b5b7 100644 --- a/docs/guide/adapters/fastly.md +++ b/docs/guide/adapters/fastly.md @@ -61,6 +61,48 @@ store metadata. Prefer `run_app` or `dispatch_with_config` for normal use. `dispatch_with_config_handle` exists for advanced/manual cases where you already have a prepared `ConfigStoreHandle`. +### Capturing raw-request signals (JA4, H2, client IP) + +`run_app` converts the `fastly::Request` into a neutral core request before +dispatch, so Fastly-only signals that are readable only on the raw request +(`get_tls_ja4()`, `get_client_h2_fingerprint()`, the client-IP getter) aren't +reachable from handlers by default. Use `run_app_with_request_extensions`, which +runs an app closure against a scratch `Extensions` **before** conversion and +merges the values into the core request — so a `State`/extractor or middleware +can read them: + +```rust +#[derive(Clone)] +struct Ja4(String); + +#[fastly::main] +fn main(req: fastly::Request) -> Result { + edgezero_adapter_fastly::run_app_with_request_extensions::(req, |raw, ext| { + if let Some(ja4) = raw.get_tls_ja4() { + ext.insert(Ja4(ja4)); + } + }) +} +``` + +`run_app` is exactly `run_app_with_request_extensions::(req, |_, _| {})`. +The closure runs once per request; insert whatever typed values your handlers +need, then read them in a handler via a custom extractor or +`ctx.request().extensions().get::()`. + +### Owning your own logging + +By default `run_app` initializes the Fastly logger. If your app already installs +a `log` backend, opt out with the platform-neutral `Hooks::owns_logging()` flag — +via the `app!` macro: + +```rust +edgezero_core::app!("edgezero.toml", owns_logging = true); +``` + +or on a hand-written `Hooks` impl (`fn owns_logging() -> bool { true }`). Every +adapter's `run_app` honors it, so the app is responsible for logger setup. + ## Building Build for Fastly's Wasm target: diff --git a/docs/guide/handlers.md b/docs/guide/handlers.md index 90515f93..645b2f7e 100644 --- a/docs/guide/handlers.md +++ b/docs/guide/handlers.md @@ -266,6 +266,40 @@ resolved by its own type. Registering the same `T` twice is last-write-wins. If a handler asks for a `State` that was never registered, extraction fails with a `500` — register it before `build()`. +### With the `app!` macro + +If your app is fully macro-driven (`app!("edgezero.toml")` builds the router from +the manifest), you don't hand-write `RouterBuilder::with_state`. Instead pass a +`state` argument — the macro emits `.with_state()` into the generated +router: + +```rust +// lib.rs +use std::sync::{Arc, OnceLock}; + +pub struct AppState { /* settings, registries, orchestrator, … */ } + +pub fn app_state() -> Arc { + static STATE: OnceLock> = OnceLock::new(); + Arc::clone(STATE.get_or_init(|| Arc::new(AppState { /* … */ }))) +} + +edgezero_core::app!("edgezero.toml", state = crate::app_state()); +``` + +`state = ` is any expression evaluating to the state value — write the call +(`crate::app_state()`), not a bare function path. Handlers then extract +`State>` exactly as above. Multiple types: repeat the argument +(`state = a(), state = b()`). + +> **Make `app_state()` cheap.** The macro emits the `state` expression inside the +> generated `build_router()`, which each adapter's `run_app` calls through +> `A::build_app()` — **once at startup** for long-lived runtimes (Axum), but +> **once per request** on Fastly Compute (each request is a fresh Wasm instance). +> So the `state` expression runs on that cadence: build heavy state **once** and +> hand out clones (e.g. a `OnceLock>` as above, or a `static`), and +> let `T = Arc` so each call is just a refcount bump. Do **not** > `Arc::new(HeavyThing::build())` directly in the `state` expression. + ## Response Types ### Text Responses diff --git a/examples/app-demo/crates/app-demo-core/src/lib.rs b/examples/app-demo/crates/app-demo-core/src/lib.rs index 59892387..9735fbaa 100644 --- a/examples/app-demo/crates/app-demo-core/src/lib.rs +++ b/examples/app-demo/crates/app-demo-core/src/lib.rs @@ -8,7 +8,7 @@ pub mod config; // internally; pub visibility is purely additive. pub mod handlers; -use std::sync::Arc; +use std::sync::{Arc, OnceLock}; /// App-owned shared state for the `app!(..., state = ...)` demonstration, /// handed to handlers via `State>`. @@ -18,13 +18,24 @@ pub struct DemoState { pub greeting: String, } -/// Constructs the shared app state. Referenced by `app!(..., state = crate::app_state())`. +/// Returns the shared app state, referenced by `app!(..., state = crate::app_state())`. +/// +/// IMPORTANT: `app!(state = )` emits this call inside the macro-generated +/// `build_router()`, which every adapter's `run_app` invokes via `A::build_app()` +/// — once at startup for long-lived runtimes (Axum), but **once per request** on +/// Fastly Compute (each request is a fresh Wasm instance). So `app_state()` must +/// be **cheap**: build the heavy state once and hand out clones. Here a +/// `OnceLock>` builds it lazily and every call just bumps the +/// `Arc` refcount — do NOT `Arc::new(..)` a heavy object on each call. #[must_use] #[inline] pub fn app_state() -> Arc { - Arc::new(DemoState { - greeting: "hello from app state".to_owned(), - }) + static STATE: OnceLock> = OnceLock::new(); + Arc::clone(STATE.get_or_init(|| { + Arc::new(DemoState { + greeting: "hello from app state".to_owned(), + }) + })) } edgezero_core::app!("../../edgezero.toml", state = crate::app_state()); From 8fc67f0637e43161ab2ba3871e1da5c8911b100e Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Sun, 5 Jul 2026 22:32:02 -0700 Subject: [PATCH 68/74] docs: correct state= guidance, JA4 snippet, and P0-D plan app_state - handlers.md: app! allows only one state= (macro rejects duplicates); use an aggregate app-state struct for multiple values. Fix stray '>' typo. - fastly.md: get_tls_ja4() returns Option<&str>; Ja4 wraps String -> .to_owned(). - p0d plan: model the cached OnceLock> app_state (was fresh Arc per call). --- docs/guide/adapters/fastly.md | 2 +- docs/guide/handlers.md | 7 +++--- ...2026-07-04-edgezero-p0d-app-macro-state.md | 23 ++++++++++++++----- 3 files changed, 22 insertions(+), 10 deletions(-) diff --git a/docs/guide/adapters/fastly.md b/docs/guide/adapters/fastly.md index 0437b5b7..5322ab22 100644 --- a/docs/guide/adapters/fastly.md +++ b/docs/guide/adapters/fastly.md @@ -79,7 +79,7 @@ struct Ja4(String); fn main(req: fastly::Request) -> Result { edgezero_adapter_fastly::run_app_with_request_extensions::(req, |raw, ext| { if let Some(ja4) = raw.get_tls_ja4() { - ext.insert(Ja4(ja4)); + ext.insert(Ja4(ja4.to_owned())); } }) } diff --git a/docs/guide/handlers.md b/docs/guide/handlers.md index 645b2f7e..630b3e91 100644 --- a/docs/guide/handlers.md +++ b/docs/guide/handlers.md @@ -289,8 +289,9 @@ edgezero_core::app!("edgezero.toml", state = crate::app_state()); `state = ` is any expression evaluating to the state value — write the call (`crate::app_state()`), not a bare function path. Handlers then extract -`State>` exactly as above. Multiple types: repeat the argument -(`state = a(), state = b()`). +`State>` exactly as above. Only **one** `state = ` is +allowed per `app!` — to share more than one value, wrap them in an aggregate +app-state struct and register that. > **Make `app_state()` cheap.** The macro emits the `state` expression inside the > generated `build_router()`, which each adapter's `run_app` calls through @@ -298,7 +299,7 @@ edgezero_core::app!("edgezero.toml", state = crate::app_state()); > **once per request** on Fastly Compute (each request is a fresh Wasm instance). > So the `state` expression runs on that cadence: build heavy state **once** and > hand out clones (e.g. a `OnceLock>` as above, or a `static`), and -> let `T = Arc` so each call is just a refcount bump. Do **not** > `Arc::new(HeavyThing::build())` directly in the `state` expression. +> let `T = Arc` so each call is just a refcount bump. Do **not** `Arc::new(HeavyThing::build())` directly in the `state` expression. ## Response Types diff --git a/docs/superpowers/plans/2026-07-04-edgezero-p0d-app-macro-state.md b/docs/superpowers/plans/2026-07-04-edgezero-p0d-app-macro-state.md index a12f8f12..325778fc 100644 --- a/docs/superpowers/plans/2026-07-04-edgezero-p0d-app-macro-state.md +++ b/docs/superpowers/plans/2026-07-04-edgezero-p0d-app-macro-state.md @@ -189,7 +189,7 @@ In `examples/app-demo/crates/app-demo-core/src/lib.rs`, add the state types at t ```rust pub mod handlers; -use std::sync::Arc; +use std::sync::{Arc, OnceLock}; /// App-owned shared state for the `app!(..., state = ...)` demonstration, /// handed to handlers via `State>`. @@ -199,19 +199,30 @@ pub struct DemoState { pub greeting: String, } -/// Constructs the shared app state. Referenced by `app!(..., state = crate::app_state())`. +/// Returns the shared app state, referenced by `app!(..., state = crate::app_state())`. +/// +/// IMPORTANT: `app!(state = )` emits this call inside the macro-generated +/// `build_router()`, which every adapter's `run_app` invokes via `A::build_app()` +/// — once at startup for long-lived runtimes (Axum), but **once per request** on +/// Fastly Compute (each request is a fresh Wasm instance). So `app_state()` must +/// be **cheap**: build the heavy state once and hand out clones. Here a +/// `OnceLock>` builds it lazily and every call just bumps the +/// `Arc` refcount — do NOT `Arc::new(..)` a heavy object on each call. #[must_use] #[inline] pub fn app_state() -> Arc { - Arc::new(DemoState { - greeting: "hello from app state".to_owned(), - }) + static STATE: OnceLock> = OnceLock::new(); + Arc::clone(STATE.get_or_init(|| { + Arc::new(DemoState { + greeting: "hello from app state".to_owned(), + }) + })) } edgezero_core::app!("../../edgezero.toml", state = crate::app_state()); ``` -(`#[inline]` is required by `missing_inline_in_public_items`; `#[derive(Debug)]` keeps the public struct debuggable.) +(`#[inline]` is required by `missing_inline_in_public_items`; `#[derive(Debug)]` keeps the public struct debuggable. The `OnceLock>` caches the value so the per-request `build_router()` on Fastly only bumps a refcount — see the "Make `app_state()` cheap" caveat in the handler guide.) - [x] **Step 2: Add the `State` handler** From 222cb3aff6fea3e6040d6372abf1a26f4e549868 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Sun, 5 Jul 2026 23:23:22 -0700 Subject: [PATCH 69/74] docs: align config-push + Fastly guides with the blob/secret-metadata model MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - cli-reference.md / cli-walkthrough.md: bundled `edgezero config push` errors (no raw push); typed push writes ONE BlobEnvelope with every field verbatim, including #[secret] key NAMES — nothing is flattened or stripped. Correct the per-adapter mechanics to one (key, envelope_json) entry and fix the stale Fastly command (config-store-entry update --upsert --stdin). - blob-app-config-migration.md: C::SECRET_FIELDS -> C::secret_fields(). - fastly.md: client IP is carried via FastlyRequestContext (only JA4/H2 need the raw hook); fix import path to context::FastlyRequestContext. --- docs/guide/adapters/fastly.md | 12 ++++--- docs/guide/blob-app-config-migration.md | 2 +- docs/guide/cli-reference.md | 25 +++++++------- docs/guide/cli-walkthrough.md | 45 ++++++++++++++----------- 4 files changed, 46 insertions(+), 38 deletions(-) diff --git a/docs/guide/adapters/fastly.md b/docs/guide/adapters/fastly.md index 5322ab22..f8d25739 100644 --- a/docs/guide/adapters/fastly.md +++ b/docs/guide/adapters/fastly.md @@ -61,12 +61,14 @@ store metadata. Prefer `run_app` or `dispatch_with_config` for normal use. `dispatch_with_config_handle` exists for advanced/manual cases where you already have a prepared `ConfigStoreHandle`. -### Capturing raw-request signals (JA4, H2, client IP) +### Capturing raw-request signals (JA4, H2 fingerprint) `run_app` converts the `fastly::Request` into a neutral core request before -dispatch, so Fastly-only signals that are readable only on the raw request -(`get_tls_ja4()`, `get_client_h2_fingerprint()`, the client-IP getter) aren't -reachable from handlers by default. Use `run_app_with_request_extensions`, which +dispatch. The client IP is carried across automatically — read it via +`FastlyRequestContext` (see [Context Access](#context-access) below). Other +Fastly-only signals that are readable only on the raw request +(`get_tls_ja4()`, `get_client_h2_fingerprint()`) aren't reachable from handlers +by default. Use `run_app_with_request_extensions`, which runs an app closure against a scratch `Extensions` **before** conversion and merges the values into the core request — so a `State`/extractor or middleware can read them: @@ -224,7 +226,7 @@ Access Fastly-specific APIs via the request context extensions: ```rust use edgezero_core::context::RequestContext; -use edgezero_adapter_fastly::FastlyRequestContext; +use edgezero_adapter_fastly::context::FastlyRequestContext; async fn handler(ctx: RequestContext) -> Result { // Access Fastly context from extensions diff --git a/docs/guide/blob-app-config-migration.md b/docs/guide/blob-app-config-migration.md index d8a5a9cf..2ed20964 100644 --- a/docs/guide/blob-app-config-migration.md +++ b/docs/guide/blob-app-config-migration.md @@ -57,7 +57,7 @@ problems made that untenable as projects grew: blob model writes ONE envelope per `[stores.config]` key. - **Secret resolution was per-handler.** Every handler that read a `#[secret]` field had to remember to call `require_str`. The new - `AppConfig` extractor walks `C::SECRET_FIELDS` once and replaces + `AppConfig` extractor walks `C::secret_fields()` once and replaces each key NAME with the resolved value before handing `cfg` to the handler. diff --git a/docs/guide/cli-reference.md b/docs/guide/cli-reference.md index ce42d628..47b5299e 100644 --- a/docs/guide/cli-reference.md +++ b/docs/guide/cli-reference.md @@ -237,26 +237,27 @@ edgezero config push --adapter [--manifest ] [--app-config ] **Two flavours (same split as `config validate`):** -- The default `edgezero` binary runs the **raw** push — flattens the on-disk TOML tree, JSON-encodes arrays into single values, and pushes every leaf as `(dotted_key, string_value)`. **No secret filtering** — the raw flow has no `AppConfigMeta` to read `SECRET_FIELDS` from, so anything in `.toml` is pushed verbatim. -- A downstream CLI built on `edgezero-cli` that owns its app-config struct (e.g. `app-demo-cli`) runs the **typed** push: runs strict pre-flight validation (`validator::Validate`, secret presence, store-ref membership, adapter checks), serialises the struct via `serde_json`, and **strips every `#[secret]` and `#[secret(store_ref)]` top-level field** before flattening — runtime store ids and secret values both stay out of the config payload. +- The default `edgezero` binary **does not push** — `config push` on the bundled binary always errors with a pointer to the typed downstream CLI. The blob app-config model needs the app's typed `AppConfig` (for validation and canonical serialisation), which the bundled binary doesn't embed. +- A downstream CLI built on `edgezero-cli` that owns its app-config struct (e.g. `app-demo-cli`) runs the **typed** push: strict pre-flight validation (`validator::Validate`, secret presence, store-ref membership, adapter checks), then serialises the struct into a single [`BlobEnvelope`](./blob-app-config-migration.md) — one JSON `{ version, generated_at, sha256, data }` value written under one config-store key. `data` carries **every field VERBATIM, including `#[secret]` / `#[secret(store_ref)]` fields**: their value at rest is the operator-supplied key NAME (e.g. `"demo_api_token"`), which the runtime `AppConfig` extractor swaps for the resolved secret at request time. Nothing is flattened or stripped, and the blob never contains resolved secret bytes. -**Per-adapter behaviour:** +**Per-adapter behaviour:** every adapter writes the **single blob envelope** as one `(key, envelope_json)` config-store entry (the store-resolution and shell mechanics below are unchanged; see [the blob migration guide](./blob-app-config-migration.md#per-adapter-mechanics) for the authoritative per-adapter blob details, including Fastly's oversized-envelope chunking). -| `--adapter` | Behaviour | -| ------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `axum` | Writes the flattened payload to `.edgezero/local-config-.json` (the file `AxumConfigStore` reads back). Creates `.edgezero/` on first use. No shell-out. | -| `cloudflare` | Reads the namespace id from `wrangler.toml` (matched by `binding = `, where `` resolves from `EDGEZERO__STORES__CONFIG____NAME` or falls back to the logical ``), writes the entries to a temp file in wrangler's bulk format (`[{"key": "...", "value": "..."}]`), and runs `wrangler kv bulk put --namespace-id=`. Errors with "did you run `provision`?" if the binding is absent. | -| `fastly` | Resolves the platform config-store id on demand via `fastly config-store list --json` (matched by `name = `, where `` resolves from `EDGEZERO__STORES__CONFIG____NAME` or falls back to the logical ``), then runs `fastly config-store-entry create --store-id= --key= --value=` per entry. Errors with "did you run `provision`?" if the store name isn't found. Re-runs on entries that already exist will fail loudly — delete the entry first or use `fastly config-store-entry update` manually. | -| `spin` | Reads `runtime-config.toml` (default: next to `spin.toml`, override with `--runtime-config `) to dispatch per-backend. **`--local` forces SQLite-direct** writes into `/.spin/sqlite_key_value.db` (Spin's local KV file) regardless of manifest deploy config; non-`default` labels still require a `[key_value_store.