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/18] 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 0000000..d61ef63 --- /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/18] 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 0000000..1632281 --- /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/18] 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 1632281..6073f16 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/18] 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 6073f16..a6150fd 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/18] 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 a6150fd..a9b8a66 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/18] 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 a9b8a66..6aada2a 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/18] 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 c774653..57ef322 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/18] 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 56fea46..d3656e9 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 18e242d..201b39d 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/18] 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 0000000..dde4d71 --- /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 0337e5e..d88bd5d 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/18] 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 c108d1c..19fb9cf 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 4858d5a..cddfbea 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/18] 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 b49c91a..2100fc0 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 1fea962..09e1b08 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 19fb9cf..bb584cd 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/18] 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 4eab9b7..0c8bb82 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/18] 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 201b39d..7baa346 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/18] 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 b4f1c78..c8e53cf 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 a854c0c..8358ac5 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 bd8b93c..f546263 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/18] 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 6b92ac1..492d8ba 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 98649ee..a8c2e4f 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/18] 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 dde4d71..7413862 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 7baa346..d5ba304 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 cddfbea..9d52e3a 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 a8c2e4f..ad5dea0 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/18] 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 6aada2a..df562c8 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 d61ef63..761f241 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/18] 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 7413862..650b17b 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();