diff --git a/Cargo.lock b/Cargo.lock index b49c91ae..2100fc07 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -829,6 +829,7 @@ dependencies = [ "proc-macro2", "quote", "serde", + "serde_json", "syn 2.0.117", "tempfile", "toml", diff --git a/Cargo.toml b/Cargo.toml index 1fea962c..09e1b085 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -69,6 +69,7 @@ sha2 = "0.10" similar = "2" simple_logger = "5" proc-macro2 = { version = "1", features = ["span-locations"] } +quote = "1" syn = { version = "2", features = ["full", "extra-traits", "visit"] } subtle = "2" # Pinned to the `~6.0` range (allows 6.0.x, blocks 6.1+) so a minor diff --git a/crates/edgezero-cli/src/templates/root/edgezero.toml.hbs b/crates/edgezero-cli/src/templates/root/edgezero.toml.hbs index b4f1c78c..c8e53cfa 100644 --- a/crates/edgezero-cli/src/templates/root/edgezero.toml.hbs +++ b/crates/edgezero-cli/src/templates/root/edgezero.toml.hbs @@ -52,6 +52,32 @@ methods = ["GET", "POST"] handler = "{{proj_core_mod}}::handlers::proxy_demo" adapters = [{{{adapter_list}}}] +# -- Introspection routes ------------------------------------------------------ + +[[triggers.http]] +id = "manifest" +path = "/_{{name}}/manifest" +methods = ["GET"] +handler = "edgezero_core::introspection::manifest" +adapters = [{{{adapter_list}}}] +description = "App manifest as JSON" + +[[triggers.http]] +id = "config" +path = "/_{{name}}/config" +methods = ["GET"] +handler = "edgezero_core::introspection::config" +adapters = [{{{adapter_list}}}] +description = "Effective app config (secret-safe)" + +[[triggers.http]] +id = "routes" +path = "/_{{name}}/routes" +methods = ["GET"] +handler = "edgezero_core::introspection::routes" +adapters = [{{{adapter_list}}}] +description = "Registered route table" + # -- Stores ---------------------------------------------------------------- # # `[stores.]` declares logical store ids only. `default` is required diff --git a/crates/edgezero-core/src/context.rs b/crates/edgezero-core/src/context.rs index 56fea46c..24eebf01 100644 --- a/crates/edgezero-core/src/context.rs +++ b/crates/edgezero-core/src/context.rs @@ -69,6 +69,18 @@ impl RequestContext { .and_then(|registry| registry.default_ref()) } + /// Clone a request extension of type `T`, if present. Used by the + /// introspection extractors (`ManifestJson` / `RouteTable`) to read the + /// payload the router injected for their route. + #[must_use] + #[inline] + pub(crate) fn extension(&self) -> Option + where + T: Clone + Send + Sync + 'static, + { + self.request.extensions().get::().cloned() + } + /// # Errors /// Returns [`EdgeError::bad_request`] if the body cannot be deserialized as form-urlencoded data into `T`, or the body is streaming. #[inline] diff --git a/crates/edgezero-core/src/extractor.rs b/crates/edgezero-core/src/extractor.rs index 9bb4086f..bba2c489 100644 --- a/crates/edgezero-core/src/extractor.rs +++ b/crates/edgezero-core/src/extractor.rs @@ -1,3 +1,4 @@ +use std::any; use std::ops::{Deref, DerefMut}; use async_trait::async_trait; @@ -528,6 +529,66 @@ impl Kv { } } +/// Extractor for app-owned shared state registered via +/// [`RouterBuilder::with_state`]. Resolves by type from request extensions. +/// +/// Typically `T = Arc`. The registered value is cloned into every +/// request's extensions before dispatch; registering the same `T` twice is +/// last-write-wins. +/// +/// ```ignore +/// use edgezero_core::extractor::State; +/// use std::sync::Arc; +/// +/// #[edgezero_core::action] +/// async fn handle(State(state): State>) -> Result { +/// Ok(state.greeting.clone()) +/// } +/// ``` +/// +/// [`RouterBuilder::with_state`]: crate::router::RouterBuilder::with_state +pub struct State(pub T); + +#[async_trait(?Send)] +impl FromRequest for State +where + T: Clone + Send + Sync + 'static, +{ + #[inline] + async fn from_request(ctx: &RequestContext) -> Result { + ctx.extension::().map(State).ok_or_else(|| { + EdgeError::internal(anyhow::anyhow!( + "no `State<{}>` registered -- call RouterBuilder::with_state(..) before build()", + any::type_name::() + )) + }) + } +} + +impl Deref for State { + type Target = T; + + #[inline] + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl DerefMut for State { + #[inline] + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.0 + } +} + +impl State { + /// Consume the extractor and return the inner value. + #[inline] + pub fn into_inner(self) -> T { + self.0 + } +} + /// Extractor that yields the per-request [`SecretRegistry`]. /// /// The returned [`BoundSecretStore`] is pre-bound to a platform store name @@ -991,6 +1052,11 @@ mod tests { use std::sync::Arc; use validator::Validate; + #[derive(Clone, Debug, PartialEq)] + struct AppStateFixture { + name: String, + } + #[derive(Debug, Deserialize, PartialEq)] struct FormData { age: Option, @@ -2400,4 +2466,67 @@ mod tests { ); } } + + #[test] + fn state_extractor_resolves_registered_value() { + let mut request = request_builder() + .method(Method::GET) + .uri("/") + .body(Body::empty()) + .expect("request"); + request.extensions_mut().insert(Arc::new(AppStateFixture { + name: "demo".to_owned(), + })); + let ctx = RequestContext::new(request, PathParams::default()); + + let state = + block_on(State::>::from_request(&ctx)).expect("state present"); + + // Deref: State> -> Arc -> AppStateFixture + assert_eq!(state.name, "demo"); + } + + #[test] + fn state_extractor_missing_registration_is_internal_error() { + let request = request_builder() + .method(Method::GET) + .uri("/") + .body(Body::empty()) + .expect("request"); + let ctx = RequestContext::new(request, PathParams::default()); + + // `.err().expect(..)` (not `expect_err`) so we don't require + // `State: Debug` — extractors here mirror Json/Path and omit it. + let err = block_on(State::>::from_request(&ctx)) + .err() + .expect("missing state must surface as an error, not a default"); + assert_eq!(err.status(), StatusCode::INTERNAL_SERVER_ERROR); + } + + #[test] + fn state_extractor_deref_and_into_inner() { + let mut request = request_builder() + .method(Method::GET) + .uri("/") + .body(Body::empty()) + .expect("request"); + request.extensions_mut().insert(AppStateFixture { + name: "x".to_owned(), + }); + let ctx = RequestContext::new(request, PathParams::default()); + + let state = block_on(State::::from_request(&ctx)).expect("state present"); + assert_eq!( + *state, + AppStateFixture { + name: "x".to_owned() + } + ); // Deref + assert_eq!( + state.into_inner(), + AppStateFixture { + name: "x".to_owned() + } + ); + } } diff --git a/crates/edgezero-core/src/handler.rs b/crates/edgezero-core/src/handler.rs index 17fd4831..ea545424 100644 --- a/crates/edgezero-core/src/handler.rs +++ b/crates/edgezero-core/src/handler.rs @@ -6,8 +6,36 @@ use crate::error::EdgeError; use crate::http::HandlerFuture; use crate::response::IntoResponse; +/// Which introspection payloads a route's handler needs injected at dispatch. +/// +/// Reported per handler via [`DynHandler::introspection_needs`]. Handlers written +/// with `#[action(manifest)]` / `#[action(routes)]` set the matching field(s); +/// every other handler reports the default (all-false). +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub struct IntrospectionNeeds { + pub manifest: bool, + pub routes: bool, +} + +impl IntrospectionNeeds { + /// Whether this handler needs any introspection payload injected. + #[must_use] + #[inline] + pub fn any(self) -> bool { + self.manifest || self.routes + } +} + pub trait DynHandler: Send + Sync { fn call(&self, ctx: RequestContext) -> HandlerFuture; + + /// Introspection payloads a route bound to this handler needs injected into + /// the request at dispatch. Defaults to none; `#[action(manifest)]` / + /// `#[action(routes)]` handlers override it. + #[inline] + fn introspection_needs(&self) -> IntrospectionNeeds { + IntrospectionNeeds::default() + } } impl DynHandler for F @@ -21,6 +49,13 @@ where let fut = (self)(ctx); Box::pin(async move { fut.await?.into_response() }) } + + // `missing_trait_methods` (deny) forbids relying on the trait default here; + // spell out the same all-false result that fn/closure handlers report. + #[inline] + fn introspection_needs(&self) -> IntrospectionNeeds { + IntrospectionNeeds::default() + } } pub type BoxHandler = Arc; @@ -38,3 +73,20 @@ where Arc::new(self) } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn fn_handler_reports_default_introspection_needs() { + // A plain closure handler uses the blanket `DynHandler` impl, which must + // report no introspection needs. (`&str: IntoResponse` satisfies the bound.) + let handler = |_ctx: RequestContext| async { Ok::<&'static str, EdgeError>("ok") }; + assert_eq!( + DynHandler::introspection_needs(&handler), + IntrospectionNeeds::default() + ); + assert!(!IntrospectionNeeds::default().any()); + } +} diff --git a/crates/edgezero-core/src/introspection.rs b/crates/edgezero-core/src/introspection.rs new file mode 100644 index 00000000..2c176016 --- /dev/null +++ b/crates/edgezero-core/src/introspection.rs @@ -0,0 +1,312 @@ +//! 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::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 { + method: String, + path: String, +} + +/// Extractor for the baked manifest JSON. It is also the payload the router +/// injects (via `dispatch`) for a route whose handler is `#[action(manifest)]`; +/// `from_request` clones it back out. Errors with 500 if the route did not opt +/// into the `manifest` capability (or no manifest was baked). +#[derive(Clone)] +pub struct ManifestJson(pub Arc); + +#[async_trait(?Send)] +impl FromRequest for ManifestJson { + #[inline] + async fn from_request(ctx: &RequestContext) -> Result { + ctx.extension::().ok_or_else(|| { + EdgeError::internal(anyhow::anyhow!("manifest introspection data not available")) + }) + } +} + +/// Extractor for the live route index. It is also the payload the router injects +/// for a route whose handler is `#[action(routes)]`; `from_request` clones it +/// back out. Errors with 500 if the route did not opt into the `routes` +/// capability. +#[derive(Clone)] +pub struct RouteTable(pub Arc<[RouteInfo]>); + +#[async_trait(?Send)] +impl FromRequest for RouteTable { + #[inline] + async fn from_request(ctx: &RequestContext) -> Result { + ctx.extension::().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) + .header("content-type", "application/json") + .body(body) + .map_err(EdgeError::internal) +} + +/// GET — the app manifest as JSON (baked at compile time by `app!`). +#[action(manifest)] +pub async fn manifest(ManifestJson(json): ManifestJson) -> Result { + json_response(StatusCode::OK, Body::text(json.to_string())) +} + +/// GET — `[{ "method", "path" }]` for every registered route. +#[action(routes)] +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(), + }) + .collect(); + 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 manifest_without_baked_json_is_500() { + // The route opts into `manifest`, but no manifest was baked + // (`with_manifest_json` not called), so `dispatch` injects nothing and + // the `ManifestJson` extractor errors 500. + let router = RouterService::builder().get("/m", manifest).build(); + let req = request_builder() + .method(Method::GET) + .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(); + 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); + 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"); + assert!(arr + .iter() + .any(|entry| entry["method"] == "GET" && entry["path"] == "/r")); + } + + #[test] + fn config_without_store_is_not_found() { + let router = RouterService::builder().get("/c", config).build(); + let req = request_builder() + .method(Method::GET) + .uri("/c") + .body(Body::empty()) + .unwrap(); + let resp = block_on(router.oneshot(req)).unwrap(); + assert_eq!(resp.status(), StatusCode::NOT_FOUND); + } + + #[test] + fn config_happy_path_returns_envelope_data_secret_safe() { + let data = serde_json::json!({ "greeting": "hi", "api_token": "demo_api_token" }); + let resp = run_config(StubStore(Ok(Some(valid_envelope_json(data))))); + assert_eq!(resp.status(), StatusCode::OK); + // Raw envelope `data` verbatim: the secret field holds the KEY NAME, + // never a resolved value. + let body = body_json(resp); + assert_eq!(body["greeting"], "hi"); + assert_eq!(body["api_token"], "demo_api_token"); + } + + #[test] + fn config_missing_blob_is_not_found() { + let resp = run_config(StubStore(Ok(None))); + assert_eq!(resp.status(), StatusCode::NOT_FOUND); + } + + #[test] + fn config_backend_unavailable_maps_503() { + let resp = run_config(StubStore(Err(ConfigStoreError::unavailable("x")))); + assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE); + } + + #[test] + fn config_invalid_key_maps_400() { + let resp = run_config(StubStore(Err(ConfigStoreError::invalid_key("x")))); + assert_eq!(resp.status(), StatusCode::BAD_REQUEST); + } + + #[test] + fn config_backend_internal_maps_500() { + let resp = run_config(StubStore(Err(ConfigStoreError::internal(anyhow::anyhow!( + "x" + ))))); + assert_eq!(resp.status(), StatusCode::INTERNAL_SERVER_ERROR); + } + + #[test] + fn config_malformed_envelope_maps_500() { + let resp = run_config(StubStore(Ok(Some("not json".to_owned())))); + assert_eq!(resp.status(), StatusCode::INTERNAL_SERVER_ERROR); + } + + #[test] + fn config_sha_mismatch_maps_500() { + // Valid JSON envelope shape but wrong sha → verify() fails. + let bad = r#"{"data":{"a":1},"generated_at":"t","sha256":"deadbeef","version":1}"#; + let resp = run_config(StubStore(Ok(Some(bad.to_owned())))); + assert_eq!(resp.status(), StatusCode::INTERNAL_SERVER_ERROR); + } + + #[test] + fn config_unknown_version_maps_500() { + let bad = r#"{"data":{},"generated_at":"t","sha256":"x","version":99}"#; + let resp = run_config(StubStore(Ok(Some(bad.to_owned())))); + assert_eq!(resp.status(), StatusCode::INTERNAL_SERVER_ERROR); + } +} diff --git a/crates/edgezero-core/src/lib.rs b/crates/edgezero-core/src/lib.rs index 0337e5e3..d88bd5d8 100644 --- a/crates/edgezero-core/src/lib.rs +++ b/crates/edgezero-core/src/lib.rs @@ -9,6 +9,10 @@ reason = "proc-macros must be re-exported through the parent crate" )] +// Required so `#[action]` handlers defined inside this crate resolve the +// absolute `::edgezero_core::…` paths the proc-macro emits. +extern crate self as edgezero_core; + pub mod addr; pub mod app; pub mod app_config; @@ -23,6 +27,7 @@ pub mod error; pub mod extractor; pub mod handler; pub mod http; +pub mod introspection; pub mod key_value_store; pub mod manifest; pub mod middleware; diff --git a/crates/edgezero-core/src/manifest.rs b/crates/edgezero-core/src/manifest.rs index c7746530..57ef3221 100644 --- a/crates/edgezero-core/src/manifest.rs +++ b/crates/edgezero-core/src/manifest.rs @@ -1,6 +1,6 @@ use log::LevelFilter; use serde::de::Error as DeError; -use serde::Deserialize; +use serde::{Deserialize, Serialize}; use std::collections::{BTreeMap, BTreeSet}; use std::path::{Path, PathBuf}; use std::sync::Arc; @@ -83,7 +83,7 @@ impl ManifestLoader { } } -#[derive(Debug, Deserialize, Validate)] +#[derive(Debug, Deserialize, Serialize, Validate)] #[validate(schema(function = "validate_manifest_adapter_keys_case_unique"))] #[expect( clippy::partial_pub_fields, @@ -214,20 +214,26 @@ impl Manifest { } } -#[derive(Debug, Default, Deserialize, Validate)] +#[derive(Debug, Default, Deserialize, Serialize, Validate)] #[non_exhaustive] pub struct ManifestApp { #[serde(default)] #[validate(length(min = 1_u64))] pub entry: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[validate(length(min = 1_u64))] + pub kind: Option, #[serde(default)] pub middleware: Vec, #[serde(default)] #[validate(length(min = 1_u64))] pub name: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[validate(length(min = 1_u64))] + pub version: Option, } -#[derive(Debug, Default, Deserialize, Validate)] +#[derive(Debug, Default, Deserialize, Serialize, Validate)] #[non_exhaustive] pub struct ManifestTriggers { #[serde(default)] @@ -235,7 +241,7 @@ pub struct ManifestTriggers { pub http: Vec, } -#[derive(Clone, Debug, Deserialize, Validate)] +#[derive(Clone, Debug, Deserialize, Serialize, Validate)] #[non_exhaustive] pub struct ManifestHttpTrigger { #[serde(default)] @@ -273,10 +279,10 @@ impl ManifestHttpTrigger { } } -#[derive(Debug, Default, Deserialize, Validate)] +#[derive(Debug, Default, Deserialize, Serialize, Validate)] #[non_exhaustive] pub struct ManifestEnvironment { - #[serde(default)] + #[serde(default, serialize_with = "serialize_secrets")] #[validate(nested)] pub secrets: Vec, #[serde(default)] @@ -284,7 +290,7 @@ pub struct ManifestEnvironment { pub variables: Vec, } -#[derive(Debug, Deserialize, Validate)] +#[derive(Debug, Deserialize, Serialize, Validate)] #[non_exhaustive] pub struct ManifestBinding { #[serde(default)] @@ -316,6 +322,14 @@ impl ManifestBinding { } } +#[derive(Clone, Debug)] +pub struct ResolvedEnvironmentBinding { + pub description: Option, + pub env: String, + pub name: String, + pub value: Option, +} + impl ResolvedEnvironmentBinding { fn from_manifest(binding: &ManifestBinding) -> Self { Self { @@ -327,21 +341,13 @@ impl ResolvedEnvironmentBinding { } } -#[derive(Clone, Debug)] -pub struct ResolvedEnvironmentBinding { - pub description: Option, - pub env: String, - pub name: String, - pub value: Option, -} - #[derive(Clone, Debug, Default)] pub struct ResolvedEnvironment { pub secrets: Vec, pub variables: Vec, } -#[derive(Debug, Default, Deserialize, Validate)] +#[derive(Debug, Default, Deserialize, Serialize, Validate)] #[non_exhaustive] #[validate(schema(function = "validate_manifest_adapter"))] pub struct ManifestAdapter { @@ -365,7 +371,7 @@ pub struct ManifestAdapter { pub logging: ManifestLoggingConfig, } -#[derive(Debug, Default, Deserialize, Validate)] +#[derive(Debug, Default, Deserialize, Serialize, Validate)] #[non_exhaustive] #[validate(schema(function = "validate_manifest_adapter_definition"))] pub struct ManifestAdapterDefinition { @@ -402,7 +408,7 @@ pub struct ManifestAdapterDefinition { pub port: Option, } -#[derive(Debug, Default, Deserialize, Validate)] +#[derive(Debug, Default, Deserialize, Serialize, Validate)] #[non_exhaustive] pub struct ManifestAdapterBuild { #[serde(default)] @@ -415,7 +421,7 @@ pub struct ManifestAdapterBuild { pub target: Option, } -#[derive(Debug, Default, Deserialize, Validate)] +#[derive(Debug, Default, Deserialize, Serialize, Validate)] #[non_exhaustive] pub struct ManifestAdapterCommands { /// Per-project override for `edgezero auth login --adapter `. @@ -457,7 +463,7 @@ pub struct ManifestAdapterCommands { /// adapter sections, etc.) already reject legacy fields below this /// level, so adding the rejection HERE seals the only remaining /// silent-typo path. -#[derive(Debug, Default, Deserialize, Validate)] +#[derive(Debug, Default, Deserialize, Serialize, Validate)] #[serde(deny_unknown_fields)] #[non_exhaustive] pub struct ManifestStores { @@ -479,7 +485,7 @@ pub struct ManifestStores { /// tuning. Platform-specific runtime config (store names, tuning) is supplied /// out of band; in this interim model a store's name resolves to its logical /// [`StoreDeclaration::default_id`]. -#[derive(Debug, Deserialize, Validate)] +#[derive(Debug, Deserialize, Serialize, Validate)] #[non_exhaustive] #[validate(schema(function = "validate_store_declaration"))] pub struct StoreDeclaration { @@ -516,7 +522,7 @@ impl StoreDeclaration { // Logging (unchanged) // --------------------------------------------------------------------------- -#[derive(Debug, Default, Deserialize, Validate)] +#[derive(Debug, Default, Deserialize, Serialize, Validate)] #[non_exhaustive] pub struct ManifestLogging { #[serde(flatten)] @@ -524,7 +530,7 @@ pub struct ManifestLogging { pub adapters: BTreeMap, } -#[derive(Debug, Default, Deserialize, Clone, Validate)] +#[derive(Debug, Default, Deserialize, Serialize, Clone, Validate)] #[non_exhaustive] pub struct ManifestLoggingConfig { #[serde(default)] @@ -634,6 +640,13 @@ impl<'de> Deserialize<'de> for HttpMethod { } } +impl serde::Serialize for HttpMethod { + #[inline] + fn serialize(&self, serializer: S) -> Result { + serializer.serialize_str(self.as_str()) + } +} + #[derive(Clone, Debug, Eq, PartialEq)] #[non_exhaustive] pub enum BodyMode { @@ -664,6 +677,16 @@ impl<'de> Deserialize<'de> for BodyMode { } } +impl serde::Serialize for BodyMode { + #[inline] + fn serialize(&self, serializer: S) -> Result { + serializer.serialize_str(match self { + Self::Buffered => "buffered", + Self::Stream => "stream", + }) + } +} + #[derive(Clone, Copy, Debug, Eq, PartialEq, Default)] #[non_exhaustive] pub enum LogLevel { @@ -734,6 +757,46 @@ impl<'de> Deserialize<'de> for LogLevel { } } +impl serde::Serialize for LogLevel { + #[inline] + fn serialize(&self, serializer: S) -> Result { + serializer.serialize_str(self.as_str()) + } +} + +/// Serialize a `[[environment.secrets]]` list without exposing `value`. +/// Secret bindings share `ManifestBinding` with variables, whose `value` +/// is safe to emit; secret values must never appear in manifest output. +fn serialize_secrets(secrets: &[ManifestBinding], serializer: S) -> Result +where + S: serde::Serializer, +{ + use serde::ser::SerializeSeq as _; + + #[derive(Serialize)] + struct RedactedBinding { + #[serde(skip_serializing_if = "Vec::is_empty")] + adapters: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + description: Option, + #[serde(skip_serializing_if = "Option::is_none")] + env: Option, + name: String, + // `value` intentionally omitted. + } + + let mut seq = serializer.serialize_seq(Some(secrets.len()))?; + for binding in secrets { + seq.serialize_element(&RedactedBinding { + adapters: binding.adapters.clone(), + description: binding.description.clone(), + env: binding.env.clone(), + name: binding.name.clone(), + })?; + } + seq.end() +} + fn resolve_root_path(path: &Path, cwd: &Path) -> PathBuf { match path.parent() { Some(parent) if parent.as_os_str().is_empty() => cwd.to_path_buf(), @@ -996,6 +1059,72 @@ env = "APP_TOKEN" assert_eq!(manifest.app.name.as_deref(), Some("demo")); } + #[test] + fn serializes_manifest_and_redacts_secret_values() { + let toml = r#" +[app] +name = "t" +version = "0.1.0" +kind = "http" + +[[triggers.http]] +id = "root" +path = "/" +methods = ["GET"] +handler = "t::handlers::root" + +[[environment.variables]] +name = "LOG_LEVEL" +value = "info" + +[[environment.secrets]] +name = "API_TOKEN" +value = "super-secret-value" +"#; + let manifest: Manifest = toml::from_str(toml).unwrap(); + let json = serde_json::to_value(&manifest).unwrap(); + + // [app] version/kind round-trip + assert_eq!(json["app"]["version"], "0.1.0"); + assert_eq!(json["app"]["kind"], "http"); + // variables keep their value + assert_eq!(json["environment"]["variables"][0]["value"], "info"); + // secrets NEVER expose value + let secret = &json["environment"]["secrets"][0]; + assert_eq!(secret["name"], "API_TOKEN"); + assert!( + secret.get("value").is_none(), + "secret value must be redacted" + ); + // Enums serialize to their wire strings, not Rust variant names. + assert_eq!(json["triggers"]["http"][0]["methods"][0], "GET"); + } + + #[test] + fn serializes_enums_with_wire_casing() { + let toml = r#" +[app] +name = "t" + +[[triggers.http]] +id = "r" +path = "/" +methods = ["POST"] +handler = "t::h::r" +body-mode = "buffered" + +[logging.axum] +level = "info" +"#; + let manifest: Manifest = toml::from_str(toml).unwrap(); + let json = serde_json::to_value(&manifest).unwrap(); + assert_eq!(json["triggers"]["http"][0]["methods"][0], "POST"); + // `body_mode` is serde-renamed to `body-mode` (manifest.rs:243), so the + // serialized key is `body-mode`, NOT `body_mode`. + assert_eq!(json["triggers"]["http"][0]["body-mode"], "buffered"); + assert_eq!(json["logging"]["axum"]["level"], "info"); + } + #[test] fn try_load_from_str_rejects_invalid_toml() { let err = ManifestLoader::try_load_from_str("not a [valid manifest\n") diff --git a/crates/edgezero-core/src/router.rs b/crates/edgezero-core/src/router.rs index 18e242d7..b29398c5 100644 --- a/crates/edgezero-core/src/router.rs +++ b/crates/edgezero-core/src/router.rs @@ -3,36 +3,33 @@ 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::handler::{BoxHandler, IntoHandler, IntrospectionNeeds}; +use crate::http::{Extensions, HandlerFuture, Method, Request, Response}; +use crate::introspection::{ManifestJson, RouteTable}; 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, + introspection_needs: IntrospectionNeeds, } impl Clone for RouteEntry { fn clone(&self) -> Self { Self { handler: Arc::clone(&self.handler), + introspection_needs: self.introspection_needs, } } fn clone_from(&mut self, source: &Self) { self.handler = Arc::clone(&source.handler); + self.introspection_needs = source.introspection_needs; } } @@ -64,24 +61,23 @@ impl RouteInfo { } } -#[derive(Serialize)] -struct RouteListingEntry { - method: String, - path: String, -} - enum RouteMatch<'route> { Found(&'route RouteEntry, PathParams), MethodNotAllowed(Vec), NotFound, } +/// Type-erased closure that clones a registered state value into a request's +/// extensions at dispatch. See [`RouterBuilder::with_state`]. +type StateInserter = Arc; + #[derive(Default)] pub struct RouterBuilder { + manifest_json: Option>, middlewares: Vec, route_info: Vec, - route_listing_path: Option, routes: HashMap>, + state_inserters: Vec, } impl RouterBuilder { @@ -95,11 +91,17 @@ impl RouterBuilder { { let router = self.routes.entry(method.clone()).or_default(); + // The handler reports which introspection payloads its route needs; the + // flag is read once here and consulted per request in `dispatch`. + let boxed = handler.into_handler(); + let introspection_needs = boxed.introspection_needs(); + router .insert( path, RouteEntry { - handler: handler.into_handler(), + handler: boxed, + introspection_needs, }, ) .unwrap_or_else(|err| panic!("duplicate route definition for {path}: {err}")); @@ -108,56 +110,18 @@ 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); + pub fn build(self) -> RouterService { + let route_index: Arc<[RouteInfo]> = Arc::from(self.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}")); - } - - RouterService::new(self.routes, self.middlewares, route_index) + RouterService::new( + self.routes, + self.middlewares, + route_index, + self.manifest_json, + self.state_inserters, + ) } #[must_use] @@ -169,33 +133,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 @@ -255,21 +192,73 @@ 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 + } + + /// Register a value cloned into every request's extensions before + /// dispatch, making it available to the [`State`] extractor and to + /// `RequestContext`-based handlers. + /// + /// Typically `T = Arc`. Registering the same `T` twice is + /// last-write-wins. Cost is one `T::clone` (an `Arc` bump for + /// `Arc`) per registered state per request. + /// + /// [`State`]: crate::extractor::State + #[must_use] + #[inline] + pub fn with_state(mut self, value: T) -> Self + where + T: Clone + Send + Sync + 'static, + { + self.state_inserters + .push(Arc::new(move |ext: &mut Extensions| { + ext.insert(value.clone()); + })); + self + } } struct RouterInner { + manifest_json: Option>, middlewares: Vec, route_index: Arc<[RouteInfo]>, routes: HashMap>, + state_inserters: Vec, } impl RouterInner { - async fn dispatch(&self, request: Request) -> Result { + async fn dispatch(&self, mut request: Request) -> Result { let method = request.method().clone(); let path = request.uri().path().to_owned(); match self.find_route(&method, &path) { RouteMatch::Found(entry, params) => { + // Inject only the introspection payloads this route asked for — + // nothing for the vast majority of routes that need none. + let needs = entry.introspection_needs; + if needs.manifest { + if let Some(json) = &self.manifest_json { + request + .extensions_mut() + .insert(ManifestJson(Arc::clone(json))); + } + } + if needs.routes { + request + .extensions_mut() + .insert(RouteTable(Arc::clone(&self.route_index))); + } + // App-owned state registered via RouterBuilder::with_state. + // Runs after introspection inserts; distinct TypeIds, so no + // collision. Last registered wins for a given `T`. + for inserter in &self.state_inserters { + inserter(request.extensions_mut()); + } let ctx = RequestContext::new(request, params); let next = Next::new(&self.middlewares, entry.handler.as_ref()); next.run(ctx).await @@ -344,12 +333,16 @@ impl RouterService { routes: HashMap>, middlewares: Vec, route_index: Arc<[RouteInfo]>, + manifest_json: Option>, + state_inserters: Vec, ) -> Self { Self { inner: Arc::new(RouterInner { + manifest_json, middlewares, route_index, routes, + state_inserters, }), } } @@ -373,21 +366,150 @@ 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 { + /// Per-capability introspection injection: a route receives exactly the + /// payloads its handler opted into via `#[action(manifest|routes)]`. + mod introspection_gating { + use super::*; + use crate::handler::DynHandler; + + /// A handler that records which introspection payloads its request + /// carried, as `(manifest_present, routes_present)`, and reports `needs`. + struct CapProbe { + needs: IntrospectionNeeds, + seen: Arc>>, + } + + impl DynHandler for CapProbe { + fn call(&self, ctx: RequestContext) -> HandlerFuture { + let seen = Arc::clone(&self.seen); + Box::pin(async move { + *seen.lock().unwrap() = Some(( + ctx.extension::().is_some(), + ctx.extension::().is_some(), + )); + response_with_body(StatusCode::OK, Body::empty()) + }) + } + fn introspection_needs(&self) -> IntrospectionNeeds { + self.needs + } + } + + #[test] + fn manifest_and_routes_route_injects_both() { + // Combined `#[action(manifest, routes)]` → both payloads injected. + let seen = run_probe( + RouterService::builder().with_manifest_json("{\"app\":{\"name\":\"t\"}}"), + IntrospectionNeeds { + manifest: true, + routes: true, + }, + ); + assert_eq!(seen, (true, true)); + } + + #[test] + fn manifest_route_injects_only_manifest() { + // Manifest available AND requested → ManifestJson present, RouteTable absent. + let seen = run_probe( + RouterService::builder().with_manifest_json("{\"app\":{\"name\":\"t\"}}"), + IntrospectionNeeds { + manifest: true, + routes: false, + }, + ); + assert_eq!(seen, (true, false)); + } + + #[test] + fn middleware_sees_injected_manifest() { + // Injection happens before the middleware chain, so a middleware on a + // manifest-flagged route sees the payload. + struct Probe(Arc>>); + #[async_trait::async_trait(?Send)] + impl Middleware for Probe { + async fn handle( + &self, + ctx: RequestContext, + next: Next<'_>, + ) -> Result { + *self.0.lock().unwrap() = Some(ctx.extension::().is_some()); + next.run(ctx).await + } + } + + let saw: Arc>> = Arc::new(Mutex::new(None)); + let router = RouterService::builder() + .with_manifest_json("{\"app\":{\"name\":\"t\"}}") + .middleware(Probe(Arc::clone(&saw))) + .get( + "/", + CapProbe { + needs: IntrospectionNeeds { + manifest: true, + routes: false, + }, + seen: Arc::new(Mutex::new(None)), + }, + ) + .build(); + let request = request_builder() + .method(Method::GET) + .uri("/") + .body(Body::empty()) + .unwrap(); + block_on(router.oneshot(request)).unwrap(); + assert_eq!(*saw.lock().unwrap(), Some(true)); + } + + #[test] + fn plain_route_injects_neither() { + // Manifest IS baked but the route requested nothing → neither injected. + let seen = run_probe( + RouterService::builder().with_manifest_json("{\"app\":{\"name\":\"t\"}}"), + IntrospectionNeeds::default(), + ); + assert_eq!(seen, (false, false)); + } + + #[test] + fn routes_route_injects_only_routes() { + // Only `routes` requested → RouteTable present (from the always-available + // route index), ManifestJson absent (not requested; none baked either). + let seen = run_probe( + RouterService::builder(), + IntrospectionNeeds { + manifest: false, + routes: true, + }, + ); + assert_eq!(seen, (false, true)); + } + + fn run_probe(builder: RouterBuilder, needs: IntrospectionNeeds) -> (bool, bool) { + let seen = Arc::new(Mutex::new(None)); + let router = builder + .get( + "/", + CapProbe { + needs, + seen: Arc::clone(&seen), + }, + ) + .build(); + let request = request_builder() + .method(Method::GET) + .uri("/") + .body(Body::empty()) + .unwrap(); + block_on(router.oneshot(request)).unwrap(); + let observed = *seen.lock().unwrap(); + observed.expect("handler ran") + } + } + use super::*; use crate::body::Body; use crate::context::RequestContext; @@ -397,9 +519,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}; @@ -603,6 +723,7 @@ mod tests { fn route_entry_clone_copies_handler() { let entry = RouteEntry { handler: ok_handler.into_handler(), + introspection_needs: IntrospectionNeeds::default(), }; let cloned = entry.clone(); @@ -616,117 +737,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)] @@ -799,4 +809,146 @@ mod tests { }); assert_eq!(collected, b"chunk-one\nchunk-two\n"); } + + #[test] + fn with_state_exposes_value_to_handler() { + use crate::extractor::{FromRequest as _, State}; + + #[derive(Clone)] + struct Counter(u32); + + async fn handler(ctx: RequestContext) -> Result { + let State(counter) = State::::from_request(&ctx).await?; + Ok(format!("count={}", counter.0)) + } + + let service = RouterService::builder() + .with_state(Counter(9)) + .get("/count", handler) + .build(); + + let request = request_builder() + .method(Method::GET) + .uri("/count") + .body(Body::empty()) + .expect("request"); + + let response = block_on(service.oneshot(request)).expect("response"); + assert_eq!(response.status(), StatusCode::OK); + assert_eq!(response.body().as_bytes().expect("buffered"), b"count=9"); + } + + #[test] + fn with_state_last_write_wins_for_same_type() { + use crate::extractor::{FromRequest as _, State}; + + #[derive(Clone)] + struct Counter(u32); + + async fn handler(ctx: RequestContext) -> Result { + let State(counter) = State::::from_request(&ctx).await?; + Ok(format!("count={}", counter.0)) + } + + let service = RouterService::builder() + .with_state(Counter(1)) + .with_state(Counter(2)) + .get("/c", handler) + .build(); + + let request = request_builder() + .method(Method::GET) + .uri("/c") + .body(Body::empty()) + .expect("request"); + + let response = block_on(service.oneshot(request)).expect("response"); + assert_eq!(response.body().as_bytes().expect("buffered"), b"count=2"); + } + + #[test] + fn with_state_no_cross_request_bleed() { + use crate::extractor::{FromRequest as _, State}; + use std::future::Future as _; + + #[derive(Clone)] + struct Tag(&'static str); + + async fn handler(ctx: RequestContext) -> Result { + let State(tag) = State::::from_request(&ctx).await?; + Ok(tag.0.to_owned()) + } + + let service = RouterService::builder() + .with_state(Tag("shared")) + .get("/t", handler) + .build(); + + let req1 = request_builder() + .method(Method::GET) + .uri("/t") + .body(Body::empty()) + .expect("req1"); + let req2 = request_builder() + .method(Method::GET) + .uri("/t") + .body(Body::empty()) + .expect("req2"); + + // Two independent in-flight requests, polled interleaved on one thread. + let mut f1 = Box::pin(service.oneshot(req1)); + let mut f2 = Box::pin(service.oneshot(req2)); + let mut cx = Context::from_waker(noop_waker_ref()); + + let mut r1 = None; + let mut r2 = None; + while r1.is_none() || r2.is_none() { + if r1.is_none() { + if let Poll::Ready(value) = f1.as_mut().poll(&mut cx) { + r1 = Some(value); + } + } + if r2.is_none() { + if let Poll::Ready(value) = f2.as_mut().poll(&mut cx) { + r2 = Some(value); + } + } + } + + let resp1 = r1.unwrap().expect("resp1"); + let resp2 = r2.unwrap().expect("resp2"); + assert_eq!(resp1.body().as_bytes().expect("buffered"), b"shared"); + assert_eq!(resp2.body().as_bytes().expect("buffered"), b"shared"); + } + + #[test] + fn with_state_supports_multiple_distinct_types() { + use crate::extractor::{FromRequest as _, State}; + + #[derive(Clone)] + struct First(u32); + #[derive(Clone)] + struct Second(&'static str); + + async fn handler(ctx: RequestContext) -> Result { + let State(first) = State::::from_request(&ctx).await?; + let State(second) = State::::from_request(&ctx).await?; + Ok(format!("{}-{}", first.0, second.0)) + } + + let service = RouterService::builder() + .with_state(First(7)) + .with_state(Second("hi")) + .get("/both", handler) + .build(); + + let request = request_builder() + .method(Method::GET) + .uri("/both") + .body(Body::empty()) + .expect("request"); + + let response = block_on(service.oneshot(request)).expect("response"); + assert_eq!(response.body().as_bytes().expect("buffered"), b"7-hi"); + } } diff --git a/crates/edgezero-macros/Cargo.toml b/crates/edgezero-macros/Cargo.toml index c108d1ca..81f0b970 100644 --- a/crates/edgezero-macros/Cargo.toml +++ b/crates/edgezero-macros/Cargo.toml @@ -13,10 +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"] } -syn = { version = "2", features = ["full"] } +serde_json = { workspace = true } +syn = { workspace = true } toml = { workspace = true } validator = { workspace = true, features = ["derive"] } @@ -26,5 +27,6 @@ validator = { workspace = true, features = ["derive"] } # users will. Cargo allows dev-dep cycles (only the main dep edge # matters for build ordering). edgezero-core = { workspace = true } +futures = { workspace = true } tempfile = { workspace = true } trybuild = { workspace = true } diff --git a/crates/edgezero-macros/src/action.rs b/crates/edgezero-macros/src/action.rs index 92a03c63..064a90fe 100644 --- a/crates/edgezero-macros/src/action.rs +++ b/crates/edgezero-macros/src/action.rs @@ -1,6 +1,12 @@ use proc_macro::TokenStream; use quote::{format_ident, quote}; -use syn::{spanned::Spanned as _, Error, FnArg, ItemFn, Pat, PathArguments, Type}; +use syn::parse::Parser as _; +use syn::punctuated::Punctuated; +use syn::{spanned::Spanned as _, Error, FnArg, ItemFn, Pat, PathArguments, Token, Type}; + +/// `(extract_stmts, arg_idents)` produced from a handler's argument list — the +/// `FromRequest` extraction statements and the idents passed to the inner fn. +type ArgExtractors = (Vec, Vec); pub fn expand_action(attr: TokenStream, item: TokenStream) -> TokenStream { expand_action_impl(&attr.into(), item.into()).into() @@ -10,10 +16,15 @@ fn expand_action_impl( attr: &proc_macro2::TokenStream, item: proc_macro2::TokenStream, ) -> proc_macro2::TokenStream { - if !attr.is_empty() { - return syn::Error::new(attr.span(), "#[action] does not accept arguments") - .to_compile_error(); - } + // `#[action]` takes an optional atomic capability list, e.g. + // `#[action(manifest)]` / `#[action(routes)]` / `#[action(manifest, routes)]`. + // Each names an introspection payload the handler needs injected; a handler + // that opts in is emitted as a capability-carrying struct (see below). + let (manifest_cap, routes_cap) = match parse_action_params(attr) { + Ok(caps) => caps, + Err(err) => return err.to_compile_error(), + }; + let is_capability_handler = manifest_cap || routes_cap; let func: ItemFn = match syn::parse2(item) { Ok(func) => func, @@ -52,6 +63,93 @@ fn expand_action_impl( return err.to_compile_error(); } + let (extract_stmts, arg_idents) = match build_arg_extractors(&func) { + Ok(parts) => parts, + Err(err) => return err.to_compile_error(), + }; + + let output = if is_capability_handler { + // A fn can't carry per-handler data past type-erasure into + // `Arc`, so an opt-in handler becomes a unit struct with + // its own `DynHandler` impl whose `introspection_needs()` reports which + // payloads the router must inject for its route. + quote! { + #inner_fn + + #(#attrs)* + #[allow(non_camel_case_types)] + #vis struct #ident; + + impl ::edgezero_core::handler::DynHandler for #ident { + #[inline] + fn call( + &self, + __ctx: ::edgezero_core::context::RequestContext, + ) -> ::edgezero_core::http::HandlerFuture { + ::std::boxed::Box::pin(async move { + #(#extract_stmts)* + let result = #inner_ident(#(#arg_idents),*).await; + ::edgezero_core::responder::Responder::respond(result) + }) + } + + #[inline] + fn introspection_needs(&self) -> ::edgezero_core::handler::IntrospectionNeeds { + ::edgezero_core::handler::IntrospectionNeeds { + manifest: #manifest_cap, + routes: #routes_cap, + } + } + } + } + } else { + quote! { + #inner_fn + + #(#attrs)* + #vis async fn #ident( + __ctx: ::edgezero_core::context::RequestContext, + ) -> ::std::result::Result<::edgezero_core::http::Response, ::edgezero_core::error::EdgeError> { + #(#extract_stmts)* + let result = #inner_ident(#(#arg_idents),*).await; + ::edgezero_core::responder::Responder::respond(result) + } + } + }; + + output +} + +/// Parse the optional `#[action(...)]` capability list into +/// `(needs_manifest, needs_routes)`. Empty attr → `(false, false)`. Unknown +/// idents are a compile error. Extend the known set as new capabilities land. +fn parse_action_params(attr: &proc_macro2::TokenStream) -> Result<(bool, bool), Error> { + if attr.is_empty() { + return Ok((false, false)); + } + let params = Punctuated::::parse_terminated.parse2(attr.clone())?; + let mut manifest_cap = false; + let mut routes_cap = false; + for param in ¶ms { + if param == "manifest" { + manifest_cap = true; + } else if param == "routes" { + routes_cap = true; + } else { + return Err(Error::new( + param.span(), + format!("unknown #[action] parameter `{param}`; supported: manifest, routes"), + )); + } + } + Ok((manifest_cap, routes_cap)) +} + +/// Build the per-argument extractor statements and the argument idents passed to +/// the inner fn. `RequestContext` arguments map to `__ctx`; every other argument +/// is extracted via `FromRequest`. Returns the `(extract_stmts, arg_idents)` +/// used by both the fn and struct codegen forms. +fn build_arg_extractors(func: &ItemFn) -> Result { let mut extract_stmts = Vec::new(); let mut arg_idents = Vec::new(); let mut has_request_context = false; @@ -60,22 +158,20 @@ fn expand_action_impl( let pat_type = match arg { FnArg::Typed(pat_type) => pat_type, FnArg::Receiver(receiver) => { - return syn::Error::new( + return Err(Error::new( receiver.span(), "#[action] functions cannot have a `self` receiver", - ) - .to_compile_error(); + )); } }; let ty = &pat_type.ty; if is_request_context_type(ty) { if has_request_context { - return syn::Error::new( + return Err(Error::new( ty.span(), "#[action] functions support at most one RequestContext argument", - ) - .to_compile_error(); + )); } has_request_context = true; arg_idents.push(quote! { __ctx }); @@ -89,20 +185,7 @@ fn expand_action_impl( arg_idents.push(quote! { #var_ident }); } - let output = quote! { - #inner_fn - - #(#attrs)* - #vis async fn #ident( - __ctx: ::edgezero_core::context::RequestContext, - ) -> ::std::result::Result<::edgezero_core::http::Response, ::edgezero_core::error::EdgeError> { - #(#extract_stmts)* - let result = #inner_ident(#(#arg_idents),*).await; - ::edgezero_core::responder::Responder::respond(result) - } - }; - - output + Ok((extract_stmts, arg_idents)) } fn is_request_context_type(ty: &Type) -> bool { @@ -194,8 +277,12 @@ mod tests { let output = expand_action_impl(&TokenStream::new(), input); let rendered = render(&output); assert!(rendered.contains("__demo_inner")); - assert!(rendered.contains("fn demo")); assert!(rendered.contains("responder :: Responder :: respond")); + // No params → a plain `async fn`, NOT a capability-carrying struct. + let collapsed = collapse_whitespace(&rendered); + assert!(collapsed.contains("asyncfndemo")); + assert!(!collapsed.contains("structdemo")); + assert!(!collapsed.contains("introspection_needs")); } #[test] @@ -209,15 +296,61 @@ mod tests { } #[test] - fn rejects_attribute_arguments() { + fn rejects_unknown_param() { let input = quote! { async fn demo(ctx: ::edgezero_core::context::RequestContext) -> ::edgezero_core::http::Response { unimplemented!() } }; - let output = expand_action_impl("e!(path = "/demo"), input); + let output = expand_action_impl("e!(bogus), input); let rendered = render(&output); - assert!(rendered.contains("does not accept arguments")); + assert!(rendered.contains("unknown #[action] parameter")); + } + + #[test] + fn manifest_param_emits_capability_struct() { + let input = quote! { + async fn manifest( + ManifestJson(json): ManifestJson, + ) -> ::std::result::Result< + ::edgezero_core::http::Response, + ::edgezero_core::error::EdgeError, + > { + let _ = json; + unimplemented!() + } + }; + let output = expand_action_impl("e!(manifest), input); + let collapsed = collapse_whitespace(&render(&output)); + // Opt-in handlers become a capability-carrying struct, not a fn. + assert!(collapsed.contains("structmanifest")); + assert!(collapsed.contains("DynHandlerformanifest")); + assert!(collapsed.contains("fnintrospection_needs")); + // The `manifest` capability field is set true; `routes` false. + assert!(collapsed.contains("manifest:true")); + assert!(collapsed.contains("routes:false")); + } + + #[test] + fn manifest_and_routes_params_set_both_capabilities() { + let input = quote! { + async fn both( + ManifestJson(json): ManifestJson, + RouteTable(table): RouteTable, + ) -> ::std::result::Result< + ::edgezero_core::http::Response, + ::edgezero_core::error::EdgeError, + > { + let _ = (json, table); + unimplemented!() + } + }; + let output = expand_action_impl("e!(manifest, routes), input); + let collapsed = collapse_whitespace(&render(&output)); + // The combined form emits a struct whose `introspection_needs` sets both. + assert!(collapsed.contains("structboth")); + assert!(collapsed.contains("manifest:true")); + assert!(collapsed.contains("routes:true")); } #[test] diff --git a/crates/edgezero-macros/src/app.rs b/crates/edgezero-macros/src/app.rs index 4858d5a1..9d52e3a8 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())); @@ -140,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 { @@ -170,6 +184,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() diff --git a/crates/edgezero-macros/tests/action_state.rs b/crates/edgezero-macros/tests/action_state.rs new file mode 100644 index 00000000..1c999105 --- /dev/null +++ b/crates/edgezero-macros/tests/action_state.rs @@ -0,0 +1,56 @@ +//! Integration coverage: `#[action]` composes the `State` extractor with a +//! request-derived extractor (`Query`) and runs end-to-end through the +//! router. Lives in `edgezero-macros/tests` because the `#[action]` macro +//! emits absolute `::edgezero_core::…` paths that only resolve when +//! `edgezero_core` is an external crate (as it is here, via the dev-dep). + +#[cfg(test)] +mod tests { + use edgezero_core::action; + use edgezero_core::body::Body; + use edgezero_core::error::EdgeError; + use edgezero_core::extractor::{Query, State}; + use edgezero_core::http::{request_builder, Method, StatusCode}; + use edgezero_core::router::RouterService; + use futures::executor::block_on; + use serde::Deserialize; + use std::sync::Arc; + + #[derive(Clone)] + struct AppState { + greeting: String, + } + + #[derive(Deserialize)] + struct Params { + n: u32, + } + + #[action] + async fn handler( + State(state): State>, + Query(params): Query, + ) -> Result { + Ok(format!("{}:{}", state.greeting, params.n)) + } + + #[test] + fn action_composes_state_and_query() { + let service = RouterService::builder() + .with_state(Arc::new(AppState { + greeting: "hi".to_owned(), + })) + .get("/h", handler) + .build(); + + let request = request_builder() + .method(Method::GET) + .uri("/h?n=5") + .body(Body::empty()) + .expect("request"); + + let response = block_on(service.oneshot(request)).expect("response"); + assert_eq!(response.status(), StatusCode::OK); + assert_eq!(response.body().as_bytes().expect("buffered"), b"hi:5"); + } +} diff --git a/docs/guide/handlers.md b/docs/guide/handlers.md index 5f13adc1..90515f93 100644 --- a/docs/guide/handlers.md +++ b/docs/guide/handlers.md @@ -214,6 +214,58 @@ async fn inspect(ctx: RequestContext) -> Result, EdgeError> { | `into_request()` | `Request` - consume context, take request | | `proxy_handle()` | `Option` - adapter proxy hook | +## Sharing app state + +Request-derived extractors (`Json`, `Query`, `Path`, …) cover per-request data. +For app-owned state that outlives a single request — a settings object, a +connection registry, an orchestrator — register it once on the router and read +it back with the `State` extractor. + +Register the value with `RouterBuilder::with_state`. It is cloned into every +request's extensions before dispatch, so `T` must be `Clone + Send + Sync + +'static` — typically an `Arc`, where the clone is a cheap refcount +bump: + +```rust +use std::sync::Arc; +use edgezero_core::extractor::State; +use edgezero_core::router::RouterService; + +#[derive(Clone)] +struct AppState { + greeting: String, +} + +let state = Arc::new(AppState { greeting: "hello".into() }); + +let service = RouterService::builder() + .with_state(Arc::clone(&state)) + .get("/greet", greet) + .build(); +``` + +Read it in any `#[action]` handler by adding a `State` argument — it composes +with the other extractors: + +```rust +use edgezero_core::action; +use edgezero_core::error::EdgeError; +use edgezero_core::extractor::State; +use std::sync::Arc; + +#[action] +async fn greet( + State(state): State>, +) -> Result { + Ok(state.greeting.clone()) +} +``` + +Register different types independently (`with_state(a).with_state(b)`); each is +resolved by its own type. Registering the same `T` twice is last-write-wins. If +a handler asks for a `State` that was never registered, extraction fails with +a `500` — register it before `build()`. + ## Response Types ### Text Responses diff --git a/docs/guide/roadmap.md b/docs/guide/roadmap.md index 6b92ac1f..492d8ba1 100644 --- a/docs/guide/roadmap.md +++ b/docs/guide/roadmap.md @@ -14,7 +14,7 @@ shift as the roadmap evolves. - Adapter behavior matrix: document which adapters buffer bodies, which preserve streaming, and where proxy headers/automatic decompression apply so expectations match runtime behavior. - Example coverage: add focused guides for `axum.toml`, manifest `description` fields, logging - precedence, and route listing + body-mode behavior to reduce ambiguity. + precedence, and introspection routes + body-mode behavior to reduce ambiguity. - Spin support: add first-class Spin adapter support and document how EdgeZero manifests mirror Spin-compatible deployments. - Provider additions: prototype a third adapter (e.g. AWS Lambda@Edge or Vercel Edge Functions) diff --git a/docs/guide/routing.md b/docs/guide/routing.md index 98649eed..ad5dea0b 100644 --- a/docs/guide/routing.md +++ b/docs/guide/routing.md @@ -113,33 +113,50 @@ 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`: +::: 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 [ - { "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: diff --git a/docs/superpowers/plans/2026-07-02-edgezero-nested-secrets.md b/docs/superpowers/plans/2026-07-02-edgezero-nested-secrets.md new file mode 100644 index 00000000..4f9cec7f --- /dev/null +++ b/docs/superpowers/plans/2026-07-02-edgezero-nested-secrets.md @@ -0,0 +1,1871 @@ +# EdgeZero Nested / Array `#[secret]` Support Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Let `#[secret]` fields live below the config root — nested inside sub-structs and inside `Vec<_>` elements — resolved at runtime by a **field path** instead of a single top-level name. + +**Architecture:** Reshape secret metadata from a flat `SecretField { kind, name: &'static str }` into path-qualified, **owned** `SecretField { kind, path: Vec, optional: bool }`, and change `AppConfigMeta` from an associated `const SECRET_FIELDS` to `fn secret_fields() -> Vec` so the derive can recurse across crates (a parent prepends its field/`ArrayEach` segment onto each child's `secret_fields()`). The runtime `secret_walk`, the push-time `validate_excluding_secrets`, and the CLI reflections all become path navigators. A new `#[app_config(nested)]` field opt-in drives recursion, and the existing "no nested AppConfig" CI guard is **inverted** to allow nesting only on opted-in fields. Arrays (`ArrayEach`) are included from day one. + +**Tech Stack:** Rust 1.95, edition 2021. `edgezero-core` (metadata + runtime walk + push validation), `edgezero-macros` (derive recursion + attribute parsing), `edgezero-cli` (path-aware validate/push/diff + inverted CI guard binary), `edgezero-adapter` (owned secret-entry label), `edgezero-adapter-spin` (collision check over paths). `serde_json` / `toml` / `validator` 0.20. + +## Base branch + +- **Implementation branch:** `worktree-state-nested-secrets-spec-review`, with **PR #300 already merged** (merge commit `051a9ad`). PR #300 touches none of this plan's files, so every line number below (verified live on the merged tree) is identical to `origin/main @ 42843b1`. +- Shares its branch with the sibling **`State`** plan (`2026-07-02-edgezero-state-extractor.md`). The only shared file is `crates/edgezero-core/src/extractor.rs`, edited in a disjoint region (that plan appends an extractor; this plan rewrites `secret_walk` at `extractor.rs:827`). Either order is safe. +- **This plan is the source of truth and supersedes the spec's pre-correction shapes.** The spec's §2–§7 still show stale forms that §8 (and its second-pass blockers) overrode: borrowed `&'static` path segments without `optional` (spec §4.2 / line 257), array scope framed as "open/defer" (spec B-1 / line 274), and a `State` crate-root re-export (spec §7 / line 379). Where the spec body and this plan disagree, follow the plan. (The spec body should be reconciled with §8 separately so implementers aren't handed contradictory instructions.) + +## Global Constraints + +- **Rust 1.95.0**, edition 2021, resolver 2. +- **WASM-compat:** no Tokio; `#[async_trait(?Send)]`; async tests use `futures::executor::block_on`. +- **HTTP facade:** never import `http` directly (not relevant to most of this plan, but holds). +- **Colocate tests** in `#[cfg(test)] mod tests`. +- **`validator` is 0.20**: `ValidationErrors::errors()` → `&HashMap, ValidationErrorsKind>`; `errors_mut()` → `&mut` of the same. `ValidationErrorsKind::{Field(Vec), Struct(Box), List(BTreeMap>)}`. Keys are `Cow<'static, str>` and `.as_ref()` gives `&str`. +- **Push/runtime validation split is sacred:** push time uses `validate_excluding_secrets` (secret leaves hold key NAMES, so their per-field validators are skipped); runtime uses `cfg.validate()` after `secret_walk` has resolved values. Nesting must preserve this for nested/array secrets too. +- **`#[secret(store_ref)]` (`StoreRef` kind) leaves are always skipped** by the walk and kept by push validation (their value is a store id, identical at push and runtime). +- **`store_ref` sibling scoping rule:** a `KeyInNamedStore { store_ref_field }` leaf resolves its `store_ref_field` sibling **within the same innermost parent object** as the secret leaf. +- **Array metadata is `[*]`; runtime errors are `[n]`.** `SecretField::dotted_path()` renders `ArrayEach` as `[*]` (static form); `secret_walk` builds `[n]` per element at runtime. Matches the existing `format!("{path}[{idx}]")` convention at `extractor.rs:959`. +- **CI gates (all must pass):** + 1. `cargo fmt --all -- --check` + 2. `cargo clippy --workspace --all-targets --all-features -- -D warnings` + 3. `cargo test --workspace --all-targets` + 4. `cargo check --workspace --all-targets --features "fastly cloudflare spin"` + 5. `cargo check -p edgezero-adapter-spin --target wasm32-wasip2 --features spin` + 6. **Nested AppConfig audit** (`.github/workflows/test.yml:58`): `cargo run -q --bin check_no_nested_app_config --features nested-app-config-check -- examples/app-demo crates/edgezero-cli/src/templates` + +--- + +## File Structure + +| File | Responsibility | Change | +| ---- | -------------- | ------ | +| `crates/edgezero-core/src/app_config.rs` | `SecretPathSegment` enum, reshaped owned `SecretField { kind, path, optional }`, `AppConfigMeta::secret_fields()` fn (was const), `SecretField::dotted_path()`, nested/list-aware `validate_excluding_secrets` | Modify | +| `crates/edgezero-core/src/extractor.rs` | Path-navigating `secret_walk` (Field descent + `ArrayEach` + optional skip + `KeyInNamedStore` sibling-in-parent + dotted runtime error path) | Modify (`secret_walk` region) | +| `crates/edgezero-macros/src/lib.rs` | Register the `app_config` helper attribute | Modify (1 line) | +| `crates/edgezero-macros/src/app_config.rs` | Emit `fn secret_fields()` with owned path segments + `optional`; parse `#[app_config(nested)]`; recurse (object → `Field`, `Vec` → `Field` + `ArrayEach`); relax to accept `Option`; extend `rename_all` guard to nested-only parents; assert nested types derive `AppConfig` | Modify | +| `crates/edgezero-macros/tests/ui/*` + `tests/app_config_derive.rs` | UI + happy-path derive coverage for nesting/arrays/optional | Modify / create | +| `crates/edgezero-cli/src/config.rs` | Path-aware secret reflection in `run_adapter_typed_checks` + `typed_secret_checks` (TOML path navigator); flip all test `impl AppConfigMeta` const → fn | Modify | +| `crates/edgezero-adapter/src/registry.rs` | `TypedSecretEntry.field_name` → owned `String` (dotted label) | Modify | +| `crates/edgezero-adapter-spin/src/cli.rs` | Collision check consumes owned label (logic unchanged — keys on value) | Modify | +| `crates/edgezero-cli/src/bin/check_no_nested_app_config.rs` | Invert: nested `AppConfig` allowed **iff** the field carries `#[app_config(nested)]`; add tests | Modify | +| `docs/guide/configuration.md` | Document nested/array `#[secret]`, the opt-in, sibling scoping, dotted-path errors | Modify | + +**Task ordering rationale:** Task 1 is the atomic metadata reshape — it changes the trait shape and every in-tree consumer in one green step, but only ever produces **length-1** paths, so behavior is byte-identical to today. It also builds the *full* Field+`ArrayEach` navigators up front, so once the macro (Task 4) emits longer paths, nesting "just works." Tasks 2–3 add runtime + push path-awareness (still exercised only by hand-written multi-segment test fixtures). Task 4 makes the derive emit nested/array metadata. Task 5 inverts the CI guard. Task 6 makes the CLI path-aware. Task 7 is the end-to-end proof + docs. + +--- + +## Task 1: Reshape secret metadata to owned, path-qualified fields + +This is the foundation: new types, `const`→`fn` trait, `dotted_path()`, and updates to **every** in-tree consumer so the workspace stays green with identical top-level behavior (all paths length 1). + +**Files:** +- Modify: `crates/edgezero-core/src/app_config.rs` (types, trait, `dotted_path`, and `validate_excluding_secrets` — flat behavior for now; nested pruning lands in Task 3) +- Modify: `crates/edgezero-core/src/extractor.rs` (`secret_walk` signature stays; body reads `C::secret_fields()` — full navigator lands in Task 2; for Task 1 keep top-level behavior but via the new shape) +- Modify: `crates/edgezero-cli/src/config.rs` (test `impl AppConfigMeta` const→fn; consumers read `field.path`/`dotted_path()` at length 1) +- Modify: `crates/edgezero-macros/src/app_config.rs` (emit `fn secret_fields()` with length-1 `Field` paths + `optional: false`) +- Modify: `crates/edgezero-adapter/src/registry.rs` (`field_name: String`) +- Modify: `crates/edgezero-adapter-spin/src/cli.rs` (consume owned label) + +**Interfaces produced (relied on by all later tasks):** +```rust +// crates/edgezero-core/src/app_config.rs +pub enum SecretPathSegment { Field(std::borrow::Cow<'static, str>), ArrayEach } +pub struct SecretField { pub kind: SecretKind, pub path: Vec, pub optional: bool } +impl SecretField { pub fn dotted_path(&self) -> String; } // Field→"a.b", ArrayEach→"[*]" +pub trait AppConfigMeta { fn secret_fields() -> Vec; } // was: const SECRET_FIELDS +// SecretKind is UNCHANGED (still Copy, store_ref_field: &'static str). +``` + +- [ ] **Step 1: Write the failing metadata unit tests** + +Append to `crates/edgezero-core/src/app_config.rs`'s `#[cfg(test)] mod tests` (module starts near `app_config.rs:599`; it already imports `SecretField`, `SecretKind`): + +```rust + #[test] + fn dotted_path_renders_nested_and_array_segments() { + use super::{SecretField, SecretKind, SecretPathSegment::*}; + use std::borrow::Cow; + + let top = SecretField { + kind: SecretKind::KeyInDefault, + path: vec![Field(Cow::Borrowed("api_token"))], + optional: false, + }; + assert_eq!(top.dotted_path(), "api_token"); + + let nested = SecretField { + kind: SecretKind::KeyInDefault, + path: vec![ + Field(Cow::Borrowed("integrations")), + Field(Cow::Borrowed("datadome")), + Field(Cow::Borrowed("server_side_key")), + ], + optional: false, + }; + assert_eq!(nested.dotted_path(), "integrations.datadome.server_side_key"); + + let array = SecretField { + kind: SecretKind::KeyInDefault, + path: vec![ + Field(Cow::Borrowed("partners")), + ArrayEach, + Field(Cow::Borrowed("api_key")), + ], + optional: false, + }; + assert_eq!(array.dotted_path(), "partners[*].api_key"); + } +``` + +- [ ] **Step 2: Run it (fails to compile)** + +Run: `cargo test -p edgezero-core --lib dotted_path_renders 2>&1 | tail -15` +Expected: FAIL — `SecretPathSegment` / `SecretField.path` / `dotted_path` do not exist. + +- [ ] **Step 3: Reshape the metadata types + trait in `app_config.rs`** + +Add `use std::borrow::Cow;` near the top of `crates/edgezero-core/src/app_config.rs` (with the other `use`s). Replace the `AppConfigMeta` trait (`app_config.rs:34-37`), the `SecretField` struct (`app_config.rs:41-48`), and add `SecretPathSegment` + `dotted_path`. `SecretKind` (`app_config.rs:53-69`) is **unchanged**. + +```rust +/// One segment of a [`SecretField`] path. +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum SecretPathSegment { + /// An object key — a Rust field name, verbatim (no `serde(rename)`). + Field(Cow<'static, str>), + /// Every element of an array/`Vec` at this position. + ArrayEach, +} + +/// One field's worth of secret-annotation metadata. +/// +/// The `path` locates the secret leaf from the config root. A top-level +/// scalar has a length-1 path `[Field("api_token")]`. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct SecretField { + /// Which secret-store resolution this field participates in. + pub kind: SecretKind, + /// Path from the config root to the secret leaf. + pub path: Vec, + /// `true` for `#[secret]` on `Option`: an absent leaf is + /// skipped by the runtime walk instead of erroring. + pub optional: bool, +} + +impl SecretField { + /// Human-readable dotted path for error messages and CLI output. + /// `ArrayEach` renders as `[*]` (the static form); the runtime walk + /// renders per-index `[n]` as it descends. + #[must_use] + pub fn dotted_path(&self) -> String { + let mut out = String::new(); + for segment in &self.path { + match segment { + SecretPathSegment::Field(name) => { + if !out.is_empty() { + out.push('.'); + } + out.push_str(name); + } + SecretPathSegment::ArrayEach => out.push_str("[*]"), + } + } + out + } +} + +/// Per-field metadata emitted by `#[derive(AppConfig)]`. `config validate` +/// / `config push` and the runtime secret walk reflect over this to gate +/// secret-aware behaviour. +pub trait AppConfigMeta { + /// Every `#[secret]` / `#[secret(store_ref)]` leaf on the struct, + /// including those reached through `#[app_config(nested)]` children, + /// each carrying its full path from this struct's root. + fn secret_fields() -> Vec; +} +``` + +Note: `SecretField` and `SecretPathSegment` are **no longer `Copy`** (they own a `Vec`/`Cow`). This is intentional per §8 [B, BLOCKER]. `SecretKind` stays `Copy`. + +- [ ] **Step 4: Run the metadata test (passes)** + +Run: `cargo test -p edgezero-core --lib dotted_path_renders 2>&1 | tail -15` +Expected: PASS. (The crate will not fully build yet — consumers still reference the old shape. Fix them in the next steps.) + +- [ ] **Step 5: Update `validate_excluding_secrets` to the new shape (flat behavior preserved)** + +In `crates/edgezero-core/src/app_config.rs:204-226`, the loop currently does `bag.remove(field.name)`. For Task 1, keep flat removal but source the key from the length-1 path. (Task 3 replaces this with nested/list-aware pruning.) Change the loop body: + +```rust + let bag = errors.errors_mut(); + for field in C::secret_fields() { + if matches!(field.kind, SecretKind::StoreRef) { + continue; // store_id field; validator stays + } + // Task 1: flat removal by the first path segment (length-1 paths only + // exist until the derive emits nesting). Task 3 makes this nested-aware. + if let Some(SecretPathSegment::Field(name)) = field.path.first() { + bag.remove(name.as_ref()); + } + } +``` + +Add `use SecretPathSegment` access (it's in the same module, reference as `SecretPathSegment::Field`). + +- [ ] **Step 6: Update `secret_walk` to the new shape (top-level behavior preserved)** + +In `crates/edgezero-core/src/extractor.rs:827-894`, change the import at `extractor.rs:8` to also bring in the path segment, and change the loop to source the key/field name from the length-1 path. (Task 2 replaces the whole body with a recursive navigator.) Minimal Task-1 change: replace `for field in C::SECRET_FIELDS` with `for field in C::secret_fields()`, and replace each `field.name` use with a locally computed `let leaf = field.dotted_path();` for error hints and `let leaf_key = match field.path.last() { Some(SecretPathSegment::Field(n)) => n.as_ref(), _ => /* length-1 guaranteed in Task 1 */ };` for the `data_obj.get(...)`/`insert(...)` calls. Concretely, at the top of the loop: + +```rust + for field in C::secret_fields() { + // Task 1: top-level only — the leaf is the single Field segment. + let leaf_key = match field.path.last() { + Some(SecretPathSegment::Field(name)) => name.clone().into_owned(), + _ => { + return Err(EdgeError::internal(anyhow::anyhow!( + "secret field `{}` has no field leaf", + field.dotted_path() + ))) + } + }; + let hint = field.dotted_path(); + // ... below, replace `field.name` (get/insert key) with `leaf_key.as_str()` + // and `field.name.to_owned()` (error path arg) with `hint.clone()`. +``` + +Update `crates/edgezero-core/src/extractor.rs:8` from `use crate::app_config::{AppConfigMeta, SecretKind};` to `use crate::app_config::{AppConfigMeta, SecretKind, SecretPathSegment};`. Apply the `leaf_key`/`hint` substitution throughout the existing loop body (the `data_obj.get(field.name)`, the `data_obj.insert(field.name.to_owned(), ...)`, and every `field.name.to_owned()` error arg become `leaf_key.as_str()` / `hint.clone()` respectively; `store_ref_field` handling is unchanged — it's still a top-level sibling in Task 1). + +- [ ] **Step 7: Flip the emitter in `edgezero-macros/src/app_config.rs` to `fn` + length-1 paths** + +In `crates/edgezero-macros/src/app_config.rs`, change the per-entry emission (`app_config.rs:128-150`) and the impl block (`app_config.rs:152-166`). The entries currently emit `SecretField { name: #name_lit, kind: #kind_tokens }`; change to owned length-1 paths: + +```rust + let entries = annotations.iter().map(|annotation| { + let name_lit = annotation.name.to_string(); + let kind_tokens = match &annotation.kind { + SecretAnnotation::KeyInDefault => { + quote!(::edgezero_core::app_config::SecretKind::KeyInDefault) + } + SecretAnnotation::StoreRef => { + quote!(::edgezero_core::app_config::SecretKind::StoreRef) + } + SecretAnnotation::KeyInNamedStore { store_ref_field } => { + let lit = syn::LitStr::new(store_ref_field, Span::call_site()); + quote!(::edgezero_core::app_config::SecretKind::KeyInNamedStore { + store_ref_field: #lit + }) + } + }; + // Task 1: length-1 Field path, non-optional. Task 4 sets `optional` + // from Option and prepends nested/array segments. + quote! { + ::edgezero_core::app_config::SecretField { + kind: #kind_tokens, + path: ::std::vec![::edgezero_core::app_config::SecretPathSegment::Field( + ::std::borrow::Cow::Borrowed(#name_lit) + )], + optional: false, + } + } + }); +``` + +And the impl block (`app_config.rs:152-166`) — change the `const` to a `fn`: + +```rust + Ok(quote! { + #[automatically_derived] + impl #impl_generics ::edgezero_core::app_config::AppConfigMeta + for #struct_ident #type_generics #where_clause + { + fn secret_fields() -> ::std::vec::Vec<::edgezero_core::app_config::SecretField> { + ::std::vec![#(#entries),*] + } + } + + #[automatically_derived] + impl #impl_generics ::edgezero_core::app_config::AppConfigRoot + for #struct_ident #type_generics #where_clause + {} + }) +} +``` + +- [ ] **Step 8: Make `TypedSecretEntry.field_name` owned** + +In `crates/edgezero-adapter/src/registry.rs:174-198`, change `field_name: &'entry str` to owned `String`. **Keep `new`'s param generic as `impl Into`** so the 7 existing `&str`-literal call sites in the Spin tests (`adapter-spin/src/cli.rs:1292/1307/1327/1328/1344/1345/1357/1389/1390`) keep compiling unchanged, while the CLI callers pass owned dotted labels: + +```rust +#[non_exhaustive] +pub struct TypedSecretEntry<'entry> { + /// Dotted secret-field path label (e.g. `"partners[3].api_key"`). + pub field_name: String, + /// Blob value — i.e. the secret-store KEY NAME. + pub key_value: &'entry str, + /// Logical secret-store id this key targets. + pub store_id: &'entry str, +} + +impl<'entry> TypedSecretEntry<'entry> { + #[must_use] + #[inline] + pub fn new( + store_id: &'entry str, + field_name: impl Into, + key_value: &'entry str, + ) -> Self { + Self { + field_name: field_name.into(), + key_value, + store_id, + } + } +} +``` + +Because `&str: Into` and `String: Into`, no `TypedSecretEntry::new` call site needs editing for the signature change (only the CLI callers change *what* they pass — a dotted label — in Task 6). + +- [ ] **Step 9: Update the Spin collision check to the owned label** + +In `crates/edgezero-adapter-spin/src/cli.rs:514-552`, the logic keys on `entry.key_value` (the secret value) — unchanged. Only the `seen` map value type shifts from `&str` (borrowing a `&'static` name) to a borrow of the owned `String`. Change the map value binding so it borrows `entry.field_name`: + +```rust + let mut seen: HashMap = HashMap::with_capacity(entries.len()); + for entry in entries { + let spin_var = entry.key_value.to_ascii_lowercase(); + if !is_valid_spin_key(&spin_var) { + let reason = spin_key_rule_violation(&spin_var); + return Err(format!( + "`#[secret]` field `{field}` value `{value}` translates to Spin variable `{spin_var}`, which is not a valid Spin variable name. {reason}. Pick a `#[secret]` value that conforms.", + field = entry.field_name, + value = entry.key_value, + )); + } + if let Some(prev_field) = seen.insert(spin_var.clone(), entry.field_name.as_str()) { + return Err(format!( + "Spin variable `{spin_var}` would receive values from BOTH `#[secret]` field `{prev_field}` AND `#[secret]` field `{this_field}`; Spin's flat variable namespace cannot disambiguate them. Pick distinct `#[secret]` values whose lowercased forms differ.", + this_field = entry.field_name, + )); + } + } + Ok(()) +``` + +(Only two edits vs. today: `entry.field_name` is now a `String` so it interpolates the same in `format!`, and the `seen.insert(..., entry.field_name)` becomes `entry.field_name.as_str()`.) + +- [ ] **Step 10: Update the CLI consumers + all test `impl AppConfigMeta` sites** + +In `crates/edgezero-cli/src/config.rs`, update the two runtime consumers to the new shape (still flat/length-1 in Task 1 — full path navigation lands in Task 6): + +- `run_adapter_typed_checks` (`config.rs:1295-1333`): change `for field in C::SECRET_FIELDS` → `for field in C::secret_fields()`; compute `let leaf = field.dotted_path();` and a flat lookup key `let key = match field.path.last() { Some(SecretPathSegment::Field(n)) => n.as_ref(), _ => continue };`; replace `raw_table.get(field.name)` with `raw_table.get(key)`; replace `TypedSecretEntry::new(store_id, field.name, key_value)` with `TypedSecretEntry::new(store_id, leaf.clone(), key_value)`. `store_ref_field` lookups are unchanged (still `raw_table.get(store_ref_field)`, top-level in Task 1). +- `typed_secret_checks` (`config.rs:1339-1412`): same `for field in C::secret_fields()`; compute `let leaf = field.dotted_path();` and `let key = /* leaf field name as above */;`; replace `raw_table.get(field.name)` with `raw_table.get(key)`; replace every `field.name` in error messages with `leaf`. + +Add `SecretPathSegment` to the import at `config.rs:28` (`use edgezero_core::app_config::{AppConfigMeta, SecretKind, SecretPathSegment};`). + +Then flip **every hand-written `impl AppConfigMeta`** from `const SECRET_FIELDS: &'static [SecretField] = &[ ... ];` to `fn secret_fields() -> Vec { vec![ ... ] }`, converting each `SecretField { name: "x", kind: K }` literal to `SecretField { kind: K, path: vec![SecretPathSegment::Field(Cow::Borrowed("x"))], optional: false }`. Sites (all in `#[cfg(test)]`), verified line numbers: + + - `crates/edgezero-core/src/app_config.rs`: `:620`, `:1106`, `:1138`, `:1156`, `:1181`, `:1201` + - `crates/edgezero-core/src/extractor.rs`: `:1049`, `:1062`, `:2329`, `:2370` + - `crates/edgezero-cli/src/config.rs`: `:1649`, `:1866`, `:2204`, `:2746`, `:3315` + +Worked example — `app_config.rs:1156-1161` (empty→one-field) currently: + +```rust + impl AppConfigMeta for Fixture { + const SECRET_FIELDS: &'static [SecretField] = + &[SecretField { name: "api_token", kind: SecretKind::KeyInDefault }]; + } +``` + +becomes: + +```rust + impl AppConfigMeta for Fixture { + fn secret_fields() -> Vec { + vec![SecretField { + kind: SecretKind::KeyInDefault, + path: vec![SecretPathSegment::Field(std::borrow::Cow::Borrowed("api_token"))], + optional: false, + }] + } + } +``` + +For each such test module, add `use edgezero_core::app_config::SecretPathSegment;` (or `use super::SecretPathSegment;` inside core) and `use std::borrow::Cow;` if not already present. Empty-array impls become `fn secret_fields() -> Vec { vec![] }`. Assertions that read `Type::SECRET_FIELDS` (e.g. the derive test in `crates/edgezero-macros/tests/app_config_derive.rs:71-126` and app-demo `config.rs:126`) change to `Type::secret_fields()` and compare against the new shape — update those in this step too (app-demo assertion detail below). + +App-demo assertion at `examples/app-demo/crates/app-demo-core/src/config.rs:124-138` currently maps `AppDemoConfig::SECRET_FIELDS` `.map(|f| (f.name, f.kind))`. Change to: + +```rust + let by_path: Vec<(String, SecretKind)> = AppDemoConfig::secret_fields() + .into_iter() + .map(|f| (f.dotted_path(), f.kind)) + .collect(); + assert_eq!( + by_path, + vec![ + ("api_token".to_owned(), SecretKind::KeyInDefault), + ("vault".to_owned(), SecretKind::StoreRef), + ], + ); +``` + +The derive assertions in `app_config_derive.rs:71-126` change from comparing `SECRET_FIELDS` slices to comparing `secret_fields()` `Vec`s against `SecretField { kind, path: vec![SecretPathSegment::Field(Cow::Borrowed("..."))], optional: false }` literals (or, more simply, assert `dotted_path()` + `kind` + `optional` per entry). + +- [ ] **Step 11: Build + test the whole workspace (green, behavior identical)** + +Run: `cargo build --workspace --all-targets 2>&1 | tail -20` +Expected: compiles. + +Run: `cargo test --workspace --all-targets 2>&1 | tail -25` +Expected: PASS — all existing secret tests still green (top-level behavior unchanged). Also run app-demo: + +Run: `(cd examples/app-demo && cargo test 2>&1 | tail -15)` +Expected: PASS (`secret_fields_metadata_matches_declarations`, round-trip, config-flow). + +- [ ] **Step 12: Lint + commit** + +Run: `cargo fmt --all && cargo clippy --workspace --all-targets --all-features -- -D warnings 2>&1 | tail -15` +Expected: clean. + +```bash +git add crates/edgezero-core/src/app_config.rs crates/edgezero-core/src/extractor.rs \ + crates/edgezero-macros/src/app_config.rs crates/edgezero-cli/src/config.rs \ + crates/edgezero-adapter/src/registry.rs crates/edgezero-adapter-spin/src/cli.rs \ + examples/app-demo/crates/app-demo-core/src/config.rs \ + crates/edgezero-macros/tests/app_config_derive.rs +git commit -m "refactor(secrets): owned path-qualified SecretField + AppConfigMeta::secret_fields()" +``` + +--- + +## Task 2: Path-navigating runtime `secret_walk` (nesting + arrays + optional) + +Replace `secret_walk`'s top-level loop with a recursive navigator that descends `Field`/`ArrayEach` segments, resolves the leaf, skips absent optionals, and reports the dotted runtime path (`[n]` per index) on error. Exercised via hand-written multi-segment `impl AppConfigMeta` fixtures. + +**Files:** +- Modify: `crates/edgezero-core/src/extractor.rs` (`secret_walk` at `:827`; add tests to the `#[cfg(test)] mod tests`) + +**Interfaces:** +- Consumes: `SecretField { kind, path, optional }`, `SecretPathSegment` (Task 1); `SecretKind` (unchanged); `ctx.secret_store_default()` / `ctx.secret_store(id)` / `bound.require_str(key)` / `map_secret_error` (existing, `extractor.rs:896`); `first_violating_field`'s `[{idx}]` convention. +- Produces: a `secret_walk::` that resolves nested/array leaves. Consumed by Task 7 (E2E). + +- [ ] **Step 1: Write failing nested/array `secret_walk` tests** + +Append to `crates/edgezero-core/src/extractor.rs` `#[cfg(test)] mod tests`. Mirror the existing `app_config_secret_walk_resolves_key_in_default_store` test (`extractor.rs:2170`) for store setup (`InMemorySecretStore`, `StoreRegistry`, inserting the secret registry into request extensions, building `ctx`). Add three fixtures + tests: + +```rust + // Nested object leaf: integrations.datadome.server_side_key + struct NestedCfg; + impl AppConfigMeta for NestedCfg { + fn secret_fields() -> Vec { + vec![SecretField { + kind: SecretKind::KeyInDefault, + path: vec![ + SecretPathSegment::Field(std::borrow::Cow::Borrowed("integrations")), + SecretPathSegment::Field(std::borrow::Cow::Borrowed("datadome")), + SecretPathSegment::Field(std::borrow::Cow::Borrowed("server_side_key")), + ], + optional: false, + }] + } + } + + #[test] + fn secret_walk_resolves_nested_object_leaf() { + let ctx = ctx_with_default_secret_store("dd_key", "resolved-dd"); // helper: see below + let mut data = serde_json::json!({ + "integrations": { "datadome": { "server_side_key": "dd_key" } } + }); + block_on(secret_walk::(&ctx, &mut data)).expect("walk"); + assert_eq!( + data["integrations"]["datadome"]["server_side_key"], + serde_json::json!("resolved-dd") + ); + } + + // Array leaf: partners[*].api_key + struct ArrayCfg; + impl AppConfigMeta for ArrayCfg { + fn secret_fields() -> Vec { + vec![SecretField { + kind: SecretKind::KeyInDefault, + path: vec![ + SecretPathSegment::Field(std::borrow::Cow::Borrowed("partners")), + SecretPathSegment::ArrayEach, + SecretPathSegment::Field(std::borrow::Cow::Borrowed("api_key")), + ], + optional: false, + }] + } + } + + #[test] + fn secret_walk_resolves_each_array_element() { + let ctx = ctx_with_default_secret_store_map(&[("k0", "v0"), ("k1", "v1")]); + let mut data = serde_json::json!({ + "partners": [ { "api_key": "k0" }, { "api_key": "k1" } ] + }); + block_on(secret_walk::(&ctx, &mut data)).expect("walk"); + assert_eq!(data["partners"][0]["api_key"], serde_json::json!("v0")); + assert_eq!(data["partners"][1]["api_key"], serde_json::json!("v1")); + } + + // Nested KeyInNamedStore: vaulted.token resolves against the store named by + // its SIBLING `vaulted.vault` (the sibling-in-innermost-parent scoping rule). + struct NamedStoreCfg; + impl AppConfigMeta for NamedStoreCfg { + fn secret_fields() -> Vec { + vec![SecretField { + kind: SecretKind::KeyInNamedStore { store_ref_field: "vault" }, + path: vec![ + SecretPathSegment::Field(std::borrow::Cow::Borrowed("vaulted")), + SecretPathSegment::Field(std::borrow::Cow::Borrowed("token")), + ], + optional: false, + }] + } + } + + #[test] + fn secret_walk_resolves_nested_named_store_via_sibling_in_parent() { + // A registry whose store id "named" maps key "tok_key" -> "TOK". + let ctx = ctx_with_named_secret_store("named", "tok_key", "TOK"); + let mut data = serde_json::json!({ + "vaulted": { "token": "tok_key", "vault": "named" } + }); + block_on(secret_walk::(&ctx, &mut data)).expect("walk"); + assert_eq!(data["vaulted"]["token"], serde_json::json!("TOK")); + // The store_ref sibling is left intact (it names a store, not a secret). + assert_eq!(data["vaulted"]["vault"], serde_json::json!("named")); + } + + #[test] + fn secret_walk_nested_named_store_missing_sibling_errors_with_dotted_path() { + let ctx = ctx_with_named_secret_store("named", "tok_key", "TOK"); + let mut data = serde_json::json!({ "vaulted": { "token": "tok_key" } }); // no `vault` + let err = block_on(secret_walk::(&ctx, &mut data)) + .expect_err("missing store_ref sibling"); + assert_eq!(err.status(), StatusCode::SERVICE_UNAVAILABLE); + assert!(err.to_string().contains("vaulted.token")); + } + + // Optional secret absent -> skipped (no error) + struct OptionalCfg; + impl AppConfigMeta for OptionalCfg { + fn secret_fields() -> Vec { + vec![SecretField { + kind: SecretKind::KeyInDefault, + path: vec![SecretPathSegment::Field(std::borrow::Cow::Borrowed("maybe_key"))], + optional: true, + }] + } + } + + #[test] + fn secret_walk_skips_absent_optional_leaf() { + let ctx = ctx_with_default_secret_store("unused", "unused"); + let mut data = serde_json::json!({ "greeting": "hi" }); // no maybe_key + block_on(secret_walk::(&ctx, &mut data)).expect("absent optional is fine"); + assert!(data.get("maybe_key").is_none()); + } + + #[test] + fn secret_walk_skips_null_optional_leaf() { + // serde serializes `Option::None` as JSON `null` (the key is present, + // not omitted). The walk must skip a null optional leaf, not error it. + let ctx = ctx_with_default_secret_store("unused", "unused"); + let mut data = serde_json::json!({ "maybe_key": null }); + block_on(secret_walk::(&ctx, &mut data)) + .expect("null optional is skipped, not treated as non-string"); + assert_eq!(data["maybe_key"], serde_json::json!(null)); // left untouched + } + + #[test] + fn secret_walk_missing_required_nested_leaf_errors_with_dotted_path() { + let ctx = ctx_with_default_secret_store("dd_key", "resolved-dd"); + let mut data = serde_json::json!({ "integrations": { "datadome": {} } }); + let err = block_on(secret_walk::(&ctx, &mut data)) + .expect_err("missing required nested leaf"); + assert_eq!(err.status(), StatusCode::SERVICE_UNAVAILABLE); // config_out_of_date -> 503 (error.rs:183) + assert!(err.to_string().contains("integrations.datadome.server_side_key")); + } +``` + +Add the small test helpers near the existing secret-walk test scaffolding (mirror `extractor.rs:2170`'s store construction). `ctx_with_default_secret_store(key, value)` builds an `InMemorySecretStore` mapping `default/{key}` → `value`, wraps it in a `StoreRegistry` with default id `"default"`, inserts the registry into a request's extensions, and returns the `RequestContext`. `ctx_with_default_secret_store_map(&[(k, v), ...])` is the multi-entry variant. `ctx_with_named_secret_store(store_id, key, value)` registers an `InMemorySecretStore` under `store_id` (mapping `{store_id}/{key}` → `value`) in the registry so `ctx.secret_store(store_id)` resolves — used by the `KeyInNamedStore` tests. (`EdgeError::config_out_of_date` → `StatusCode::SERVICE_UNAVAILABLE` per `error.rs:183`, confirmed.) + +- [ ] **Step 2: Run (fails)** + +Run: `cargo test -p edgezero-core --lib secret_walk_ 2>&1 | tail -25` +Expected: FAIL — nested/array data is not navigated (current walk only reads/writes top-level keys); missing-leaf message lacks the dotted path. + +- [ ] **Step 3: Rewrite `secret_walk` as a recursive navigator** + +Replace the body of `secret_walk` (`crates/edgezero-core/src/extractor.rs:827-894`) with a path navigator. Keep the signature (`async fn secret_walk(ctx: &RequestContext, data: &mut serde_json::Value) -> Result<(), EdgeError> where C: AppConfigMeta`). New body: + +```rust + for field in C::secret_fields() { + resolve_secret_field(ctx, data, &field, &field.path, String::new()).await?; + } + Ok(()) +} + +/// Recursively descend `remaining` path segments from `node`, resolving the +/// secret leaf(s). `rendered` is the dotted path so far (with concrete `[n]` +/// indices) for error hints. +fn resolve_secret_field<'a>( + ctx: &'a RequestContext, + node: &'a mut serde_json::Value, + field: &'a SecretField, + remaining: &'a [SecretPathSegment], + rendered: String, +) -> std::pin::Pin> + 'a>> { + Box::pin(async move { + match remaining.split_first() { + // Leaf reached: `node` is the PARENT object; the last Field is the key. + Some((SecretPathSegment::Field(name), rest)) if rest.is_empty() => { + resolve_leaf(ctx, node, field, name.as_ref(), &rendered).await + } + // Descend into an object key. + Some((SecretPathSegment::Field(name), rest)) => { + let next_rendered = join_field(&rendered, name.as_ref()); + match node.get_mut(name.as_ref()) { + // Absent optional subtree: key missing OR serialized as null. + None | Some(serde_json::Value::Null) if field.optional => Ok(()), + Some(child) => { + resolve_secret_field(ctx, child, field, rest, next_rendered).await + } + None => Err(EdgeError::config_out_of_date( + format!("missing or non-object value at `{next_rendered}`"), + next_rendered, + )), + } + } + // Iterate every array element. + Some((SecretPathSegment::ArrayEach, rest)) => { + let Some(items) = node.as_array_mut() else { + if field.optional { + return Ok(()); + } + return Err(EdgeError::config_out_of_date( + format!("expected an array at `{rendered}`"), + rendered, + )); + }; + for (idx, item) in items.iter_mut().enumerate() { + let indexed = format!("{rendered}[{idx}]"); + resolve_secret_field(ctx, item, field, rest, indexed).await?; + } + Ok(()) + } + None => Ok(()), + } + }) +} + +fn join_field(prefix: &str, name: &str) -> String { + if prefix.is_empty() { + name.to_owned() + } else { + format!("{prefix}.{name}") + } +} + +/// Resolve one leaf: `parent` is the innermost containing object; `key` is the +/// secret field name; `store_ref_field` (for `KeyInNamedStore`) is a sibling +/// within `parent`. +async fn resolve_leaf( + ctx: &RequestContext, + parent: &mut serde_json::Value, + field: &SecretField, + key: &str, + rendered_parent: &str, +) -> Result<(), EdgeError> { + if matches!(field.kind, SecretKind::StoreRef) { + return Ok(()); // store id, not a secret key + } + let leaf_path = join_field(rendered_parent, key); + + let Some(parent_obj) = parent.as_object_mut() else { + if field.optional { + return Ok(()); + } + return Err(EdgeError::config_out_of_date( + format!("expected an object containing `{key}` at `{rendered_parent}`"), + leaf_path, + )); + }; + + let key_name = match parent_obj.get(key) { + Some(serde_json::Value::String(k)) => k.clone(), + // An optional secret is absent when the key is MISSING *or* serialized + // as JSON `null`. serde emits `Option::None` as `null` (and `#[secret]` + // bans `skip_serializing_if`, so the key is never omitted), so both + // cases must skip — not just the missing-key case. + None | Some(serde_json::Value::Null) if field.optional => return Ok(()), + _ => { + return Err(EdgeError::config_out_of_date( + format!("missing or non-string value at `{leaf_path}`"), + leaf_path, + )) + } + }; + + let (bound, resolved_store_id) = match field.kind { + SecretKind::KeyInDefault => { + let bound = ctx.secret_store_default().ok_or_else(|| { + EdgeError::config_out_of_date( + format!("secret field `{leaf_path}` has kind KeyInDefault but no default secret store is registered"), + leaf_path.clone(), + ) + })?; + let id = bound.store_name().to_owned(); + (bound, id) + } + SecretKind::StoreRef => return Ok(()), + SecretKind::KeyInNamedStore { store_ref_field } => { + let store_id_str = parent_obj + .get(store_ref_field) + .and_then(|v| v.as_str()) + .ok_or_else(|| { + EdgeError::config_out_of_date( + format!("missing store_ref `{store_ref_field}` for secret field `{leaf_path}`"), + leaf_path.clone(), + ) + })? + .to_owned(); + let bound = ctx.secret_store(&store_id_str).ok_or_else(|| { + EdgeError::config_out_of_date( + format!("blob declared store_ref `{store_id_str}` but [stores.secrets] has no such id"), + leaf_path.clone(), + ) + })?; + (bound, store_id_str) + } + }; + + let secret = bound + .require_str(&key_name) + .await + .map_err(|err| map_secret_error(err, &leaf_path, &resolved_store_id, &key_name))?; + parent_obj.insert(key.to_owned(), serde_json::Value::String(secret)); + Ok(()) +} +``` + +Notes: +- `map_secret_error` (`extractor.rs:896`) takes `field_name: &str` — pass `&leaf_path`; no signature change needed. +- The recursion uses a boxed future (WASM-safe; matches the crate's `?Send` async style) because async fns can't recurse directly. +- `KeyInNamedStore` resolves `store_ref_field` in `parent_obj` — the **innermost** parent, satisfying the sibling scoping rule for nested secrets. + +- [ ] **Step 4: Run (passes)** + +Run: `cargo test -p edgezero-core --lib secret_walk_ 2>&1 | tail -25` +Expected: PASS (nested object, array-each, absent-optional-skip, missing-nested-dotted-error). Also confirm the pre-existing top-level walk tests (`extractor.rs:2170`, `:2198`) still pass: + +Run: `cargo test -p edgezero-core --lib app_config_secret_walk 2>&1 | tail -15` +Expected: PASS. + +- [ ] **Step 5: Lint + commit** + +Run: `cargo clippy -p edgezero-core --all-targets --all-features -- -D warnings 2>&1 | tail -15` + +```bash +git add crates/edgezero-core/src/extractor.rs +git commit -m "feat(secrets): path-navigating secret_walk (nested objects, arrays, optionals)" +``` + +--- + +## Task 3: Nested/list-aware `validate_excluding_secrets` + +Push time must skip the per-field validator of a nested/array secret leaf, whose failure lives under the parent inside `ValidationErrorsKind::Struct`/`List` — a flat `bag.remove(name)` cannot reach it (§8 [B, IMPORTANT]). Reuse the `first_violating_field` walk pattern (`extractor.rs:926`). + +**Files:** +- Modify: `crates/edgezero-core/src/app_config.rs` (`validate_excluding_secrets` at `:204`; add tests) + +**Interfaces:** +- Consumes: `C::secret_fields()`, `SecretField.path`, `SecretPathSegment`, validator 0.20 `ValidationErrors`/`ValidationErrorsKind`. +- Produces: nested-aware pruning. Consumed by Task 6 (CLI push over nested config) + Task 7. + +- [ ] **Step 1: Write failing nested-pruning tests** + +Append to `app_config.rs` `#[cfg(test)] mod tests`. Model on `validate_excluding_secrets_skips_secret_field_rules` (`app_config.rs:1148`) but with a nested struct fixture whose nested secret leaf has a failing validator (e.g. `#[validate(length(min = 100))]` on the key-name string, which is short at push time). Assert `validate_excluding_secrets` returns `Ok(())` (the nested secret's validator was pruned) while a **non-secret** nested failure still surfaces `Err`. + +```rust + #[test] + fn validate_excluding_secrets_prunes_nested_secret_leaf_validator() { + use validator::Validate; + + #[derive(Validate)] + struct Inner { + #[validate(length(min = 100))] + server_side_key: String, // holds a short KEY NAME at push time + } + #[derive(Validate)] + struct Outer { + #[validate(nested)] + integrations: Inner, + } + impl AppConfigMeta for Outer { + fn secret_fields() -> Vec { + vec![SecretField { + kind: SecretKind::KeyInDefault, + path: vec![ + SecretPathSegment::Field(std::borrow::Cow::Borrowed("integrations")), + SecretPathSegment::Field(std::borrow::Cow::Borrowed("server_side_key")), + ], + optional: false, + }] + } + } + + let cfg = Outer { + integrations: Inner { + server_side_key: "dd_key".to_owned(), // 6 chars < 100 + }, + }; + // The only failure is the nested secret leaf's validator -> pruned -> Ok. + assert!(validate_excluding_secrets(&cfg).is_ok()); + } + + #[test] + fn validate_excluding_secrets_keeps_nested_non_secret_failures() { + use validator::Validate; + + #[derive(Validate)] + struct Inner { + #[validate(length(min = 100))] + server_side_key: String, + #[validate(length(min = 100))] + note: String, // NON-secret, must still fail + } + #[derive(Validate)] + struct Outer { + #[validate(nested)] + integrations: Inner, + } + impl AppConfigMeta for Outer { + fn secret_fields() -> Vec { + vec![SecretField { + kind: SecretKind::KeyInDefault, + path: vec![ + SecretPathSegment::Field(std::borrow::Cow::Borrowed("integrations")), + SecretPathSegment::Field(std::borrow::Cow::Borrowed("server_side_key")), + ], + optional: false, + }] + } + } + + let cfg = Outer { + integrations: Inner { + server_side_key: "dd_key".to_owned(), + note: "short".to_owned(), + }, + }; + assert!(validate_excluding_secrets(&cfg).is_err()); // `note` still fails + } + + #[test] + fn validate_excluding_secrets_prunes_array_secret_leaf_keeps_siblings() { + use validator::Validate; + + #[derive(Validate)] + struct Partner { + #[validate(length(min = 100))] + api_key: String, // secret leaf (a key NAME at push time) + #[validate(length(min = 100))] + label: String, // NON-secret sibling + } + #[derive(Validate)] + struct Outer { + #[validate(nested)] + partners: Vec, + } + impl AppConfigMeta for Outer { + fn secret_fields() -> Vec { + vec![SecretField { + kind: SecretKind::KeyInDefault, + path: vec![ + SecretPathSegment::Field(std::borrow::Cow::Borrowed("partners")), + SecretPathSegment::ArrayEach, + SecretPathSegment::Field(std::borrow::Cow::Borrowed("api_key")), + ], + optional: false, + }] + } + } + + // Every element fails BOTH validators at push time. + let cfg = Outer { + partners: vec![ + Partner { api_key: "k0".to_owned(), label: "s".to_owned() }, + Partner { api_key: "k1".to_owned(), label: "s".to_owned() }, + ], + }; + // `api_key` (secret) pruned from every List element; `label` + // (non-secret) survives in every element -> overall Err. + let err = validate_excluding_secrets(&cfg).expect_err("non-secret siblings still fail"); + let rendered = format!("{err:?}"); + assert!(rendered.contains("label"), "non-secret sibling must survive"); + assert!( + !rendered.contains("api_key"), + "secret leaf must be pruned from every array element" + ); + } + + #[test] + fn validate_excluding_secrets_prunes_array_all_secret_failures_to_ok() { + use validator::Validate; + + #[derive(Validate)] + struct Partner { + #[validate(length(min = 100))] + api_key: String, // the ONLY validated field, and it's the secret leaf + } + #[derive(Validate)] + struct Outer { + #[validate(nested)] + partners: Vec, + } + impl AppConfigMeta for Outer { + fn secret_fields() -> Vec { + vec![SecretField { + kind: SecretKind::KeyInDefault, + path: vec![ + SecretPathSegment::Field(std::borrow::Cow::Borrowed("partners")), + SecretPathSegment::ArrayEach, + SecretPathSegment::Field(std::borrow::Cow::Borrowed("api_key")), + ], + optional: false, + }] + } + } + + // Every element's only failure is the secret leaf -> each List element + // clears -> the empty List is retained-out -> `partners` removed -> Ok. + let cfg = Outer { + partners: vec![ + Partner { api_key: "k0".to_owned() }, + Partner { api_key: "k1".to_owned() }, + ], + }; + assert!( + validate_excluding_secrets(&cfg).is_ok(), + "an array branch whose only failures are secret leaves must fully prune to Ok(())" + ); + } +``` + +Note on the array tests: together they prove the `ValidationErrorsKind::List` branch of `prune_secret_leaf` (Step 3) both (a) removes the secret leaf from **each** indexed element while leaving non-secret siblings, and (b) fully collapses to `Ok(())` when every element's only failure is the secret leaf (the `items.retain(..)` + `clear = items.is_empty()` path) — the `#[secret]`-in-`Vec` case the plan commits to from day one. + +- [ ] **Step 2: Run (fails)** + +Run: `cargo test -p edgezero-core --lib validate_excluding_secrets_prunes_nested 2>&1 | tail -20` +Expected: FAIL — the flat `bag.remove(first_segment)` removes the top-level `integrations` entry entirely (over-pruning) or fails to prune the nested leaf, so the assertion is wrong. (Either way the Task-1 flat impl is incorrect for nesting.) + +- [ ] **Step 3: Implement nested-aware pruning** + +Replace `validate_excluding_secrets`'s loop (`app_config.rs:204-226`) with a path-aware pruner that navigates `ValidationErrorsKind::Struct`/`List` down each secret field's path, removes the leaf validator, and prunes now-empty containers so a fully-cleared branch disappears (rather than leaving an empty `Struct`/`List` marker that would keep `errors` non-empty). The loop: + +```rust + let Err(mut errors) = result else { + return Ok(()); + }; + for field in C::secret_fields() { + if matches!(field.kind, SecretKind::StoreRef) { + continue; // store_id field; validator stays + } + prune_secret_leaf(&mut errors, &field.path); + } + if errors.errors().is_empty() { + return Ok(()); + } + Err(errors) +} +``` + +The pruner peeks the segment after each `Field` so a `Field` immediately followed by `ArrayEach` is handled as one step — validator nests a `List` under the array field's key, not as a bare top-level kind. (Mirrors the navigation in `first_violating_field` at `extractor.rs:926`.) Add `use validator::ValidationErrorsKind;` locally. + +```rust +fn prune_secret_leaf(errors: &mut validator::ValidationErrors, path: &[SecretPathSegment]) { + use validator::ValidationErrorsKind; + + let Some((head, rest)) = path.split_first() else { return; }; + let SecretPathSegment::Field(name) = head else { + // ArrayEach only appears immediately after a Field (a root is always a + // struct), so it is consumed by the peek below, never as a head. + return; + }; + + // Leaf. + if rest.is_empty() { + errors.errors_mut().remove(name.as_ref()); + return; + } + + // Does the next segment iterate an array? If so consume it and target a List. + let (kind_is_array, tail) = match rest.split_first() { + Some((SecretPathSegment::ArrayEach, tail)) => (true, tail), + _ => (false, rest), + }; + + let mut clear = false; + match errors.errors_mut().get_mut(name.as_ref()) { + Some(ValidationErrorsKind::Struct(inner)) if !kind_is_array => { + prune_secret_leaf(inner, tail); + clear = inner.errors().is_empty(); + } + Some(ValidationErrorsKind::List(items)) if kind_is_array => { + for inner in items.values_mut() { + prune_secret_leaf(inner, tail); + } + items.retain(|_, inner| !inner.errors().is_empty()); + clear = items.is_empty(); + } + _ => {} + } + if clear { + errors.errors_mut().remove(name.as_ref()); + } +} +``` + +- [ ] **Step 4: Run (passes)** + +Run: `cargo test -p edgezero-core --lib validate_excluding_secrets 2>&1 | tail -20` +Expected: PASS — both new tests plus the four pre-existing `validate_excluding_secrets_*` tests (`app_config.rs:1132/1148/1173/1195`). + +- [ ] **Step 5: Lint + commit** + +Run: `cargo clippy -p edgezero-core --all-targets --all-features -- -D warnings 2>&1 | tail -15` + +```bash +git add crates/edgezero-core/src/app_config.rs +git commit -m "feat(secrets): nested/list-aware validate_excluding_secrets pruning" +``` + +--- + +## Task 4: Derive recursion — `#[app_config(nested)]`, `Option`, path emission + +Make the derive actually emit nested/array/optional metadata: register the `app_config` attribute, parse `#[app_config(nested)]`, recurse into child `secret_fields()` prepending `Field` (object) or `Field` + `ArrayEach` (`Vec`), accept `Option` on `#[secret]` (→ `optional: true`), extend the `rename_all` guard to nested-only parents, and assert nested types derive `AppConfig`. + +**Files:** +- Modify: `crates/edgezero-macros/src/lib.rs` (`:20`) +- Modify: `crates/edgezero-macros/src/app_config.rs` (parsing, recursion, guards, optional) +- Modify/Create: `crates/edgezero-macros/tests/app_config_derive.rs` + `crates/edgezero-macros/tests/ui/*` + +**Interfaces:** +- Consumes: the Task-1 emitter shape (`fn secret_fields()` returning `Vec` with owned paths + `optional`). +- Produces: nested/array/optional metadata for real derived structs. Consumed by Tasks 6 & 7 and app-demo (unchanged top-level app-demo still emits length-1). + +- [ ] **Step 1: Register the `app_config` helper attribute** + +In `crates/edgezero-macros/src/lib.rs:20`, change: + +```rust +#[proc_macro_derive(AppConfig, attributes(secret))] +``` + +to: + +```rust +#[proc_macro_derive(AppConfig, attributes(secret, app_config))] +``` + +- [ ] **Step 2: Write failing derive/UI tests** + +Add happy-path assertions to `crates/edgezero-macros/tests/app_config_derive.rs` (a nested object emits the expected 3-segment path; a `Vec` nested field emits `Field`+`ArrayEach`; `Option` sets `optional: true`). Example: + +```rust + #[derive(serde::Deserialize, validator::Validate, edgezero_core::AppConfig)] + #[serde(deny_unknown_fields)] + struct DataDome { + #[secret] + server_side_key: String, + } + #[derive(serde::Deserialize, validator::Validate, edgezero_core::AppConfig)] + #[serde(deny_unknown_fields)] + struct Integrations { + #[app_config(nested)] + #[validate(nested)] + datadome: DataDome, + } + #[derive(serde::Deserialize, validator::Validate, edgezero_core::AppConfig)] + #[serde(deny_unknown_fields)] + struct Partner { + #[secret] + api_key: String, + #[secret] + maybe: Option, + } + #[derive(serde::Deserialize, validator::Validate, edgezero_core::AppConfig)] + #[serde(deny_unknown_fields)] + struct Settings { + #[app_config(nested)] + #[validate(nested)] + integrations: Integrations, + #[app_config(nested)] + #[validate(nested)] + partners: Vec, + } + + #[test] + fn nested_and_array_paths_are_emitted() { + use edgezero_core::app_config::{AppConfigMeta as _, SecretKind}; + + let mut paths: Vec<(String, SecretKind, bool)> = Settings::secret_fields() + .into_iter() + .map(|f| (f.dotted_path(), f.kind, f.optional)) + .collect(); + paths.sort_by(|a, b| a.0.cmp(&b.0)); + assert_eq!( + paths, + vec![ + ("integrations.datadome.server_side_key".to_owned(), SecretKind::KeyInDefault, false), + ("partners[*].api_key".to_owned(), SecretKind::KeyInDefault, false), + ("partners[*].maybe".to_owned(), SecretKind::KeyInDefault, true), + ], + ); + } +``` + +Add UI compile-fail fixtures under `crates/edgezero-macros/tests/ui/` and register them in `crates/edgezero-macros/tests/app_config_derive.rs`'s `trybuild_compile_fail_fixtures` test (`:144-159`). New fixtures (each with a `.stderr`): + - `app_config_nested_on_non_appconfig.rs` — `#[app_config(nested)]` on a field whose type does not derive `AppConfig` → clear `AppConfigRoot`/`AppConfigMeta` trait-bound error. + - `app_config_unknown_option.rs` — `#[app_config(bogus)]` (or a `nested` typo) errors instead of being silently ignored (proves `nested_optin` returns `Err`). + - `secret_on_option_non_string.rs` — `#[secret]` on `Option` still errors. + - `secret_store_ref_optional.rs` — `#[secret(store_ref)]` on `Option` errors (a store id is structural; optional is disallowed — Step 6). + - `nested_secret_serde_rename.rs` — `#[serde(rename)]` on a `#[secret]` leaf inside a nested struct still errors (guard self-enforced per struct). + - `nested_field_serde_rename.rs` — `#[serde(rename = "...")] #[app_config(nested)] child: Child` errors (the nested *parent* field carries `rename`, which would desync its `Field(field_name)` segment — Step 4 guard). + - `nested_parent_rename_all.rs` — a parent with only `#[app_config(nested)]` children (no direct `#[secret]`) carrying `#[serde(rename_all="kebab-case")]` errors (Step 7 guard). + +Naming caution: the existing harness globs `compile_fail("tests/ui/secret_*.rs")` (`app_config_derive.rs:147`). Do **not** prefix new fixtures with `secret_` unless they are compile-fail; `secret_on_option_non_string.rs` is compile-fail so the glob covers it (don't double-register). The `app_config_*` and `nested_*` names must be listed explicitly. + +Note: `app_config_derive.rs` runs `trybuild` only in that single `#[test]`; also add an `Option` **pass** assertion (that it compiles + sets `optional: true`) inside the happy-path `mod tests` above — not as a UI fixture. + +- [ ] **Step 3: Run (fails)** + +Run: `cargo test -p edgezero-macros --test app_config_derive 2>&1 | tail -30` +Expected: FAIL — `#[app_config(nested)]` is not parsed (unknown attribute or ignored); `Option` rejected by `is_scalar_string_type`; nested paths not emitted. + +- [ ] **Step 4: Parse `#[app_config(nested)]` and classify fields** + +In `crates/edgezero-macros/src/app_config.rs`, add a helper that detects the opt-in and extracts the recursion child type. A field is a **nested recursion** field iff it carries `#[app_config(nested)]`. Determine object-vs-array from the field type: a `Vec`/`[T]` → array (child `T`, emit `Field(field)` + `ArrayEach`); anything else → object (child = the field type, emit `Field(field)`). + +```rust +/// Whether `field` carries `#[app_config(nested)]`. Returns `Err` (not +/// `false`) on a malformed `#[app_config(...)]` such as `#[app_config(bogus)]` +/// or `#[app_config(nseted)]`, so a typo is a hard compile error rather than a +/// silently-ignored non-recursion (which would drop the child's secrets). +fn nested_optin(field: &Field) -> syn::Result { + let mut found = false; + for attr in &field.attrs { + if !attr.path().is_ident("app_config") { + continue; + } + attr.parse_nested_meta(|meta| { + if meta.path.is_ident("nested") { + found = true; + Ok(()) + } else { + Err(meta.error("`#[app_config(...)]` only accepts `nested`")) + } + })?; + } + Ok(found) +} + +/// The child element type to recurse into and whether it is an array element. +/// `Vec` / `[T]` -> (T, true); otherwise (field_ty, false). +fn nested_child_type(ty: &Type) -> (&Type, bool) { + if let Type::Path(tp) = ty { + if let Some(last) = tp.path.segments.last() { + if last.ident == "Vec" { + if let syn::PathArguments::AngleBracketed(ab) = &last.arguments { + if let Some(syn::GenericArgument::Type(inner)) = ab.args.first() { + return (inner, true); + } + } + } + } + } + if let Type::Slice(s) = ty { + return (&s.elem, true); + } + (ty, false) +} +``` + +Modify the main field loop in `expand` (`app_config.rs:62-66`) so that for each field it either (a) records a direct `#[secret]` annotation (as today) or (b) records a nested-recursion descriptor `{ field_name, child_ty, is_array }` when `nested_optin(field)?` returns `true`. Propagate the `syn::Result` with `?` so a malformed `#[app_config(...)]` becomes a compile error. A field may not be both `#[secret]` and `#[app_config(nested)]` (error if so). + +**Guard the nested parent field's serde attrs.** The emitter writes `Field(Cow::Borrowed(field_name))` using the Rust field name verbatim, so a `#[serde(rename = "...")]` (or `flatten`/`skip*`) on the `#[app_config(nested)]` field itself would desync the emitted path segment from the serialized key — the exact hazard the spec forbids "anywhere on a secret path" (spec §4.3 point 3, line 282). The existing `enforce_no_disallowed_serde_attrs(field)?` (`app_config.rs:363`) already bans `rename`/`flatten`/`skip`/`skip_deserializing`/`skip_serializing`/`skip_serializing_if`. Call it on every nested-recursion field (it currently runs only on `#[secret]` fields via `scan_field`). Each struct on the path self-enforces this for its own fields, so `rename` at any level along the path is rejected by whichever struct declares that field. + +- [ ] **Step 5: Emit recursion into `secret_fields()`** + +Extend the emitter (Task 1's `entries`) to also emit, for each nested descriptor, a loop that prepends segments onto the child's `secret_fields()`. Change the `fn secret_fields()` body emission to build a `Vec` imperatively: + +```rust + // direct #[secret] entries (owned length-1 paths, optional from Option) + let direct_entries = /* Task 1 entries, but `optional: #is_option` per field */; + + // nested recursion pushes + let nested_pushes = nested_descriptors.iter().map(|d| { + let field_lit = d.field_name.to_string(); + let child_ty = &d.child_ty; + if d.is_array { + quote! { + for mut __f in <#child_ty as ::edgezero_core::app_config::AppConfigMeta>::secret_fields() { + let mut __p = ::std::vec![ + ::edgezero_core::app_config::SecretPathSegment::Field(::std::borrow::Cow::Borrowed(#field_lit)), + ::edgezero_core::app_config::SecretPathSegment::ArrayEach, + ]; + __p.append(&mut __f.path); + __f.path = __p; + __out.push(__f); + } + } + } else { + quote! { + for mut __f in <#child_ty as ::edgezero_core::app_config::AppConfigMeta>::secret_fields() { + let mut __p = ::std::vec![ + ::edgezero_core::app_config::SecretPathSegment::Field(::std::borrow::Cow::Borrowed(#field_lit)), + ]; + __p.append(&mut __f.path); + __f.path = __p; + __out.push(__f); + } + } + } + }); + + let secret_fields_body = quote! { + let mut __out: ::std::vec::Vec<::edgezero_core::app_config::SecretField> = + ::std::vec![#(#direct_entries),*]; + #(#nested_pushes)* + __out + }; +``` + +And the impl: + +```rust + impl #impl_generics ::edgezero_core::app_config::AppConfigMeta + for #struct_ident #type_generics #where_clause + { + fn secret_fields() -> ::std::vec::Vec<::edgezero_core::app_config::SecretField> { + #secret_fields_body + } + } +``` + +Additionally, emit an explicit **`AppConfigRoot`** assertion per nested child (spec §4.3 / B-2: the sub-struct must derive `AppConfig`, tracked via the `AppConfigRoot` marker — not merely impl `AppConfigMeta`, which a hand-rolled impl could satisfy without going through the derive). Calling `<#child_ty as AppConfigMeta>::secret_fields()` alone would accept a hand-written `AppConfigMeta` impl; the `AppConfigRoot` bound closes that gap and gives a clear error span: + +```rust + const _: () = { + fn __assert_app_config_root() {} + fn __assert_nested_children() { + #( __assert_app_config_root::<#nested_child_tys>(); )* + } + }; +``` + +where `#nested_child_tys` is the list of the recursion child types (the object field type, or the `Vec`/slice element type). A nested field whose type does not derive `AppConfig` fails with "the trait bound `Child: AppConfigRoot` is not satisfied" — the `app_config_nested_on_non_appconfig.rs` UI fixture pins this message. + +- [ ] **Step 6: Relax scalar rule to accept `Option`; set `optional`** + +Change `is_scalar_string_type`/`enforce_scalar_string_type` (`app_config.rs:265-284`) to also accept `Option`, and have `scan_field` (`app_config.rs:195-219`) report whether the secret type was optional so the emitter sets `optional`. Add: + +```rust +/// `Option` -> Some(true); `String` -> Some(false); else None. +fn secret_string_optionality(ty: &Type) -> Option { + if is_scalar_string_type(ty) { + return Some(false); + } + if let Type::Path(tp) = ty { + if let Some(last) = tp.path.segments.last() { + if last.ident == "Option" { + if let syn::PathArguments::AngleBracketed(ab) = &last.arguments { + if let Some(syn::GenericArgument::Type(inner)) = ab.args.first() { + if is_scalar_string_type(inner) { + return Some(true); + } + } + } + } + } + } + None +} +``` + +Replace `enforce_scalar_string_type(field)?;` (`app_config.rs:215`, called from `scan_field` after `parse_secret_kind` yields `kind`) with the optionality computation **plus a `StoreRef`-cannot-be-optional guard**: + +```rust + let optional = secret_string_optionality(&field.ty).ok_or_else(|| { + syn::Error::new_spanned( + &field.ty, + "`#[secret]` may only annotate `String` or `Option`", + ) + })?; + // A `#[secret(store_ref)]` value is a store id — structural, always + // present. `Option` there is undefined (an absent store cannot + // resolve its dependent `KeyInNamedStore` sibling), so reject it. Optional + // is allowed only on the secret-VALUE kinds (KeyInDefault / KeyInNamedStore). + if optional && matches!(kind, SecretAnnotation::StoreRef) { + return Err(syn::Error::new_spanned( + &field.ty, + "`#[secret(store_ref)]` may not be `Option` — a store id is structural and must always be present", + )); + } +``` + +and thread `optional` into `FieldAnnotation` (add a `bool` field), then into the direct-entry emission (`optional: #optional_lit`). Keep rejecting `Vec`, `Cow<'_, str>`, non-string scalars (they yield `None`). Note the runtime walk already early-returns for `StoreRef` regardless of `optional`; this compile-time guard removes the ambiguity at the source so CLI validation and the walk never see an optional store id. + +- [ ] **Step 7: Extend the `rename_all` guard to nested-only parents** + +The guard fires today only when `!annotations.is_empty()` (`app_config.rs:75-77`, direct `#[secret]` fields present). A parent whose secrets are all in `#[app_config(nested)]` children has no direct annotations but its own `rename_all` would still desync the emitted `Field(parent_field)` segment. Change the gate to also fire when any nested descriptor exists: + +```rust + if !annotations.is_empty() || !nested_descriptors.is_empty() { + enforce_no_container_rename_all(&input.attrs)?; + } +``` + +- [ ] **Step 8: Run (passes)** + +Run: `cargo test -p edgezero-macros --test app_config_derive 2>&1 | tail -30` +Expected: PASS — happy-path nested/array/optional emission + all UI compile-fail fixtures match their `.stderr`. Regenerate `.stderr` with `TRYBUILD=overwrite cargo test -p edgezero-macros --test app_config_derive` if messages differ, then inspect the diffs for correctness before committing. + +- [ ] **Step 9: Lint + commit** + +Run: `cargo clippy -p edgezero-macros --all-targets --all-features -- -D warnings 2>&1 | tail -15` + +```bash +git add crates/edgezero-macros/src/lib.rs crates/edgezero-macros/src/app_config.rs \ + crates/edgezero-macros/tests/app_config_derive.rs crates/edgezero-macros/tests/ui/ +git commit -m "feat(macros): #[app_config(nested)] recursion, Option secrets, path emission" +``` + +--- + +## Task 5: Invert the nested-AppConfig CI guard + +The `check_no_nested_app_config` binary currently rejects **any** `AppConfig` struct used inside another (`.github/workflows/test.yml:58` runs it). Invert it: nesting is allowed **iff** the containing field carries `#[app_config(nested)]`. Add the tests it lacks today. + +**Files:** +- Modify: `crates/edgezero-cli/src/bin/check_no_nested_app_config.rs` + +**Interfaces:** +- Consumes: `syn` field attributes (the binary already parses structs with `syn::visit`). +- Produces: a guard that permits opted-in nesting; still fails on un-opted-in nesting. + +- [ ] **Step 1: Write failing guard tests** + +Add a `#[cfg(test)] mod tests` to `crates/edgezero-cli/src/bin/check_no_nested_app_config.rs`. The binary is behind `#![cfg(feature = "nested-app-config-check")]`, so tests run only with that feature. Test the pure helpers by parsing source snippets with `syn::parse_file` and running the collector + visitor: + +```rust +#[cfg(test)] +mod tests { + use super::*; + + fn violations_in(src: &str) -> usize { + let file = syn::parse_file(src).expect("parse"); + let mut collector = AppConfigStructCollector::default(); + syn::visit::visit_file(&mut collector, &file); + let mut visitor = NestedAppConfigVisitor::new(&collector.app_config_structs, std::path::Path::new("t.rs")); + syn::visit::visit_file(&mut visitor, &file); + visitor.violations + } + + const NESTED_WITHOUT_OPT_IN: &str = r#" + #[derive(edgezero_core::AppConfig)] struct Inner { #[secret] k: String } + #[derive(edgezero_core::AppConfig)] struct Outer { inner: Inner } + "#; + + const NESTED_WITH_OPT_IN: &str = r#" + #[derive(edgezero_core::AppConfig)] struct Inner { #[secret] k: String } + #[derive(edgezero_core::AppConfig)] struct Outer { #[app_config(nested)] inner: Inner } + "#; + + const NESTED_VEC_WITH_OPT_IN: &str = r#" + #[derive(edgezero_core::AppConfig)] struct Inner { #[secret] k: String } + #[derive(edgezero_core::AppConfig)] struct Outer { #[app_config(nested)] inner: Vec } + "#; + + #[test] + fn flags_nesting_without_opt_in() { + assert_eq!(violations_in(NESTED_WITHOUT_OPT_IN), 1); + } + + #[test] + fn allows_nesting_with_opt_in() { + assert_eq!(violations_in(NESTED_WITH_OPT_IN), 0); + } + + #[test] + fn allows_vec_nesting_with_opt_in() { + assert_eq!(violations_in(NESTED_VEC_WITH_OPT_IN), 0); + } +} +``` + +(If the collector/visitor don't currently expose `default()`/`new()`/public fields for construction in tests, add minimal `#[derive(Default)]` / a `new` constructor / `pub(crate)` visibility as part of this task — they're in the same binary crate.) + +- [ ] **Step 2: Run (fails)** + +Run: `cargo test -p edgezero-cli --features nested-app-config-check --bin check_no_nested_app_config 2>&1 | tail -20` +Expected: FAIL — `allows_nesting_with_opt_in` sees 1 violation (the guard flags all nesting today). + +- [ ] **Step 3: Teach the visitor to honor `#[app_config(nested)]`** + +In `NestedAppConfigVisitor::visit_item_struct` (`check_no_nested_app_config.rs:156-181`), before reporting a violation for a field whose type contains an `AppConfig` struct, skip it if the field carries `#[app_config(nested)]`. Add a helper mirroring the macro's detection and guard the report: + +```rust +// Returns true only for a well-formed `#[app_config(nested)]`. A malformed +// `#[app_config(...)]` returns false -> the field is treated as NOT opted in, +// so the guard still FLAGS the nesting (loud CI failure) rather than silently +// waving it through. This is safe here (unlike the derive's `nested_optin`, +// which must hard-error): the guard runs only over already-compiling code, and +// the derive's strict `nested_optin` (Task 4) has already rejected any +// malformed `#[app_config(...)]` before this binary ever runs. +fn field_has_nested_optin(field: &syn::Field) -> bool { + field.attrs.iter().any(|attr| { + attr.path().is_ident("app_config") + && attr + .parse_nested_meta(|meta| { + if meta.path.is_ident("nested") { + Ok(()) + } else { + Err(meta.error("unknown app_config option")) + } + }) + .is_ok() + }) +} +``` + +and in the field loop, wrap the existing `if let Some(inner_name) = type_contains_app_config_struct(...) { self.report(...) }` so it becomes: + +```rust + if let Some(inner_name) = type_contains_app_config_struct(&field.ty, self.app_config_structs) { + if field_has_nested_optin(field) { + continue; // opted in via #[app_config(nested)] — allowed + } + self.report(self.source_path, field, outer_ident, &field_name, &inner_name); + } +``` + +(Adjust the `report` call to match the current signature at `check_no_nested_app_config.rs:138-149`.) + +- [ ] **Step 4: Run (passes) + run the guard over the real trees** + +Run: `cargo test -p edgezero-cli --features nested-app-config-check --bin check_no_nested_app_config 2>&1 | tail -20` +Expected: PASS. + +Run: `cargo run -q -p edgezero-cli --bin check_no_nested_app_config --features nested-app-config-check -- examples/app-demo crates/edgezero-cli/src/templates 2>&1 | tail -5` +Expected: `check_no_nested_app_config: OK` (app-demo has no opted-in nesting yet; still clean). + +- [ ] **Step 5: Lint + commit** + +Run: `cargo clippy -p edgezero-cli --features nested-app-config-check --bin check_no_nested_app_config -- -D warnings 2>&1 | tail -15` + +```bash +git add crates/edgezero-cli/src/bin/check_no_nested_app_config.rs +git commit -m "ci(secrets): allow nested AppConfig when field opts in via #[app_config(nested)]" +``` + +--- + +## Task 6: Path-aware CLI reflection (validate / push / diff over nested config) + +Task 1 made the CLI consumers compile against the new shape but only navigate top-level keys. Now make `run_adapter_typed_checks` and `typed_secret_checks` navigate the raw TOML by path (Field/ArrayEach), emitting one `TypedSecretEntry` per array element with a runtime dotted label, and resolving `store_ref` siblings within the innermost parent. + +**Files:** +- Modify: `crates/edgezero-cli/src/config.rs` (`run_adapter_typed_checks` at `:1295`, `typed_secret_checks` at `:1339`; add a TOML path navigator + tests) + +**Interfaces:** +- Consumes: `SecretField.path`/`optional`, `toml::Value`, `TypedSecretEntry::new(store_id, String, key_value)` (Task 1). +- Produces: path-aware validate/push/diff. Consumed by acceptance (nested config validates/pushes). + +- [ ] **Step 1: Write failing CLI navigation tests** + +Add tests to `crates/edgezero-cli/src/config.rs` `#[cfg(test)] mod tests`, driven through the **public** `run_config_validate_typed::` entry point (which calls both `typed_secret_checks` and `run_adapter_typed_checks`). `ValidationContext` has private fields and a `ManifestLoader` that is impractical to build by hand, so mirror the existing harness: write a manifest + `demo-app.toml` to a tempdir with `setup_project(manifest, app_config)` (`config.rs:1662`, returns `(TempDir, manifest_path, app_config_path)`) and pass `args_for(&manifest_path)` (`config.rs:1671`). The config type is a **real** nested `#[derive(AppConfig)]` (Task 4 is complete by Task 6), which also proves derive→CLI integration. + +```rust + // Real nested derive: integrations.datadome.server_side_key (KeyInDefault), + // partners[*].api_key (KeyInDefault). + #[derive(Debug, Deserialize, Serialize, Validate, edgezero_core::AppConfig)] + #[serde(deny_unknown_fields)] + struct DataDome { + #[secret] + server_side_key: String, + } + #[derive(Debug, Deserialize, Serialize, Validate, edgezero_core::AppConfig)] + #[serde(deny_unknown_fields)] + struct Integrations { + #[app_config(nested)] + #[validate(nested)] + datadome: DataDome, + } + #[derive(Debug, Deserialize, Serialize, Validate, edgezero_core::AppConfig)] + #[serde(deny_unknown_fields)] + struct Partner { + #[secret] + api_key: String, + } + #[derive(Debug, Deserialize, Serialize, Validate, edgezero_core::AppConfig)] + #[serde(deny_unknown_fields)] + struct NestedCliConfig { + #[app_config(nested)] + #[validate(nested)] + integrations: Integrations, + #[app_config(nested)] + #[validate(nested)] + partners: Vec, + } + + const NESTED_MANIFEST: &str = r#" +[app] +name = "demo-app" + +[adapters.axum.adapter] +crate = "crates/demo-axum" +[adapters.axum.commands] +build = "echo" +deploy = "echo" +serve = "echo" + +[stores.config] +ids = ["app_config"] + +[stores.secrets] +ids = ["default"] +"#; + + #[test] + fn validate_typed_accepts_well_formed_nested_and_array_secrets() { + let app_config = r#" +[integrations.datadome] +server_side_key = "dd_key" + +[[partners]] +api_key = "p0" + +[[partners]] +api_key = "p1" +"#; + let (_dir, manifest_path, _) = setup_project(NESTED_MANIFEST, app_config); + run_config_validate_typed::(&args_for(&manifest_path)) + .expect("well-formed nested + array secret config validates"); + } + + #[test] + fn validate_typed_reports_dotted_path_for_empty_array_secret() { + // partners[1].api_key is empty -> typed_secret_checks must reject it and + // name the INDEXED dotted path. + let app_config = r#" +[integrations.datadome] +server_side_key = "dd_key" + +[[partners]] +api_key = "p0" + +[[partners]] +api_key = "" +"#; + let (_dir, manifest_path, _) = setup_project(NESTED_MANIFEST, app_config); + let err = run_config_validate_typed::(&args_for(&manifest_path)) + .expect_err("empty array secret must be rejected"); + assert!( + err.contains("partners[1].api_key"), + "error names the indexed dotted path: {err}" + ); + } + + #[test] + fn validate_typed_reports_dotted_path_for_missing_nested_leaf() { + // integrations.datadome table present but server_side_key missing. + // (Note: serde deny_unknown_fields + required String means this also + // fails deserialization; assert the dotted path appears either way.) + let app_config = r#" +[integrations.datadome] + +[[partners]] +api_key = "p0" +"#; + let (_dir, manifest_path, _) = setup_project(NESTED_MANIFEST, app_config); + let err = run_config_validate_typed::(&args_for(&manifest_path)) + .expect_err("missing nested leaf must be rejected"); + assert!( + err.contains("server_side_key"), + "error names the missing nested leaf: {err}" + ); + } +``` + +Nested `KeyInNamedStore` CLI case (proves the store_ref sibling is resolved within the innermost parent table, and the named store must be declared in `[stores.secrets].ids`): + +```rust + #[derive(Debug, Deserialize, Serialize, Validate, edgezero_core::AppConfig)] + #[serde(deny_unknown_fields)] + struct Vaulted { + #[secret(store_ref = "vault")] + token: String, + #[secret(store_ref)] + vault: String, + } + #[derive(Debug, Deserialize, Serialize, Validate, edgezero_core::AppConfig)] + #[serde(deny_unknown_fields)] + struct NamedStoreCliConfig { + #[app_config(nested)] + #[validate(nested)] + vaulted: Vaulted, + } + + #[test] + fn validate_typed_accepts_nested_named_store_with_sibling() { + let manifest = r#" +[app] +name = "demo-app" + +[adapters.axum.adapter] +crate = "crates/demo-axum" +[adapters.axum.commands] +build = "echo" +deploy = "echo" +serve = "echo" + +[stores.config] +ids = ["app_config"] + +[stores.secrets] +ids = ["default", "named"] +"#; + let app_config = r#" +[vaulted] +token = "tok_key" +vault = "named" +"#; + let (_dir, manifest_path, _) = setup_project(manifest, app_config); + run_config_validate_typed::(&args_for(&manifest_path)) + .expect("nested named-store secret with a declared store validates"); + } +``` + +- [ ] **Step 2: Factor a TOML path leaf-collector** + +Add a helper that, given the raw `&toml::Value` table and a `&SecretField`, yields each resolved leaf as `(label: String, value: &str, store_ref_value: Option<&str>)`, where `label` uses concrete `[n]` indices and `store_ref_value` is resolved from the leaf's innermost parent table (for `KeyInNamedStore`). Absent optional leaves yield nothing; missing required leaves yield an error carrying the dotted label. + +```rust +struct ResolvedTomlLeaf<'a> { + label: String, + value: &'a str, + store_ref_value: Option<&'a str>, +} + +fn collect_secret_leaves<'a>( + root: &'a toml::Value, + field: &SecretField, +) -> Result>, String> { + fn walk<'a>( + node: &'a toml::Value, + field: &SecretField, + remaining: &[SecretPathSegment], + rendered: String, + out: &mut Vec>, + ) -> Result<(), String> { + match remaining.split_first() { + Some((SecretPathSegment::Field(name), rest)) if rest.is_empty() => { + let parent = node.as_table().ok_or_else(|| { + format!("expected a table containing `{name}` at `{rendered}`") + })?; + let leaf_label = if rendered.is_empty() { + name.to_string() + } else { + format!("{rendered}.{name}") + }; + match parent.get(name.as_ref()).and_then(toml::Value::as_str) { + Some(value) => { + let store_ref_value = match field.kind { + SecretKind::KeyInNamedStore { store_ref_field } => { + parent.get(store_ref_field).and_then(toml::Value::as_str) + } + _ => None, + }; + out.push(ResolvedTomlLeaf { label: leaf_label, value, store_ref_value }); + Ok(()) + } + None if field.optional && parent.get(name.as_ref()).is_none() => Ok(()), + None => Err(format!("`#[secret]` field `{leaf_label}` is missing or not a string")), + } + } + Some((SecretPathSegment::Field(name), rest)) => { + let table = node.as_table().ok_or_else(|| format!("expected a table at `{rendered}`"))?; + let next_rendered = if rendered.is_empty() { name.to_string() } else { format!("{rendered}.{name}") }; + match table.get(name.as_ref()) { + Some(child) => walk(child, field, rest, next_rendered, out), + None if field.optional => Ok(()), + None => Err(format!("missing `{next_rendered}`")), + } + } + Some((SecretPathSegment::ArrayEach, rest)) => { + let arr = node.as_array().ok_or_else(|| format!("expected an array at `{rendered}`"))?; + for (idx, item) in arr.iter().enumerate() { + walk(item, field, rest, format!("{rendered}[{idx}]"), out)?; + } + Ok(()) + } + None => Ok(()), + } + } + let mut out = Vec::new(); + walk(root, field, &field.path, String::new(), &mut out)?; + Ok(out) +} +``` + +- [ ] **Step 3: Rewrite the two consumers to use the collector** + +Replace the flat lookups in `run_adapter_typed_checks` (`config.rs:1295-1333`) and `typed_secret_checks` (`config.rs:1339-1412`): + +- `run_adapter_typed_checks`: for each `field in C::secret_fields()`, for each leaf in `collect_secret_leaves(raw_value, &field)?`, build entries. For `KeyInDefault`, use `default_store_id`; for `KeyInNamedStore`, use `leaf.store_ref_value` (error if `None`); push `TypedSecretEntry::new(store_id, leaf.label, leaf.value)`. `StoreRef` still produces no entry. +- `typed_secret_checks`: for each `field`, for each leaf, apply the existing empty-string / `[stores.secrets]`-declared / store-ref-in-ids checks, but keyed on `leaf.label`/`leaf.value`. For `StoreRef`, the leaf value must be in `[stores.secrets].ids` (as today). + +Note the collector takes `&toml::Value` (the whole raw config) — `run_adapter_typed_checks`/`typed_secret_checks` currently start from `raw_table = ctx.raw_config.as_table()`; pass `&ctx.raw_config` to the collector instead (it does the `as_table` internally). + +- [ ] **Step 4: Run (passes) + full CLI tests** + +Run: `cargo test -p edgezero-cli --lib config 2>&1 | tail -25` +Expected: PASS — new nested tests + all pre-existing config tests (top-level fixtures still length-1 paths). + +- [ ] **Step 5: Lint + commit** + +Run: `cargo clippy -p edgezero-cli --all-targets --all-features -- -D warnings 2>&1 | tail -15` + +```bash +git add crates/edgezero-cli/src/config.rs +git commit -m "feat(cli): path-aware secret reflection in config validate/push/diff" +``` + +--- + +## Task 7: End-to-end nested-secret extractor test + `KeyInNamedStore` fixture + docs + +Prove the whole chain with a real `#[derive(AppConfig)]` config that has a 2-level nested secret and a nested `KeyInNamedStore` (sibling-in-parent), resolved through an `InMemorySecretStore` via the `AppConfig` extractor. Then document the feature. + +**Files:** +- Modify: `crates/edgezero-core/src/extractor.rs` (E2E test in `#[cfg(test)] mod tests`) — or a new `crates/edgezero-macros/tests/` integration test if a real `#[derive(AppConfig)]` is easier there (the derive lives in macros; a genuine nested derived struct needs `edgezero_core` as an external crate, so prefer `crates/edgezero-macros/tests/nested_secrets_e2e.rs`). +- Modify: `docs/guide/configuration.md` + +**Interfaces:** +- Consumes: everything from Tasks 1–6. +- Produces: acceptance evidence; docs. + +- [ ] **Step 1: Write the failing E2E test** + +Create `crates/edgezero-macros/tests/nested_secrets_e2e.rs`. Define a real nested config with `#[derive(AppConfig, Deserialize, Validate)]`, including one `KeyInDefault` nested leaf and one `KeyInNamedStore` nested leaf whose `store_ref` sibling lives in the same inner struct. Build a `serde_json::Value` blob holding key NAMES, run `secret_walk` (via the public `AppConfig` extraction path or by calling the crate's extraction entry point), and assert the resolved values. + +Prefer driving it through the same public surface app-demo's `config_flow.rs` uses (`InMemorySecretStore::new([...])`, build a `BlobEnvelope`, extract via the `AppConfig` extractor with the store registry in `ctx`). Model on `examples/app-demo/crates/app-demo-cli/tests/config_flow.rs:210-231`. Assert: + - nested `KeyInDefault` leaf resolves from the default store; + - nested `KeyInNamedStore` leaf resolves from the named store identified by its sibling; + - a nested config with an array of secrets resolves each element. + +```rust +#[derive(serde::Deserialize, validator::Validate, edgezero_core::AppConfig)] +#[serde(deny_unknown_fields)] +struct DataDome { + #[secret] + server_side_key: String, +} +#[derive(serde::Deserialize, validator::Validate, edgezero_core::AppConfig)] +#[serde(deny_unknown_fields)] +struct Vaulted { + #[secret(store_ref = "vault")] + token: String, + #[secret(store_ref)] + vault: String, +} +#[derive(serde::Deserialize, validator::Validate, edgezero_core::AppConfig)] +#[serde(deny_unknown_fields)] +struct Settings { + #[app_config(nested)] + #[validate(nested)] + datadome: DataDome, + #[app_config(nested)] + #[validate(nested)] + vaulted: Vaulted, +} +// ... build data = { "datadome": { "server_side_key": "dd_key" }, +// "vaulted": { "token": "tok_key", "vault": "named" } } +// ... default store: default/dd_key -> "DD"; named store "named": named/tok_key -> "TOK" +// ... run extraction; assert cfg.datadome.server_side_key == "DD" and cfg.vaulted.token == "TOK". +``` + +- [ ] **Step 2: Run (fails, then passes)** + +Run: `cargo test -p edgezero-macros --test nested_secrets_e2e 2>&1 | tail -25` +Expected: FAIL first (fixture/wiring), then PASS once assertions match resolved values. (If any Task 1–6 gap surfaces here, fix in the owning task's file and re-run.) + +- [ ] **Step 3: Docs** + +Append to `docs/guide/configuration.md` a "Nested and array secrets" section documenting: the `#[app_config(nested)]` opt-in (mirrors `#[validate(nested)]`; the nested type must itself derive `AppConfig`), `#[secret]` on `Option` (absent → skipped at runtime), object nesting and `Vec<_>` arrays (`partners[*].api_key`), the `store_ref` sibling scoping rule (resolved within the innermost containing object), and the dotted-path error format (`integrations.datadome.server_side_key`, `partners[3].api_key`). Include a worked `Settings`/`Integrations`/`Partner` example. + +- [ ] **Step 4: Full workspace verification (all CI gates)** + +```bash +cargo fmt --all -- --check +cargo clippy --workspace --all-targets --all-features -- -D warnings +cargo test --workspace --all-targets +cargo check --workspace --all-targets --features "fastly cloudflare spin" +cargo check -p edgezero-adapter-spin --target wasm32-wasip2 --features spin +cargo run -q -p edgezero-cli --bin check_no_nested_app_config --features nested-app-config-check -- examples/app-demo crates/edgezero-cli/src/templates +(cd examples/app-demo && cargo test) +``` +Expected: all green; app-demo top-level `#[secret]` still resolves; the guard prints `OK`. + +- [ ] **Step 5: Commit** + +```bash +git add crates/edgezero-macros/tests/nested_secrets_e2e.rs docs/guide/configuration.md +git commit -m "test(secrets): end-to-end nested + named-store resolution; docs: nested/array secrets" +``` + +--- + +## Acceptance criteria (spec §5) + +1. `cargo fmt` / clippy clean across `edgezero-core`, `edgezero-macros`, all adapters, `edgezero-cli`. +2. New unit + UI + integration tests (Tasks 1–7) pass; the six pre-existing `validate_excluding_secrets_*` / `app_config_secret_walk_*` tests still pass. +3. `app-demo` still builds and serves on all four adapters; its top-level `#[secret] api_token` (`KeyInDefault`) and `vault` (`StoreRef`) resolve identically (length-1 paths). +4. `edgezero-cli` `config validate/push/diff` operate correctly over a config with nested + array secrets. +5. The **Nested AppConfig audit** CI step passes and now permits `#[app_config(nested)]` fields. +6. Rustdoc + `docs/guide/configuration.md` updates merged. + +## Self-review notes (mapping to spec §4 + §8 blockers) + +- §4.2 metadata: owned `SecretField { kind, path: Vec, optional }` + `dotted_path()` → Task 1. **[B, BLOCKER] owned segments** (not `&'static`) — done. **[B, BLOCKER] `optional` flag** — done. +- §4.3 derive: `#[app_config(nested)]` opt-in, recursion, `Option`, path guards; **[B, HIGH] register `app_config` attr** (Task 4 Step 1); **[B, HIGH] nested-only `rename_all`** (Task 4 Step 7) → Task 4. **B-3 forced to `fn secret_fields()`** — Task 1. +- §4.4 runtime walk: Field/ArrayEach navigator, optional skip, sibling-in-parent, dotted `[n]` errors → Task 2. +- §4.5 CLI: path-aware `run_adapter_typed_checks`/`typed_secret_checks`; `build_config_envelope` unchanged (serializes verbatim); Spin collision keys on value (survives reshape, prints dotted label) → Tasks 1 + 6. **[B, HIGH] owned `TypedSecretEntry.field_name`** → Task 1 Step 8. +- §4.6 back-compat: top-level configs behave identically (length-1 paths); all in-tree consumers flip in the same branch → Task 1. +- **[B, IMPORTANT] nested `validate_excluding_secrets`** (not a flat remove) → Task 3. +- **[B, BLOCKER] inverted CI guard** → Task 5. +- **[B, HIGH] array scope decided: arrays-now** (`ArrayEach` implemented throughout) → Tasks 1–7. +- §4.7 tests: derive UI (nested/optional/rename/non-AppConfig), runtime (nested/named-store/optional/missing), E2E 2-level → Tasks 4, 2, 7. `KeyInNamedStore` needs a purpose-built fixture (app-demo has none) → Task 7. + +## Review round 2 — fixes folded in + +- **Optional `None` = JSON `null` (blocker):** `resolve_leaf` and the object-descent arm now skip an optional leaf/subtree that is missing *or* `null` (serde emits `None` as `null`; `#[secret]` bans `skip_serializing_if`). Added `secret_walk_skips_null_optional_leaf` (Task 2). +- **`TypedSecretEntry::new` back-compat (blocker):** constructor takes `field_name: impl Into` so the 7 existing `&str`-literal Spin test call sites compile unchanged (Task 1 Step 8). +- **Malformed `#[app_config(...)]` (high):** derive helper is `nested_optin(field) -> syn::Result` (hard error on unknown option, propagated with `?`); added `app_config_unknown_option.rs` UI fixture. CI-guard helper stays lenient by design (documented: runs only over already-compiling code) (Tasks 4, 5). +- **Array validation pruning untested (high):** added `validate_excluding_secrets_prunes_array_secret_leaf_keeps_siblings` exercising the `ValidationErrorsKind::List` branch (Task 3). +- **`#[secret(store_ref)]` + `Option` (high):** rejected at compile time (a store id is structural); added `secret_store_ref_optional.rs` UI fixture. Optional allowed only on `KeyInDefault`/`KeyInNamedStore` (Task 4 Step 6). +- **Nested-child marker (medium):** emit an explicit `AppConfigRoot` bound assertion per nested child (not just the implicit `AppConfigMeta` call), matching spec §4.3/B-2 (Task 4 Step 5). + +## Review round 3 — fixes folded in + +- **serde `rename` on nested parent fields:** the spec forbids `#[serde(rename)]` *anywhere* on a secret path. The plan now runs the existing `enforce_no_disallowed_serde_attrs` (bans rename/flatten/skip*) on every `#[app_config(nested)]` field too — a nested parent field with `#[serde(rename)]` would desync its `Field(field_name)` segment. Added `nested_field_serde_rename.rs` UI fixture (Task 4 Step 4). +- **Named-store coverage earlier + broader:** added a nested `KeyInNamedStore` sibling-in-parent runtime test (`secret_walk_resolves_nested_named_store_via_sibling_in_parent`) and a missing-sibling error test to Task 2, plus a nested `KeyInNamedStore` CLI validate test to Task 6 — no longer only end-to-end in Task 7. +- **Array pruning all-secret success:** added `validate_excluding_secrets_prunes_array_all_secret_failures_to_ok`, proving an array branch whose every element's only failure is the secret leaf collapses to `Ok(())` (the `items.retain(..)`/`items.is_empty()` path), complementing the sibling-survives test (Task 3). +- **Task 6 CLI tests made concrete:** replaced the pseudo-code with real tests driven through the public `run_config_validate_typed::` entry point using the existing `setup_project`/`args_for` harness (`config.rs:1662/1671`) and real nested `#[derive(AppConfig)]` fixtures — with concrete TOML, `partners[1].api_key` indexed-label assertion, missing-leaf assertion, and the nested `KeyInNamedStore` case (Task 6 Step 1). +- **Removed the obsolete pruning sketch:** Task 3 Step 3 now shows a single `prune_secret_leaf` (the peek-next-segment form); the earlier draft referencing the undefined `list_children_mut` is deleted. diff --git a/docs/superpowers/plans/2026-07-02-edgezero-state-extractor.md b/docs/superpowers/plans/2026-07-02-edgezero-state-extractor.md new file mode 100644 index 00000000..dcab2524 --- /dev/null +++ b/docs/superpowers/plans/2026-07-02-edgezero-state-extractor.md @@ -0,0 +1,789 @@ +# EdgeZero `State` Extractor Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add a `State` extractor plus `RouterBuilder::with_state` so any EdgeZero app can hand app-owned shared state (typically `Arc`) to `#[action]` extractor-style handlers. + +**Architecture:** Mirror PR #300's introspection-injection mechanism. `RouterBuilder::with_state` records a type-erased closure that clones the value into `request.extensions_mut()`. `RouterInner::dispatch` runs those closures on the owned request just before `RequestContext::new`, right after the existing introspection inserts. A new `State: FromRequest` extractor reads the value back by type from request extensions — so `#[action]` composes it with zero macro changes. + +**Tech Stack:** Rust 1.95, edition 2021. `edgezero-core` (WASM-compatible: `async-trait(?Send)`, no Tokio), `edgezero-macros` (proc macros), `http` crate via the `crate::http` facade only. + +## Base branch + +- **Implementation branch:** `worktree-state-nested-secrets-spec-review`. +- **Base:** **PR #300** ("pluggable introspection routes", branch `worktree-feature+introspection-routes`, head `2efa2da`) has already been **merged into this branch** (merge commit `051a9ad`). Every router line number below is from that merged tree and was verified live (`RouterBuilder` at `router.rs:71`, `build(self)` at `:110`, `with_manifest_json` at `:192`, `RouterInner` at `:198`, `dispatch(&self, mut request)` at `:206`, `RequestContext::new(request, params)` at `:227`, `RouterService::new` at `:297`). If the branch is later rebased and these drift, re-confirm before editing. +- This plan shares its branch with the sibling **nested `#[secret]`** plan (`2026-07-02-edgezero-nested-secrets.md`). The only file both touch is `crates/edgezero-core/src/extractor.rs`, in disjoint regions (this plan appends the `State` extractor; the other rewrites `secret_walk`). Either order is safe. + +## Global Constraints + +- **Rust 1.95.0**, edition 2021, resolver 2 (from `.tool-versions` / root `Cargo.toml`). +- **WASM-compat:** no Tokio, no `std::time::Instant`; extractors use `#[async_trait(?Send)]`. Async tests use `futures::executor::block_on`, never Tokio. +- **HTTP facade:** never import from the `http` crate directly. Use `crate::http::{...}` (the `Extensions` alias is `crate::http::Extensions`, defined at `crates/edgezero-core/src/http.rs:25`). +- **Colocate tests** in `#[cfg(test)] mod tests` in the same file as the implementation. +- **CI gates (all must pass):** + 1. `cargo fmt --all -- --check` + 2. `cargo clippy --workspace --all-targets --all-features -- -D warnings` + 3. `cargo test --workspace --all-targets` + 4. `cargo check --workspace --all-targets --features "fastly cloudflare spin"` + 5. `cargo check -p edgezero-adapter-spin --target wasm32-wasip2 --features spin` +- **Naming decision (locked):** ship `State` + `with_state` only. Do **not** add an `Extension` alias or a `RequestContext::state::()` accessor (YAGNI; trusted-server needs neither). No crate-root `pub use` of `State` — consumers reference `edgezero_core::extractor::State` (matches how every other extractor is reached today). + +--- + +## File Structure + +| File | Responsibility | Change | +| ---- | -------------- | ------ | +| `crates/edgezero-core/src/extractor.rs` | The `State` extractor + `Deref`/`DerefMut`/`into_inner` + unit tests | Modify (append) | +| `crates/edgezero-core/src/router.rs` | `StateInserter` alias, `RouterBuilder::with_state`, thread `state_inserters` through `build()` → `RouterService::new` → `RouterInner`, apply in `dispatch` + router tests | Modify | +| `crates/edgezero-macros/tests/action_state.rs` | Integration test proving `#[action]` composes `State` with `Query` end-to-end | Create | +| `crates/edgezero-macros/Cargo.toml` | Add `futures` dev-dependency (for `block_on` in the integration test) | Modify | +| `docs/guide/handlers.md` | "Sharing app state" section | Modify (append) | + +--- + +## Task 1: `State` extractor + +**Files:** +- Modify: `crates/edgezero-core/src/extractor.rs` (append extractor after the existing extractors; append tests inside the existing `#[cfg(test)] mod tests` at the end of the file) + +**Interfaces:** +- Consumes: `crate::context::RequestContext` (has `pub(crate) fn extension(&self) -> Option where T: Clone + Send + Sync + 'static` at `context.rs:77`), `crate::error::EdgeError` (`EdgeError::internal(anyhow::Error) -> 500`; `err.status() -> StatusCode`), the `FromRequest` trait (`extractor.rs:21`), `std::ops::{Deref, DerefMut}` (already imported at `extractor.rs:1`). +- Produces: `pub struct State(pub T)` with `impl FromRequest for State`, plus `Deref`/`DerefMut`/`into_inner`. Consumed by Task 2 (router tests) and Task 3 (macro composition). + +- [ ] **Step 1: Write the failing tests** + +Append to the `#[cfg(test)] mod tests` block at the end of `crates/edgezero-core/src/extractor.rs`. The module already imports `request_builder, Method, StatusCode` (from `crate::http`), `RequestContext`, `PathParams`, `Body`, `block_on`, and `std::sync::Arc`. + +```rust + #[derive(Clone, Debug, PartialEq)] + struct AppStateFixture { + name: String, + } + + #[test] + fn state_extractor_resolves_registered_value() { + let mut request = request_builder() + .method(Method::GET) + .uri("/") + .body(Body::empty()) + .expect("request"); + request.extensions_mut().insert(Arc::new(AppStateFixture { + name: "demo".to_owned(), + })); + let ctx = RequestContext::new(request, PathParams::default()); + + let state = block_on(State::>::from_request(&ctx)) + .expect("state present"); + + // Deref: State> -> Arc -> AppStateFixture + assert_eq!(state.name, "demo"); + } + + #[test] + fn state_extractor_missing_registration_is_internal_error() { + let request = request_builder() + .method(Method::GET) + .uri("/") + .body(Body::empty()) + .expect("request"); + let ctx = RequestContext::new(request, PathParams::default()); + + let err = block_on(State::>::from_request(&ctx)) + .expect_err("missing state must surface as an error, not a default"); + assert_eq!(err.status(), StatusCode::INTERNAL_SERVER_ERROR); + } + + #[test] + fn state_extractor_deref_and_into_inner() { + let mut request = request_builder() + .method(Method::GET) + .uri("/") + .body(Body::empty()) + .expect("request"); + request.extensions_mut().insert(AppStateFixture { + name: "x".to_owned(), + }); + let ctx = RequestContext::new(request, PathParams::default()); + + let state = + block_on(State::::from_request(&ctx)).expect("state present"); + assert_eq!( + *state, + AppStateFixture { + name: "x".to_owned() + } + ); // Deref + assert_eq!( + state.into_inner(), + AppStateFixture { + name: "x".to_owned() + } + ); + } +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `cargo test -p edgezero-core --lib state_extractor 2>&1 | tail -20` +Expected: FAIL — compile error `cannot find type/struct State in this scope` (the extractor does not exist yet). + +- [ ] **Step 3: Write the extractor** + +Insert into `crates/edgezero-core/src/extractor.rs` immediately after the `Kv` extractor block (after the `impl Kv { ... }` that ends around `extractor.rs:529`), before the next extractor. `anyhow` is already used in this file; `core::any::type_name` needs no import. + +```rust +/// Extractor for app-owned shared state registered via +/// [`RouterBuilder::with_state`]. Resolves by type from request extensions. +/// +/// Typically `T = Arc`. The registered value is cloned into every +/// request's extensions before dispatch; registering the same `T` twice is +/// last-write-wins. +/// +/// ```ignore +/// use edgezero_core::extractor::State; +/// use std::sync::Arc; +/// +/// #[edgezero_core::action] +/// async fn handle(State(state): State>) -> Result { +/// Ok(state.greeting.clone()) +/// } +/// ``` +/// +/// [`RouterBuilder::with_state`]: crate::router::RouterBuilder::with_state +pub struct State(pub T); + +#[async_trait(?Send)] +impl FromRequest for State +where + T: Clone + Send + Sync + 'static, +{ + #[inline] + async fn from_request(ctx: &RequestContext) -> Result { + ctx.extension::().map(State).ok_or_else(|| { + EdgeError::internal(anyhow::anyhow!( + "no `State<{}>` registered -- call RouterBuilder::with_state(..) before build()", + core::any::type_name::() + )) + }) + } +} + +impl Deref for State { + type Target = T; + + #[inline] + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl DerefMut for State { + #[inline] + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.0 + } +} + +impl State { + /// Consume the extractor and return the inner value. + #[inline] + pub fn into_inner(self) -> T { + self.0 + } +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `cargo test -p edgezero-core --lib state_extractor 2>&1 | tail -20` +Expected: PASS — 3 tests (`state_extractor_resolves_registered_value`, `state_extractor_missing_registration_is_internal_error`, `state_extractor_deref_and_into_inner`). + +- [ ] **Step 5: Lint** + +Run: `cargo clippy -p edgezero-core --all-targets --all-features -- -D warnings 2>&1 | tail -20` +Expected: no warnings. + +- [ ] **Step 6: Commit** + +```bash +git add crates/edgezero-core/src/extractor.rs +git commit -m "feat(core): add State extractor for app-owned shared state" +``` + +--- + +## Task 2: `RouterBuilder::with_state` + dispatch plumbing + +**Files:** +- Modify: `crates/edgezero-core/src/router.rs` (add `StateInserter` alias, `state_inserters` field on `RouterBuilder` and `RouterInner`, `with_state` method, 5th arg through `build()`/`RouterService::new`, apply in `dispatch`; add router tests in the existing `#[cfg(test)] mod tests`) + +**Interfaces:** +- Consumes: `State` from Task 1 (`crate::extractor::State`), `crate::http::Extensions` (facade alias), `std::sync::Arc` (imported at `router.rs:2`). +- Produces: `RouterBuilder::with_state(self, value: T) -> Self where T: Clone + Send + Sync + 'static`. Consumed by Task 3. + +- [ ] **Step 1: Write the failing router tests** + +Append to `crates/edgezero-core/src/router.rs`'s main `#[cfg(test)] mod tests` (the block whose imports are at `router.rs:476`, which already imports `Arc, Mutex`, `block_on`, `noop_waker_ref`, `Context, Poll`, `request_builder, Method, StatusCode`, `Body`, `RequestContext`, `EdgeError`). + +```rust + #[test] + fn with_state_exposes_value_to_handler() { + use crate::extractor::{FromRequest as _, State}; + + #[derive(Clone)] + struct Counter(u32); + + async fn handler(ctx: RequestContext) -> Result { + let State(counter) = State::::from_request(&ctx).await?; + Ok(format!("count={}", counter.0)) + } + + let service = RouterService::builder() + .with_state(Counter(9)) + .get("/count", handler) + .build(); + + let request = request_builder() + .method(Method::GET) + .uri("/count") + .body(Body::empty()) + .expect("request"); + + let response = block_on(service.oneshot(request)).expect("response"); + assert_eq!(response.status(), StatusCode::OK); + assert_eq!(response.body().as_bytes().expect("buffered"), b"count=9"); + } + + #[test] + fn with_state_supports_multiple_distinct_types() { + use crate::extractor::{FromRequest as _, State}; + + #[derive(Clone)] + struct A(u32); + #[derive(Clone)] + struct B(&'static str); + + async fn handler(ctx: RequestContext) -> Result { + let State(a) = State::::from_request(&ctx).await?; + let State(b) = State::::from_request(&ctx).await?; + Ok(format!("{}-{}", a.0, b.0)) + } + + let service = RouterService::builder() + .with_state(A(7)) + .with_state(B("hi")) + .get("/both", handler) + .build(); + + let request = request_builder() + .method(Method::GET) + .uri("/both") + .body(Body::empty()) + .expect("request"); + + let response = block_on(service.oneshot(request)).expect("response"); + assert_eq!(response.body().as_bytes().expect("buffered"), b"7-hi"); + } + + #[test] + fn with_state_same_type_is_last_write_wins() { + use crate::extractor::{FromRequest as _, State}; + + #[derive(Clone)] + struct Counter(u32); + + async fn handler(ctx: RequestContext) -> Result { + let State(counter) = State::::from_request(&ctx).await?; + Ok(format!("count={}", counter.0)) + } + + let service = RouterService::builder() + .with_state(Counter(1)) + .with_state(Counter(2)) + .get("/c", handler) + .build(); + + let request = request_builder() + .method(Method::GET) + .uri("/c") + .body(Body::empty()) + .expect("request"); + + let response = block_on(service.oneshot(request)).expect("response"); + assert_eq!(response.body().as_bytes().expect("buffered"), b"count=2"); + } + + #[test] + fn with_state_no_cross_request_bleed() { + use crate::extractor::{FromRequest as _, State}; + use std::future::Future as _; + + #[derive(Clone)] + struct Tag(&'static str); + + async fn handler(ctx: RequestContext) -> Result { + let State(tag) = State::::from_request(&ctx).await?; + Ok(tag.0.to_owned()) + } + + let service = RouterService::builder() + .with_state(Tag("shared")) + .get("/t", handler) + .build(); + + let req1 = request_builder() + .method(Method::GET) + .uri("/t") + .body(Body::empty()) + .expect("req1"); + let req2 = request_builder() + .method(Method::GET) + .uri("/t") + .body(Body::empty()) + .expect("req2"); + + // Two independent in-flight requests, polled interleaved on one thread. + let mut f1 = Box::pin(service.oneshot(req1)); + let mut f2 = Box::pin(service.oneshot(req2)); + let mut cx = Context::from_waker(noop_waker_ref()); + + let mut r1 = None; + let mut r2 = None; + while r1.is_none() || r2.is_none() { + if r1.is_none() { + if let Poll::Ready(v) = f1.as_mut().poll(&mut cx) { + r1 = Some(v); + } + } + if r2.is_none() { + if let Poll::Ready(v) = f2.as_mut().poll(&mut cx) { + r2 = Some(v); + } + } + } + + let resp1 = r1.unwrap().expect("resp1"); + let resp2 = r2.unwrap().expect("resp2"); + assert_eq!(resp1.body().as_bytes().expect("buffered"), b"shared"); + assert_eq!(resp2.body().as_bytes().expect("buffered"), b"shared"); + } +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `cargo test -p edgezero-core --lib with_state 2>&1 | tail -20` +Expected: FAIL — `no method named with_state found for struct RouterBuilder`. + +- [ ] **Step 3: Add the `StateInserter` type alias** + +In `crates/edgezero-core/src/router.rs`, add just above `pub struct RouterBuilder` (which is at `router.rs:71`, under its `#[derive(Default)]` at `router.rs:70`): + +```rust +/// Type-erased closure that clones a registered state value into a request's +/// extensions at dispatch. See [`RouterBuilder::with_state`]. +type StateInserter = Arc; +``` + +- [ ] **Step 4: Add the `state_inserters` field to `RouterBuilder`** + +Change the struct at `router.rs:70-76` from: + +```rust +#[derive(Default)] +pub struct RouterBuilder { + manifest_json: Option>, + middlewares: Vec, + route_info: Vec, + routes: HashMap>, +} +``` + +to: + +```rust +#[derive(Default)] +pub struct RouterBuilder { + manifest_json: Option>, + middlewares: Vec, + route_info: Vec, + routes: HashMap>, + state_inserters: Vec, +} +``` + +- [ ] **Step 5: Add the `with_state` method** + +In the `impl RouterBuilder` block, add immediately after `with_manifest_json` (which is at `router.rs:190-195`): + +```rust + /// Register a value cloned into every request's extensions before + /// dispatch, making it available to the [`State`] extractor and to + /// `RequestContext`-based handlers. + /// + /// Typically `T = Arc`. Registering the same `T` twice is + /// last-write-wins. Cost is one `T::clone` (an `Arc` bump for + /// `Arc`) per registered state per request. + /// + /// [`State`]: crate::extractor::State + #[must_use] + #[inline] + pub fn with_state(mut self, value: T) -> Self + where + T: Clone + Send + Sync + 'static, + { + self.state_inserters + .push(Arc::new(move |ext: &mut crate::http::Extensions| { + ext.insert(value.clone()); + })); + self + } +``` + +- [ ] **Step 6: Thread `state_inserters` through `build()`** + +Change `build()` at `router.rs:108-119` from: + +```rust + pub fn build(self) -> RouterService { + let route_index: Arc<[RouteInfo]> = Arc::from(self.route_info); + + RouterService::new( + self.routes, + self.middlewares, + route_index, + self.manifest_json, + ) + } +``` + +to (add the 5th argument): + +```rust + pub fn build(self) -> RouterService { + let route_index: Arc<[RouteInfo]> = Arc::from(self.route_info); + + RouterService::new( + self.routes, + self.middlewares, + route_index, + self.manifest_json, + self.state_inserters, + ) + } +``` + +- [ ] **Step 7: Add the field to `RouterInner` and the param to `RouterService::new`** + +Change `RouterInner` at `router.rs:198-203` from: + +```rust +struct RouterInner { + manifest_json: Option>, + middlewares: Vec, + route_index: Arc<[RouteInfo]>, + routes: HashMap>, +} +``` + +to: + +```rust +struct RouterInner { + manifest_json: Option>, + middlewares: Vec, + route_index: Arc<[RouteInfo]>, + routes: HashMap>, + state_inserters: Vec, +} +``` + +Change `RouterService::new` at `router.rs:297-311` from: + +```rust + fn new( + routes: HashMap>, + middlewares: Vec, + route_index: Arc<[RouteInfo]>, + manifest_json: Option>, + ) -> Self { + Self { + inner: Arc::new(RouterInner { + manifest_json, + middlewares, + route_index, + routes, + }), + } + } +``` + +to: + +```rust + fn new( + routes: HashMap>, + middlewares: Vec, + route_index: Arc<[RouteInfo]>, + manifest_json: Option>, + state_inserters: Vec, + ) -> Self { + Self { + inner: Arc::new(RouterInner { + manifest_json, + middlewares, + route_index, + routes, + state_inserters, + }), + } + } +``` + +- [ ] **Step 8: Apply the inserters in `dispatch`** + +In `RouterInner::dispatch` (`router.rs:206-237`), inside the `RouteMatch::Found(entry, params)` arm, add the state-insertion loop after the `needs.routes` block and before `let ctx = RequestContext::new(request, params);` (currently `router.rs:227`). The arm becomes: + +```rust + RouteMatch::Found(entry, params) => { + // Inject only the introspection payloads this route asked for — + // nothing for the vast majority of routes that need none. + let needs = entry.introspection_needs; + if needs.manifest { + if let Some(json) = &self.manifest_json { + request + .extensions_mut() + .insert(ManifestJson(Arc::clone(json))); + } + } + if needs.routes { + request + .extensions_mut() + .insert(RouteTable(Arc::clone(&self.route_index))); + } + // App-owned state registered via RouterBuilder::with_state. + // Runs after introspection inserts; distinct TypeIds, so no + // collision. Last registered wins for a given `T`. + for inserter in &self.state_inserters { + inserter(request.extensions_mut()); + } + let ctx = RequestContext::new(request, params); + let next = Next::new(&self.middlewares, entry.handler.as_ref()); + next.run(ctx).await + } +``` + +- [ ] **Step 9: Run tests to verify they pass** + +Run: `cargo test -p edgezero-core --lib with_state 2>&1 | tail -20` +Expected: PASS — 4 tests (`with_state_exposes_value_to_handler`, `with_state_supports_multiple_distinct_types`, `with_state_same_type_is_last_write_wins`, `with_state_no_cross_request_bleed`). + +- [ ] **Step 10: Full crate test + lint** + +Run: `cargo test -p edgezero-core 2>&1 | tail -20 && cargo clippy -p edgezero-core --all-targets --all-features -- -D warnings 2>&1 | tail -20` +Expected: all existing + new tests PASS; clippy clean (proves the router restructure did not regress introspection tests). + +- [ ] **Step 11: Commit** + +```bash +git add crates/edgezero-core/src/router.rs +git commit -m "feat(core): RouterBuilder::with_state injects app state into request extensions" +``` + +--- + +## Task 3: `#[action]` composition integration test + docs + +**Files:** +- Create: `crates/edgezero-macros/tests/action_state.rs` +- Modify: `crates/edgezero-macros/Cargo.toml` (add `futures` dev-dependency) +- Modify: `docs/guide/handlers.md` (append "Sharing app state" section) + +**Interfaces:** +- Consumes: `State` (Task 1), `RouterBuilder::with_state` (Task 2), `#[action]` (unchanged — `crates/edgezero-macros/src/action.rs:183` emits `<#ty as ::edgezero_core::extractor::FromRequest>::from_request(&__ctx).await?` for every non-`RequestContext` arg), `RouterService::oneshot` (`router.rs:316`), `Query` extractor (`edgezero_core::extractor::Query`). +- Produces: nothing consumed downstream; this is the acceptance proof that the macro composes `State` with another extractor. + +- [ ] **Step 1: Add the `futures` dev-dependency to the macros crate** + +Confirm `futures` is a workspace dependency: + +Run: `grep -n 'futures = ' Cargo.toml` +Expected: a line like `futures = { version = "0.3", features = ["std", "executor"] }` under `[workspace.dependencies]`. + +Then edit `crates/edgezero-macros/Cargo.toml`'s `[dev-dependencies]` (currently `edgezero-core`, `tempfile`, `trybuild`) to add `futures`: + +```toml +[dev-dependencies] +# `edgezero-core` re-exports `AppConfig`; the derive tests assert +# against the trait/types over the re-export path the way downstream +# users will. Cargo allows dev-dep cycles (only the main dep edge +# matters for build ordering). +edgezero-core = { workspace = true } +futures = { workspace = true } +tempfile = { workspace = true } +trybuild = { workspace = true } +``` + +(If `grep` shows `futures` is not workspace-managed, use `futures = "0.3"` with `features = ["std", "executor"]` instead.) + +- [ ] **Step 2: Write the failing integration test** + +Create `crates/edgezero-macros/tests/action_state.rs`: + +```rust +//! Integration coverage: `#[action]` composes the `State` extractor with a +//! request-derived extractor (`Query`) and runs end-to-end through the +//! router. Lives in `edgezero-macros/tests` because the `#[action]` macro +//! emits absolute `::edgezero_core::…` paths that only resolve when +//! `edgezero_core` is an external crate (as it is here, via the dev-dep). + +#[cfg(test)] +mod tests { + use edgezero_core::action; + use edgezero_core::body::Body; + use edgezero_core::error::EdgeError; + use edgezero_core::extractor::{Query, State}; + use edgezero_core::http::{request_builder, Method, StatusCode}; + use edgezero_core::router::RouterService; + use futures::executor::block_on; + use serde::Deserialize; + use std::sync::Arc; + + #[derive(Clone)] + struct AppState { + greeting: String, + } + + #[derive(Deserialize)] + struct Params { + n: u32, + } + + #[action] + async fn handler( + State(state): State>, + Query(params): Query, + ) -> Result { + Ok(format!("{}:{}", state.greeting, params.n)) + } + + #[test] + fn action_composes_state_and_query() { + let service = RouterService::builder() + .with_state(Arc::new(AppState { + greeting: "hi".to_owned(), + })) + .get("/h", handler) + .build(); + + let request = request_builder() + .method(Method::GET) + .uri("/h?n=5") + .body(Body::empty()) + .expect("request"); + + let response = block_on(service.oneshot(request)).expect("response"); + assert_eq!(response.status(), StatusCode::OK); + assert_eq!(response.body().as_bytes().expect("buffered"), b"hi:5"); + } +} +``` + +- [ ] **Step 3: Run the integration test** + +Run: `cargo test -p edgezero-macros --test action_state 2>&1 | tail -20` +Expected: PASS — `action_composes_state_and_query`. (This simultaneously proves the macro needs no change: `State` is dispatched by the same generic `FromRequest` line as `Query`.) + +- [ ] **Step 4: Add the docs section** + +Append to `docs/guide/handlers.md` a new section (place it after the existing extractor documentation, before any "Next steps"/footer): + +```markdown +## Sharing app state + +Request-derived extractors (`Json`, `Query`, `Path`, …) cover per-request data. +For app-owned state that outlives a single request — a settings object, a +connection registry, an orchestrator — register it once on the router and read +it back with the `State` extractor. + +Register the value with `RouterBuilder::with_state`. It is cloned into every +request's extensions before dispatch, so `T` must be `Clone + Send + Sync + +'static` — typically an `Arc`, where the clone is a cheap refcount +bump: + +```rust +use std::sync::Arc; +use edgezero_core::extractor::State; +use edgezero_core::router::RouterService; + +#[derive(Clone)] +struct AppState { + greeting: String, +} + +let state = Arc::new(AppState { greeting: "hello".into() }); + +let service = RouterService::builder() + .with_state(Arc::clone(&state)) + .get("/greet", greet) + .build(); +``` + +Read it in any `#[action]` handler by adding a `State` argument — it composes +with the other extractors: + +```rust +use edgezero_core::{action, error::EdgeError}; +use edgezero_core::extractor::{Query, State}; +use std::sync::Arc; + +#[action] +async fn greet( + State(state): State>, +) -> Result { + Ok(state.greeting.clone()) +} +``` + +Register different types independently (`with_state(a).with_state(b)`); each is +resolved by its own type. Registering the same `T` twice is last-write-wins. If +a handler asks for a `State` that was never registered, extraction fails with +a `500` — register it before `build()`. +``` + +- [ ] **Step 5: Full verification** + +Run: `cargo test --workspace --all-targets 2>&1 | tail -20` +Expected: PASS. + +Run: `cargo fmt --all -- --check && cargo clippy --workspace --all-targets --all-features -- -D warnings 2>&1 | tail -20` +Expected: formatted; clippy clean. + +Run: `cargo check --workspace --all-targets --features "fastly cloudflare spin" 2>&1 | tail -5 && cargo check -p edgezero-adapter-spin --target wasm32-wasip2 --features spin 2>&1 | tail -5` +Expected: both succeed (proves the core change is WASM-clean across adapters). + +- [ ] **Step 6: Commit** + +```bash +git add crates/edgezero-macros/tests/action_state.rs crates/edgezero-macros/Cargo.toml docs/guide/handlers.md +git commit -m "test(macros): prove #[action] composes State; docs: sharing app state" +``` + +--- + +## Acceptance criteria + +1. `cargo fmt` / clippy clean across `edgezero-core`, `edgezero-macros`, all adapters. +2. New unit tests (Task 1: 3), router tests (Task 2: 4), and the integration test (Task 3: 1) pass. +3. `cargo test --workspace --all-targets` green; PR #300's introspection tests still pass (proves `with_state` is additive to the injection mechanism). +4. WASM checks (`fastly cloudflare spin`; spin `wasm32-wasip2`) succeed. +5. Rustdoc on `State`, `with_state`, and the `docs/guide/handlers.md` section merged. + +## Self-review notes (mapping to spec §3) + +- §3.1 `State` extractor + `Deref`/`into_inner` → Task 1. +- §3.2 router plumbing (`state_inserters` field, `with_state`, dispatch insertion) → Task 2, mirroring PR #300's `manifest_json` column exactly. +- §3.3 naming → `State` only (no `Extension` alias), per locked decision. +- §3.4 tests: resolves registered / 500 unregistered / Deref (Task 1); handler sees value / two `T`s coexist / last-write-wins (Task 2); `#[action]` composition (Task 3); concurrency/no-bleed (Task 2). +- §3.5 docs: `docs/guide/handlers.md` + rustdoc → Task 3. +- §8 corrections folded in: facade `crate::http::Extensions` (not bare `http::Extensions`); no `lib.rs` re-export; `state_inserters` threaded through `RouterInner` + `RouterService::new` + `build()`. diff --git a/docs/superpowers/plans/2026-07-02-introspection-routes.md b/docs/superpowers/plans/2026-07-02-introspection-routes.md new file mode 100644 index 00000000..50913430 --- /dev/null +++ b/docs/superpowers/plans/2026-07-02-introspection-routes.md @@ -0,0 +1,1440 @@ +# 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. + +> ## ⚠️ EXECUTION STATUS — READ FIRST +> +> **Tasks 1–9 below are COMPLETE and committed.** Do NOT re-implement them. +> +> **Tasks 2 and 3 describe the ORIGINAL architecture — UNCONDITIONAL per-request +> injection with handlers reading `ctx.introspection()` directly. That approach +> was SUPERSEDED.** They are retained only as the historical record of what was +> committed and then evolved. Do NOT implement Tasks 2/3 "as written"; the +> committed code has since been changed by the Addendum. +> +> **The ACTIVE, authoritative implementation path is the "Addendum (2026-07-02)" +> at the end of this document** — opt-in, per-route **gated** injection driven by +> `#[action(manifest|routes)]`, with typed extractors for access. It matches the +> rewritten spec. When the Addendum conflicts with the main body, the Addendum +> governs. See the spec's "Design evolution" section for the full path. + +**Goal:** Add three reusable core introspection handlers — `edgezero_core::introspection::{manifest, config, routes}` — that any app binds via `[[triggers.http]]`, default-mounted at `/_/{manifest,config,routes}`. + +**Architecture (final — see Addendum):** `Manifest` gains `Serialize`; the `app!` macro bakes it to JSON via `RouterService::builder().with_manifest_json(...)`. Handlers opt into introspection data with an atomic `#[action(manifest)]` / `#[action(routes)]` parameter, which expands them to capability-carrying handler structs whose `DynHandler::introspection_needs()` returns an `IntrospectionNeeds { manifest, routes }`; `add_route` reads it onto the `RouteEntry`; `RouterInner::dispatch` injects each payload **independently and only for routes that asked** — `ManifestJson` iff `needs.manifest`, `RouteTable` iff `needs.routes` (no shared bundle). The `ManifestJson`/`RouteTable` extractors are themselves the injected payloads and clone their own type back out via a `pub(crate) extension::()` accessor; `config` uses the default config store (no injection). `app!` and `edgezero.toml` never learn about introspection. The legacy `enable_route_listing` machinery and `/__edgezero/routes` are removed. + +**Tech Stack:** Rust 1.95 (edition 2021), `serde`/`serde_json`, `matchit` routing, `#[action]`/`app!` proc-macros, `futures::executor::block_on` for tests. WASM-first: no Tokio, no runtime-specific deps in core. + +## 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"); + // 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"); +} +``` + +(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** + +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`** + +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). 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`. +/// 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 { + #[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() +} +``` + +- [ ] **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()) + } +} +``` + +- [ ] **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: + +```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_` (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. + +- [ ] **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.) + +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 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** + +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 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). + +- [ ] **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; +// 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, +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::config_store::{ConfigStore, ConfigStoreError, ConfigStoreHandle}; + use crate::http::{request_builder, Method}; + 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"))), + } + } + } + + // 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( + [( + "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(|e| e["method"] == "GET" && e["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); + } +} +``` + +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** + +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: 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** + +`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** + +```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. 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: 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). + 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_eq!(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 = resp.into_body().to_json().unwrap(); + assert_eq!(config_body["api_token"], "demo_api_token"); + assert_eq!(config_body["greeting"], "hi"); +} +``` + +(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** + +`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** + +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) +- Modify: `docs/guide/roadmap.md:16` ("route listing + body-mode behavior") + +**Interfaces:** none (documentation only). + +- [ ] **Step 1: Locate the references** + +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** + +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 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\|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** + +```bash +git add docs/guide/routing.md docs/guide/roadmap.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) + +**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 (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 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"` +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, 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, 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 (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 (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. + +**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): ACTIVE PATH — fully-atomic `#[action(manifest|routes)]` opt-in + +**This supersedes Tasks 2 & 3.** Task 9 (extractors + handler refactor) is +committed (`0feb194`). The tasks below add **fully-atomic, per-capability gated +injection** matching the rewritten spec: the handler declares exactly which data +it needs; the router injects each payload independently, only for routes that +asked. No `IntrospectionData` bundle, no `ctx.introspection()`, no +`needs_introspection` bool, no `app!`/`edgezero.toml` change. + +**Verified contract (from investigation):** +- `DynHandler: Send + Sync { fn call(&self, RequestContext) -> HandlerFuture; }`, blanket-impl'd for `F: Fn(RequestContext) -> Fut`. Object-safe with an added defaulted method. `HandlerFuture = Pin> + 'static>>` (no `Send`). +- `http::Extensions` requires inserted types be `Clone + Send + Sync + 'static`. +- Handlers run only via `DynHandler::call`; only `manifest`/`routes` become structs; neither is called directly → zero blast radius. + +Order is chosen so **every commit compiles green** (the gating swap lands last, +after the handlers are opted in while unconditional injection is still active). + +### Task 10a: `IntrospectionNeeds` + `DynHandler::introspection_needs` (edgezero-core/src/handler.rs) + +- [ ] **Step 1 — add the value type + defaulted method** (additive; compiles green): +```rust +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub struct IntrospectionNeeds { + pub manifest: bool, + pub routes: bool, +} + +impl IntrospectionNeeds { + #[must_use] + pub fn any(self) -> bool { self.manifest || self.routes } +} + +pub trait DynHandler: Send + Sync { + fn call(&self, ctx: RequestContext) -> HandlerFuture; + + /// Introspection payloads a route bound to this handler needs injected. + /// Default none; `#[action(manifest|routes)]` handlers override. + fn introspection_needs(&self) -> IntrospectionNeeds { + IntrospectionNeeds::default() + } +} +``` + The `Fn` blanket impl needs no change. +- [ ] **Step 2 — test that a plain fn/closure handler reports the default.** Add to `handler.rs`'s `#[cfg(test)]` (the blanket `impl DynHandler for F: Fn(...)` must yield all-false): +```rust +#[test] +fn fn_handler_reports_default_introspection_needs() { + // A plain closure handler uses the blanket DynHandler impl. + let handler = |_ctx: crate::context::RequestContext| async { + Ok::<&'static str, crate::error::EdgeError>("ok") + }; + assert_eq!( + crate::handler::DynHandler::introspection_needs(&handler), + IntrospectionNeeds::default() + ); + assert!(!IntrospectionNeeds::default().any()); +} +``` + (Match the crate's test imports; `&str: IntoResponse` satisfies the blanket bound.) +- [ ] **Step 3 — verify (repo rule: `cargo test` after code changes):** `cargo test -p edgezero-core` (all pass, incl. the new test); `cargo fmt -p edgezero-core`; `cargo clippy -p edgezero-core --all-targets -- -D warnings`. +- [ ] **Step 4 — commit:** `Add IntrospectionNeeds + DynHandler::introspection_needs` + +### Task 10b: `#[action(manifest|routes)]` param parser + struct codegen (edgezero-macros/src/action.rs) + +- [ ] **Step 1 (RED) — macro unit tests.** Replace `rejects_attribute_arguments` with `rejects_unknown_param` (`#[action(bogus)]` → output contains `unknown #[action] parameter`); add `manifest_param_emits_struct` (`#[action(manifest)]` → output contains `struct` + `DynHandler` + `introspection_needs`); keep `wraps_async_function`. Run `cargo test -p edgezero-macros` → the two new/changed tests FAIL. +- [ ] **Step 2 — parse params** (replace the `!attr.is_empty()` rejection): +```rust +use syn::parse::Parser as _; +use syn::punctuated::Punctuated; + +let params: Punctuated = if attr.is_empty() { + Punctuated::new() +} else { + match Punctuated::::parse_terminated.parse2(attr.clone()) { + Ok(p) => p, + Err(err) => return err.to_compile_error(), + } +}; +let mut manifest_cap = false; +let mut routes_cap = false; +for param in ¶ms { + if param == "manifest" { manifest_cap = true; } + else if param == "routes" { routes_cap = true; } + else { + return syn::Error::new(param.span(), + format!("unknown #[action] parameter `{param}`; supported: manifest, routes")) + .to_compile_error(); + } +} +let is_struct = manifest_cap || routes_cap; +``` +- [ ] **Step 3 — branch codegen.** When `!is_struct`: emit exactly today's fn form (unchanged). When `is_struct`: +```rust +quote! { + #inner_fn + + #(#attrs)* + #[allow(non_camel_case_types)] + #vis struct #ident; + + impl ::edgezero_core::handler::DynHandler for #ident { + #[inline] + fn call(&self, __ctx: ::edgezero_core::context::RequestContext) + -> ::edgezero_core::http::HandlerFuture { + ::std::boxed::Box::pin(async move { + #(#extract_stmts)* + let result = #inner_ident(#(#arg_idents),*).await; + ::edgezero_core::responder::Responder::respond(result) + }) + } + #[inline] + fn introspection_needs(&self) -> ::edgezero_core::handler::IntrospectionNeeds { + ::edgezero_core::handler::IntrospectionNeeds { manifest: #manifest_cap, routes: #routes_cap } + } + } +} +``` + (`#manifest_cap`/`#routes_cap` interpolate as `true`/`false` bool literals.) +- [ ] **Step 4 (GREEN):** `cargo test -p edgezero-macros`; `cargo fmt --all`; `cargo clippy -p edgezero-macros --all-targets -- -D warnings`. +- [ ] **Step 5 — commit:** `#[action]: accept atomic (manifest|routes) params; emit capability-carrying handler struct` + +### Task 10c: opt the handlers in (edgezero-core/src/introspection.rs) — stays green under the still-unconditional path + +- [ ] **Step 1 — opt in.** `manifest` → `#[action(manifest)]`; `routes` → `#[action(routes)]`; `config` stays `#[action]`. (The extractors still read `ctx.introspection()` and injection is still unconditional at this point, so behavior is unchanged — the handlers merely become structs reporting `introspection_needs`, which the router does not yet consult.) +- [ ] **Step 2 — verify:** `cargo test -p edgezero-core introspection` (all pass unchanged); `cargo fmt`; `cargo clippy -p edgezero-core --all-targets -- -D warnings`. +- [ ] **Step 3 — commit:** `Opt manifest/routes into introspection via #[action(manifest|routes)]` + +### Task 10d: the atomic gating swap (edgezero-core/src/{router,context,introspection}.rs) — all in one commit so it compiles + +- [ ] **Step 1 (RED) — router tests that PROVE per-capability atomicity.** The + key guarantee (spec §Key Decision 6): a route injects **exactly** the payloads + its handler asked for — `manifest`-only → `ManifestJson` present AND `RouteTable` + absent; `routes`-only → the mirror; plain → neither. A single-payload probe + would let an over-injecting implementation pass, so the probe records BOTH + payloads' presence and each test asserts the exact `(manifest_present, + routes_present)` pair. The probe carries its own `IntrospectionNeeds` so one + type covers all cases, and builds its response with `response_builder` (no + `IntoResponse` import): +```rust +struct CapProbe { + seen: std::sync::Arc>>, + needs: crate::handler::IntrospectionNeeds, +} +impl crate::handler::DynHandler for CapProbe { + fn call(&self, ctx: RequestContext) -> crate::http::HandlerFuture { + let seen = std::sync::Arc::clone(&self.seen); + Box::pin(async move { + *seen.lock().unwrap() = Some(( + ctx.extension::().is_some(), + ctx.extension::().is_some(), + )); + crate::http::response_builder() + .status(crate::http::StatusCode::OK) + .body(crate::body::Body::empty()) + .map_err(EdgeError::internal) + }) + } + fn introspection_needs(&self) -> crate::handler::IntrospectionNeeds { + self.needs + } +} +``` + Three explicit atomicity tests (each builds a router, `oneshot`s a GET, then + reads the cell). Use `IntrospectionNeeds { manifest, routes }` literally: + - `manifest_route_injects_only_manifest`: probe `needs { manifest: true, routes: false }`, router `.with_manifest_json("{}").get("/", probe)` → cell == `Some((true, false))`. + - `routes_route_injects_only_routes`: probe `needs { manifest: false, routes: true }`, router `.get("/", probe)` (no `with_manifest_json` needed — `RouteTable` comes from the always-present route index) → cell == `Some((false, true))`. + - `plain_route_injects_neither`: probe `needs IntrospectionNeeds::default()` (or a plain closure), `.with_manifest_json("{}").get("/", probe)` → cell == `Some((false, false))` (manifest is available but NOT injected because the route didn't ask). + Plus keep a **middleware** test: a `manifest`-flagged route + a probe middleware asserting `ctx.extension::().is_some()` before the handler runs (injection precedes the middleware chain). + Run (single positional filter per command — Cargo takes ONE): + `cargo test -p edgezero-core router` (fails: `introspection_needs`/`extension`/gating absent). +- [ ] **Step 2 — `RouteEntry` flag.** Add `introspection_needs: IntrospectionNeeds` (`Copy`); copy it in the manual `Clone`/`clone_from`. +- [ ] **Step 3 — read it in `add_route`:** +```rust +let boxed = handler.into_handler(); +let introspection_needs = boxed.introspection_needs(); +router.insert(path, RouteEntry { handler: boxed, introspection_needs }) + .unwrap_or_else(|err| panic!("duplicate route definition for {path}: {err}")); +``` +- [ ] **Step 4 — remove the bundle; per-capability inject in `dispatch`.** Delete the `IntrospectionData` struct and the unconditional insert at the top of `dispatch`. `RouterInner` keeps `manifest_json: Option>` + `route_index`. Inside `RouteMatch::Found(entry, params)`: +```rust +let needs = entry.introspection_needs; +let mut request = request; +if needs.manifest { + if let Some(json) = &self.manifest_json { + request.extensions_mut().insert(crate::introspection::ManifestJson(Arc::clone(json))); + } +} +if needs.routes { + request.extensions_mut().insert(crate::introspection::RouteTable(Arc::clone(&self.route_index))); +} +let ctx = RequestContext::new(request, params); +``` +- [ ] **Step 5 — context accessor.** In `context.rs`, remove `introspection()`; add: +```rust +pub(crate) fn extension(&self) -> Option +where + T: Clone + Send + Sync + 'static, +{ + self.request.extensions().get::().cloned() +} +``` +- [ ] **Step 6 — extractors as payloads.** In `introspection.rs`: add `#[derive(Clone)]` to `ManifestJson` and `RouteTable`; rewrite their `from_request` to clone their own type out of the request: +```rust +async fn from_request(ctx: &RequestContext) -> Result { + ctx.extension::() // (RouteTable in the other impl) + .ok_or_else(|| EdgeError::internal(anyhow::anyhow!("manifest introspection data not available"))) +} +``` +- [ ] **Step 7 (GREEN):** `cargo test -p edgezero-core router`; `cargo test -p edgezero-core introspection`; `cargo test -p edgezero-core`; `cargo fmt`; `cargo clippy -p edgezero-core --all-targets -- -D warnings`. Confirm `manifest_without_baked_json_is_500` still holds (route opted into `manifest`, but `manifest_json` is `None` → nothing injected → extractor 500). +- [ ] **Step 8 — commit:** `Gate introspection injection per capability via IntrospectionNeeds` + +### Task 11: full verification + whole-branch review + +Run each on its own line (single positional filter per `cargo test`): +- [ ] `cargo fmt --all -- --check` +- [ ] `cargo clippy --workspace --all-targets --all-features -- -D warnings` +- [ ] `cargo test --workspace --all-targets` +- [ ] `cd examples/app-demo && cargo test --workspace --all-targets` (other handlers unchanged; `introspection_routes_are_wired` still passes: `manifest`/`routes` opted in, `config` via store) +- [ ] `cargo test -p edgezero-cli --test generated_project_builds -- --ignored` (template handlers are plain `#[action]` → still fns) +- [ ] `cargo run -q --bin check_no_nested_app_config --features nested-app-config-check -- examples/app-demo crates/edgezero-cli/src/templates` +- [ ] `cargo check --workspace --all-targets --features "fastly cloudflare spin"` +- [ ] `cargo check -p edgezero-adapter-spin --target wasm32-wasip2 --features spin` +- [ ] Whole-branch review of the addendum commits, then push to #300. diff --git a/docs/superpowers/specs/2026-07-01-introspection-routes-design.md b/docs/superpowers/specs/2026-07-01-introspection-routes-design.md new file mode 100644 index 00000000..92e9d9f4 --- /dev/null +++ b/docs/superpowers/specs/2026-07-01-introspection-routes-design.md @@ -0,0 +1,442 @@ +# Design: Pluggable Introspection Routes (manifest / config / routes) + +**Date:** 2026-07-01 (architecture finalized 2026-07-02) +**Status:** Approved — implementation in progress +**Scope:** `edgezero-core`, `edgezero-macros`, `examples/app-demo`, `edgezero-cli` templates + +> **Note on history.** This spec describes the **final** architecture only: +> **opt-in, per-route, per-capability gated injection driven by +> `#[action(manifest|routes)]`, with typed extractors for access.** Earlier drafts +> injected data on every request (or via a single blanket gate / bundle); those +> were superseded. The "Design evolution" section at the end records the path. +> This document governs. + +## Summary + +Three reusable, framework-supplied HTTP handlers let any EdgeZero app expose its +own metadata at runtime: + +| Handler path | Emits | +| ---------------------------------------- | ------------------------------------------------------- | +| `edgezero_core::introspection::manifest` | The full manifest as JSON (baked at compile time) | +| `edgezero_core::introspection::config` | The default config-store envelope `.data` (secret-safe) | +| `edgezero_core::introspection::routes` | `[{ "method", "path" }]` from the live route index | + +Apps bind them like any other route via `[[triggers.http]]`, choosing their own +paths. There is **no** `[introspection]` manifest section and **no** dedicated +builder API. `app-demo` and every generated app ship the three routes pre-wired +under `/_/{manifest,config,routes}`, as plain trigger rows a developer +can edit or delete. + +This design also **removes** the legacy built-in route-listing machinery +(`enable_route_listing`, `enable_route_listing_at`, `DEFAULT_ROUTE_LISTING_PATH`, +`/__edgezero/routes`, `RouteListingEntry`, `build_listing_response`) in favor of +the 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 needs the + app's concrete config type. +- The only built-in introspection is a route listing at `/__edgezero/routes`, + wired through a bespoke builder method rather than the normal routing path. + +We want a single, consistent, "bind it yourself" mechanism for all three — that +costs nothing for the ~100% of requests that are not introspection calls, and +that only provisions the *specific* data each handler asks for. + +## Key Decisions + +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 (`with_manifest_json`). +2. **Config output** — emit the raw config-store `BlobEnvelope.data`. Generic + (core needs no knowledge of the app's typed config `C`) and secret-safe: + secret fields stay as unresolved key-name references; resolution happens only + inside the typed `AppConfig` extractor. +3. **Wiring** — plain `[[triggers.http]]` bindings to stable core handler paths. + No `[introspection]` section, no builder enable-API, and the `app!` macro does + **not** inspect handler paths. +4. **Paths** — per-app namespace `/_/{manifest,config,routes}` (single + underscore); just the default paths written into the templates. +5. **Access via typed extractors that are also the injected payloads.** Handlers + that need data declare it in their signature, matching the + `Json`/`Path`/`AppConfig` idiom: + - `ManifestJson(pub Arc)` — the baked manifest JSON (used by `manifest`). + - `RouteTable(pub Arc<[RouteInfo]>)` — the live route index (used by `routes`). + Each derives `Clone`, is what the router injects into the request, and its + `FromRequest::from_request` clones its own type back out (`500` if absent). + `config` takes `RequestContext` and uses neither. +6. **Opt-in, per-capability gated injection driven by an atomic `#[action(...)]` + parameter.** The router injects each capability's payload **only** for routes + whose handler opted into that specific capability — never for general traffic, + and never more than the handler asked for. The opt-in is atomic all the way + down: + - `#[action(manifest)]` / `#[action(routes)]` / `#[action(manifest, routes)]` + declare exactly which data the handler consumes. `#[action]` (no params) is + unchanged. + - Each param maps 1:1 to a field of `IntrospectionNeeds { manifest, routes }`, + reported by the handler via `DynHandler::introspection_needs()`. + - `dispatch` injects `ManifestJson` iff `needs.manifest`, and `RouteTable` iff + `needs.routes`. A `manifest`-only route never carries the route table, and + vice versa. + No process-global state, no unstable specialization, no bundle struct, and no + `app!`/`edgezero.toml` change. +7. **Remove route listing** — delete the `enable_route_listing` machinery and + `/__edgezero/routes`. + +## Architecture + +### Data flow + +``` +compile time runtime +------------ ------- +edgezero.toml + │ app!() parses Manifest + │ serde_json::to_string(&manifest) + ▼ +build_router() + builder.with_manifest_json("{…}") RouterService::oneshot(req) + builder.get(path, introspection::routes) └─ RouterInner::dispatch(req) + │ │ find_route(req) → RouteEntry + ▼ │ needs = entry.introspection_needs +RouterInner { │ if needs.manifest && manifest_json: + manifest_json: Option>, ──────┼────► insert ManifestJson(clone) + route_index: Arc<[RouteInfo]>, ──────┼────► if needs.routes: insert RouteTable(clone) +} ▼ + handler runs; extractor clones its + own type out of the request: + manifest → ManifestJson(json) + routes → RouteTable(index) + config → default config store (no injection) +``` + +- **The opt-in is on the handler.** `#[action(manifest)]` / `#[action(routes)]` + expand the handler to a capability-carrying struct whose + `introspection_needs()` sets the matching field(s). `add_route` reads that and + stores `IntrospectionNeeds` on the `RouteEntry`. +- **manifest**: parsed at compile time, re-serialized to JSON by the macro, held + as `Option>` on the router, injected (as `ManifestJson`) only for + routes that asked for `manifest`, returned verbatim. No runtime TOML dependency. +- **routes**: injected (as `RouteTable`) only for routes that asked for `routes`; + projected at request time to `[{method, path}]`. +- **config**: read from the default config store; needs no injection. + +### Component 1 — `Manifest: Serialize` (`edgezero-core/src/manifest.rs`) *(done)* + +`Serialize` added to `Manifest` and every nested struct that appears in output. +The enums `HttpMethod`/`BodyMode`/`LogLevel` get **manual** `Serialize` impls +mirroring their manual `Deserialize` (wire strings `"GET"`/`"buffered"`/`"info"`, +and `body_mode` serializes as the renamed key `body-mode`). Secret **values** are +never serialized: `environment.secrets` entries omit `value` via a +`serialize_with` redactor; `environment.variables` keep it. Internal fields +(`root`, `logging_resolved`) stay `#[serde(skip)]`. + +### Component 2 — `#[action]` atomic opt-in + capability-carrying handlers (`edgezero-macros/src/action.rs`) + +`#[action]` gains an **optional atomic parameter list** naming the introspection +data the handler consumes: + +- **`#[action]`** (no params) — unchanged. Expands to a handler **fn**; via the + existing `Fn` blanket `impl DynHandler` its `introspection_needs()` returns the + default (all-false). +- **`#[action(manifest)]`, `#[action(routes)]`, `#[action(manifest, routes)]`** — + expand the handler to a **unit struct** with its own `impl DynHandler`, whose + `introspection_needs()` returns an `IntrospectionNeeds` with exactly the named + fields set. (A fn can't carry per-item data past type-erasure into + `Arc`; a struct can. Only opt-in handlers become structs; every + other handler stays a fn, untouched.) + +The macro parses the params as a comma-separated ident list, validates each +against the known set `{ manifest, routes }`, and emits `compile_error!` on an +unknown ident. The set is extensible (future atomic capabilities are new idents ++ new `IntrospectionNeeds` fields). + +Generated struct (paths absolute, matching the existing macro): + +```rust +#inner_fn // the user's body, unchanged + +#(#attrs)* +#[allow(non_camel_case_types)] +#vis struct #ident; + +impl ::edgezero_core::handler::DynHandler for #ident { + #[inline] + fn call(&self, __ctx: ::edgezero_core::context::RequestContext) + -> ::edgezero_core::http::HandlerFuture { + ::std::boxed::Box::pin(async move { + #(#extract_stmts)* // ::from_request(&__ctx).await? + let result = #inner_ident(#(#arg_idents),*).await; + ::edgezero_core::responder::Responder::respond(result) + }) + } + #[inline] + fn introspection_needs(&self) -> ::edgezero_core::handler::IntrospectionNeeds { + ::edgezero_core::handler::IntrospectionNeeds { manifest: #manifest_lit, routes: #routes_lit } + } +} +``` + +where `#manifest_lit` / `#routes_lit` are the `bool` literals derived from the +parsed params. + +### Component 3 — Router gating (`edgezero-core/src/{handler,router,context}.rs`) + +**`handler.rs`** — the capability value type + the reporting method: + +```rust +/// Which introspection payloads a route's handler needs injected at dispatch. +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub struct IntrospectionNeeds { + pub manifest: bool, + pub routes: bool, +} + +impl IntrospectionNeeds { + #[must_use] + pub fn any(self) -> bool { + self.manifest || self.routes + } +} + +pub trait DynHandler: Send + Sync { + fn call(&self, ctx: RequestContext) -> HandlerFuture; + + /// Introspection payloads a route bound to this handler needs. Default is + /// none; `#[action(manifest|routes)]` handlers override it. + fn introspection_needs(&self) -> IntrospectionNeeds { + IntrospectionNeeds::default() + } +} +``` + +The `Fn` blanket `impl DynHandler` needs no change (inherits the default). + +**`router.rs`:** + +- `RouteEntry` gains `introspection_needs: IntrospectionNeeds` (`Copy`; copied in + the manual `Clone`/`clone_from`). +- `add_route` reads it from the boxed handler at registration: + ```rust + let boxed = handler.into_handler(); + let introspection_needs = boxed.introspection_needs(); + router.insert(path, RouteEntry { handler: boxed, introspection_needs }); + ``` +- `RouterInner` keeps `manifest_json: Option>` and + `route_index: Arc<[RouteInfo]>` (no bundle struct). + `RouterBuilder::with_manifest_json(impl Into>)` (set by the `app!` + macro) supplies the JSON. +- `dispatch` injects per capability, after matching: + ```rust + match self.find_route(&method, &path) { + RouteMatch::Found(entry, params) => { + let needs = entry.introspection_needs; + let mut request = request; + if needs.manifest { + if let Some(json) = &self.manifest_json { + request.extensions_mut() + .insert(crate::introspection::ManifestJson(Arc::clone(json))); + } + } + if needs.routes { + request.extensions_mut() + .insert(crate::introspection::RouteTable(Arc::clone(&self.route_index))); + } + let ctx = RequestContext::new(request, params); + let next = Next::new(&self.middlewares, entry.handler.as_ref()); + next.run(ctx).await + } + // MethodNotAllowed / NotFound unchanged + } + ``` + +**`context.rs`** — a single `pub(crate)` accessor the extractors share (there is +no public `introspection()` accessor and no `IntrospectionData` type): + +```rust +pub(crate) fn extension(&self) -> Option +where + T: Clone + Send + Sync + 'static, +{ + self.request.extensions().get::().cloned() +} +``` + +### Component 4 — `edgezero_core::introspection` module (`edgezero-core/src/introspection.rs`) + +The extractors (which are also the injected payloads) and three handlers: + +```rust +#[derive(Clone)] +pub struct ManifestJson(pub Arc); + +#[async_trait(?Send)] +impl FromRequest for ManifestJson { + async fn from_request(ctx: &RequestContext) -> Result { + ctx.extension::() + .ok_or_else(|| EdgeError::internal(anyhow::anyhow!("manifest introspection data not available"))) + } +} + +#[derive(Clone)] +pub struct RouteTable(pub Arc<[RouteInfo]>); + +#[async_trait(?Send)] +impl FromRequest for RouteTable { + async fn from_request(ctx: &RequestContext) -> Result { + ctx.extension::() + .ok_or_else(|| EdgeError::internal(anyhow::anyhow!("route-table introspection data not available"))) + } +} + +#[action(manifest)] +pub async fn manifest(ManifestJson(json): ManifestJson) -> Result { + json_response(StatusCode::OK, Body::text(json.to_string())) +} + +#[action(routes)] +pub async fn routes(RouteTable(table): RouteTable) -> Result { + let views: Vec = table.iter() + .map(|r| RouteView { method: r.method().as_str().to_owned(), path: r.path().to_owned() }) + .collect(); + json_response(StatusCode::OK, Body::json(&views).map_err(EdgeError::internal)?) +} + +#[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"))?; + let raw = binding.handle.get(&binding.default_key).await + .map_err(EdgeError::from)? // preserves 503/400/500 + .ok_or_else(|| EdgeError::not_found("no config blob in default store"))?; + let envelope: BlobEnvelope = serde_json::from_str(&raw) + .map_err(|e| EdgeError::internal(anyhow::anyhow!("envelope parse failed: {e}")))?; + envelope.verify().map_err(|e| EdgeError::internal(anyhow::anyhow!("envelope verification failed: {e}")))?; + json_response(StatusCode::OK, Body::json(&envelope.into_data()).map_err(EdgeError::internal)?) +} +``` + +`RouteView { method: String, path: String }` (derives `Serialize`) is the JSON +shape for `routes`. `Response` is imported from `crate::http`. Because `dispatch` +constructs `ManifestJson`/`RouteTable`, `router.rs` imports them from +`crate::introspection` (a same-crate module reference — no crate-level cycle). + +### Component 5 — `app!` macro (`edgezero-macros/src/app.rs`) *(done, unchanged by the gating work)* + +- Serialize the parsed manifest (`serde_json::to_string`, `compile_error!` on + failure) and emit `builder = builder.with_manifest_json()` as the first + builder mutation in `build_router()`. +- Emit `const _: &[u8] = include_bytes!();` so Cargo treats + `edgezero.toml` as a build input. +- Route registration is ordinary `builder.get(path, handler)` / `route(...)`; the + macro does **not** inspect handler paths. Gating comes entirely from the + handler's `introspection_needs()`. + +### Component 6 — Removals *(done)* + +Deleted from `router.rs`: `DEFAULT_ROUTE_LISTING_PATH`, `enable_route_listing`, +`enable_route_listing_at`, the `route_listing_path` field + listing branch in +`build()`, `build_listing_response`, `RouteListingEntry`, and all `route_listing_*` +tests. No workspace references to `/__edgezero/routes` remain. + +### Component 7 — Templates + app-demo (default bindings) *(done)* + +Three `[[triggers.http]]` rows in `examples/app-demo/edgezero.toml` and in +`crates/edgezero-cli/src/templates/root/edgezero.toml.hbs` (using `/_{{name}}/…` +and `{{{adapter_list}}}`), bound to `edgezero_core::introspection::{manifest,config,routes}`. +No template handler code is generated — the handlers live in core. + +## Interfaces (summary) + +| Unit | Public surface | +| ----------------------------- | ----------------------------------------------------------------- | +| `IntrospectionNeeds` | `{ manifest: bool, routes: bool }`, `Copy`, `Default`, `any()` | +| `DynHandler` | `fn introspection_needs(&self) -> IntrospectionNeeds { default }` | +| `RouterBuilder` | `with_manifest_json(impl Into>)` | +| `RequestContext` | `pub(crate) extension::() -> Option` (no public accessor) | +| `introspection::ManifestJson` | `pub Arc`; `Clone`; `FromRequest` | +| `introspection::RouteTable` | `pub Arc<[RouteInfo]>`; `Clone`; `FromRequest` | +| `introspection::{manifest,routes}` | `#[action(manifest)]` / `#[action(routes)]` GET → JSON | +| `introspection::config` | `#[action]` GET → JSON (default config store) | + +## Error Handling + +- **manifest** / **routes**: the extractor returns `500 internal` if its payload + is absent from the request — i.e. the route did not opt into that capability + (`#[action(manifest)]` / `#[action(routes)]` missing), or, for `manifest`, the + app never called `with_manifest_json` (`manifest_json` is `None`, so `dispatch` + injects nothing). +- **config**: no default config store → `404`; no blob → `404`; `ConfigStoreError` + mapped via `EdgeError::from` (503 unavailable / 400 invalid-key / 500 internal); + malformed or unverifiable envelope → `500`. + +## Testing Strategy + +Colocated `#[cfg(test)]`, `futures::executor::block_on` (no Tokio), no network. + +- **macros**: `#[action]` (no params) still emits a fn; `#[action(manifest)]` + emits a struct impl'ing `DynHandler` with `introspection_needs()` setting + `manifest: true`; `#[action(bogus)]` is a compile error. Plus a + **compile/behavioral** test in core proving `.get("/m", manifest)` accepts the + unit-struct handler value and runs end to end (`oneshot` → 200). +- **handler.rs / router.rs**: default `introspection_needs()` is all-false for fn + handlers; a `manifest`-flagged route injects `ManifestJson` (handler + + middleware can read it) and does **not** inject `RouteTable`; a `routes`-flagged + route is the mirror; a plain route injects neither + (`ctx.extension::().is_none()`). +- **introspection module**: `manifest` returns injected JSON; `routes` returns + `[{method,path}]`; `config` returns envelope `data` and the full status matrix + (200/404/400/503/500×3); `manifest` with no baked JSON → 500. +- **app-demo**: `/_app-demo/{manifest,routes}` → 200 + body shape; `/config` with + a seeded `ConfigRegistry` → 200 + secret-safe key-name. + +## CI Gates + +1. `cargo fmt --all -- --check` +2. `cargo clippy --workspace --all-targets --all-features -- -D warnings` +3. `cargo test --workspace --all-targets` (+ `cd examples/app-demo && cargo test --workspace --all-targets`) +4. `cargo test -p edgezero-cli --test generated_project_builds -- --ignored` +5. `cargo run -q --bin check_no_nested_app_config --features nested-app-config-check -- examples/app-demo crates/edgezero-cli/src/templates` +6. `cargo check --workspace --all-targets --features "fastly cloudflare spin"` +7. `cargo check -p edgezero-adapter-spin --target wasm32-wasip2 --features spin` + +## Constraints & Non-Goals + +- **WASM-first**: no Tokio, no runtime-specific deps. `serde_json` is added only + to `edgezero-macros` (build-time proc-macro crate). +- **No auth/gating of exposure in this iteration**: endpoints are reachable + wherever bound; config output is secret-safe, and `/manifest` emits + `environment.variables[].value` (not secrets) — documented so operators don't + store secrets in `[environment.variables]`. Access control is a follow-up. +- **No process-global state**: `manifest_json` / `route_index` are + per-`RouterService`, so tests and multiple apps in one process stay independent. +- **No `[introspection]` manifest section, no builder enable-API, no `app!` + handler-path inspection** — the opt-in lives on `#[action(...)]`. + +## Design evolution (for reviewers) + +1. First cut: inject a bundle on **every** request; handlers read + `ctx.introspection()`. Rejected — taxes all traffic for rarely-hit endpoints. +2. Considered a process-global (`OnceLock`) source — rejected: one-manifest- + per-process breaks unit tests and adds shared mutable state. +3. Considered `app!` recognizing the `edgezero_core::introspection::` handler + namespace to flag routes — rejected as a fragile string-match hack. +4. Considered a single `DynHandler::needs_introspection() -> bool` + one + `IntrospectionData` bundle — rejected: inconsistent with the atomic + `#[action(manifest|routes)]` params, and it over-provisions (a `manifest`-only + route would carry the route table). +5. **Final:** fully atomic. `#[action(manifest|routes)]` → `IntrospectionNeeds` + (per-capability bools) reported by `DynHandler::introspection_needs()`; the + router injects each capability's payload independently, only for routes that + asked for it. The extractors `ManifestJson`/`RouteTable` are themselves the + injected payloads. No global, no `app!` hack, no bundle, no unstable + specialization; `#[action]` (no params) is 100% unchanged so only + `manifest`/`routes` become structs. diff --git a/docs/superpowers/specs/2026-07-02-edgezero-state-and-nested-secrets-design.md b/docs/superpowers/specs/2026-07-02-edgezero-state-and-nested-secrets-design.md new file mode 100644 index 00000000..0be27549 --- /dev/null +++ b/docs/superpowers/specs/2026-07-02-edgezero-state-and-nested-secrets-design.md @@ -0,0 +1,437 @@ +# EdgeZero — `State` extractor + nested/array `#[secret]` support + +- **Status:** Draft for edgezero maintainer review +- **Date:** 2026-07-02 +- **Author:** trusted-server team (spec), to be implemented by an **edgezero** developer +- **Target repo:** `github.com/stackpop/edgezero` (crates `edgezero-core`, `edgezero-macros`) +- **Consumed by:** trusted-server "move completely to EdgeZero" migration; nothing in trusted-server can start until these two upstream primitives land + +--- + +## 1. Why this spec exists + +trusted-server is migrating fully onto EdgeZero. Two later phases of that migration are **blocked on primitives that EdgeZero does not yet expose**: + +1. **Handlers → extractors** (trusted-server Phase 4) needs a way to pass **app-owned shared state** (`Arc`: `Settings`, `AuctionOrchestrator`, `IntegrationRegistry`) into `#[action]` extractor-style handlers. EdgeZero's extractors are all **request-derived** — there is no `State`/`Extension` extractor and no `RequestContext` accessor for app state. + +2. **Secret externalization** (trusted-server Phase 3) needs `#[derive(AppConfig)]` + the runtime secret walk to resolve `#[secret]` fields that live **nested inside sub-structs and arrays**. Today both are restricted to **top-level scalar `String`** fields. trusted-server's `Settings` is deeply nested (per-integration secret fields, arrays of partners), so the current model cannot express its secrets. The existing `TrustedServerAppConfig` wrapper documents this explicitly with `SECRET_FIELDS = &[]` and a comment that nested/array extraction "needs support tracked separately." + +Both are small, self-contained additions to `edgezero-core` + `edgezero-macros`. They are **independent of each other** and can land in either order or in parallel. + +### Goals + +- Add a `State` extractor (and the router plumbing to populate it) so any EdgeZero app can hand app-owned state to extractor handlers. +- Extend `#[derive(AppConfig)]` and the runtime `secret_walk` to resolve `#[secret]` fields nested in sub-structs and (optionally) arrays, keyed by a **field path** rather than a single top-level name. + +### Non-goals + +- No changes to trusted-server in this phase (that is Phases 1–5 of the umbrella plan). +- No change to the blob-envelope format, canonical-form hashing, or the push/validate CLI flow beyond what nested secret metadata requires. +- No change to the `app!` macro's manifest-driven routing (trusted-server builds its router imperatively via `Hooks::routes()`, so `State` is wired through the `RouterBuilder`, not the manifest). + +--- + +## 2. Current mechanics (verified against `main`) + +### 2.1 Request extensions are the transport for everything per-request + +`RequestContext` wraps an `http`-style `Request`; every per-request handle is read out of `request.extensions()`: + +```rust +// crates/edgezero-core/src/context.rs +pub fn kv_store_default(&self) -> Option { + self.request.extensions().get::().and_then(StoreRegistry::default) +} +``` + +Extractors follow the same shape (`crates/edgezero-core/src/extractor.rs`): + +```rust +#[async_trait(?Send)] +impl FromRequest for Kv { + async fn from_request(ctx: &RequestContext) -> Result { + ctx.request().extensions().get::().cloned().map(Kv) + .ok_or_else(|| EdgeError::internal(anyhow::anyhow!("no kv store configured ..."))) + } +} +``` + +Registries get **inserted into `request.extensions_mut()` by the adapter** just before dispatch (reference: `edgezero-adapter-axum/src/service.rs` → `router.oneshot(core_request)`; `edgezero-adapter-fastly/src/request.rs::dispatch_with_registries`). + +### 2.2 The router owns dispatch and builds the context + +```rust +// crates/edgezero-core/src/router.rs +impl RouterInner { + async fn dispatch(&self, request: Request) -> Result { + match self.find_route(&method, &path) { + RouteMatch::Found(entry, params) => { + let ctx = RequestContext::new(request, params); // <-- request is owned here + let next = Next::new(&self.middlewares, entry.handler.as_ref()); + next.run(ctx).await + } + ... + } + } +} +``` + +`RouterBuilder` (also in `router.rs`) already holds `middlewares`, `routes`, `route_info`. It is the natural owner of app state because **trusted-server builds its router imperatively inside `Hooks::routes()`**, where `Arc` is in scope. Adapters are generic over `A: Hooks` and never see the concrete state type, so state cannot be injected adapter-side. + +### 2.3 `#[derive(AppConfig)]` is top-level + scalar-`String`-only + +```rust +// crates/edgezero-macros/src/app_config.rs +fn is_scalar_string_type(ty: &Type) -> bool { /* accepts bare `String` only */ } +// scan_field() calls enforce_scalar_string_type() -> rejects Option, Vec<_>, nested structs. +// The emitted SECRET_FIELDS is a flat array of top-level Rust field names: +const SECRET_FIELDS: &'static [SecretField] = &[SecretField { name: "api_token", kind: KeyInDefault }]; +``` + +`SecretField` (`crates/edgezero-core/src/app_config.rs`) is `{ kind: SecretKind, name: &'static str }`, where `name` is a single top-level key. + +### 2.4 The runtime `secret_walk` only navigates the top level + +```rust +// crates/edgezero-core/src/extractor.rs +async fn secret_walk(ctx: &RequestContext, data: &mut serde_json::Value) -> Result<(), EdgeError> { + let data_obj = data.as_object_mut().ok_or(...)?; // top-level object only + for field in C::SECRET_FIELDS { + let key_name = data_obj.get(field.name)...; // top-level get by flat name + // ... resolve secret value, then: + data_obj.insert(field.name, resolved_value); // top-level insert + } +} +``` + +Called from `extract_from_handle::()`, which does fetch → `BlobEnvelope::verify()` → `secret_walk` → `serde_path_to_error::deserialize` → `cfg.validate()`. The split between push-time (`validate_excluding_secrets`, values are key names) and runtime (`cfg.validate()`, values resolved) must be preserved. + +--- + +## 3. Workstream A — `State` extractor + +### 3.1 Public API additions + +**`crates/edgezero-core/src/router.rs` — `RouterBuilder`:** + +```rust +impl RouterBuilder { + /// Register a value that is cloned into every request's extensions + /// before dispatch, making it available to the `State` extractor + /// and to `RequestContext`-based handlers. + /// + /// Typically `T = Arc`. Last write wins for a given `T`. + #[must_use] + pub fn with_state(mut self, value: T) -> Self + where + T: Clone + Send + Sync + 'static, + { /* push a type-erased inserter (see 3.2) */ self } +} +``` + +**`crates/edgezero-core/src/extractor.rs` — new extractor:** + +```rust +/// Extractor for app-owned shared state registered via +/// `RouterBuilder::with_state`. Resolves by type from request extensions. +pub struct State(pub T); + +#[async_trait(?Send)] +impl FromRequest for State +where + T: Clone + Send + Sync + 'static, +{ + async fn from_request(ctx: &RequestContext) -> Result { + ctx.request() + .extensions() + .get::() + .cloned() + .map(State) + .ok_or_else(|| EdgeError::internal(anyhow::anyhow!( + "no `State<{}>` registered — call RouterBuilder::with_state(..)", + core::any::type_name::() + ))) + } +} +// + Deref/DerefMut/into_inner to mirror the other extractors. +``` + +Because `State: FromRequest`, it works inside `#[action]` with **no change to `edgezero-macros/src/action.rs`** (the macro already emits `::from_request(&ctx).await?` for every non-`RequestContext` argument). + +Handler ergonomics (trusted-server side, illustrative only): + +```rust +#[action] +pub async fn handle_auction( + State(state): State>, + Json(req): Json, +) -> Result { /* state.settings, state.orchestrator, ... */ } +``` + +### 3.2 Router plumbing + +Add to `RouterBuilder` a list of type-erased inserters and thread them into `RouterInner`: + +```rust +type StateInserter = Arc; + +#[derive(Default)] +pub struct RouterBuilder { + // ... existing fields ... + state_inserters: Vec, +} + +pub fn with_state(mut self, value: T) -> Self +where T: Clone + Send + Sync + 'static { + self.state_inserters.push(Arc::new(move |ext: &mut http::Extensions| { + ext.insert(value.clone()); + })); + self +} +``` + +In `RouterInner::dispatch`, apply inserters to the owned request **before** building the context: + +```rust +RouteMatch::Found(entry, params) => { + let mut request = request; + for inserter in &self.state_inserters { + inserter(request.extensions_mut()); + } + let ctx = RequestContext::new(request, params); + let next = Next::new(&self.middlewares, entry.handler.as_ref()); + next.run(ctx).await +} +``` + +Notes: +- `RouterInner` gains a `state_inserters: Vec` field; `RouterService::new` takes it from the builder. +- Insertion happens **after** the adapter has already inserted the store registries into the same extensions map (different `TypeId`s, no collision). If an app ever registers a `T` that an adapter also inserts, last-write-wins and the router runs last — document this; it is not expected in practice. +- Cost is one `Arc` clone (or one `T::clone`) per registered state per request — negligible for `Arc`. +- The route-listing internal handler and middleware are unaffected. + +### 3.3 Naming decision (needs maintainer sign-off) + +Two reasonable names for the same mechanism: + +- **`State`** — matches the app-state use case and reads naturally at call sites. This is what trusted-server asked for. **Recommended.** +- **`Extension`** — matches axum's *runtime-resolved* semantics exactly (this mechanism is axum's `Extension`, not axum's compile-time-typed `State`). More honest about behavior; more familiar to axum users. + +**Recommendation:** ship `State` + `with_state` as the primary API. Optionally add `Extension` + `with_extension` as thin aliases if the maintainer wants the axum-accurate name available too. Avoid shipping both as first-class with divergent behavior. + +### 3.4 Tests (edgezero) + +- Unit (`extractor.rs`): `State` resolves a registered `Arc`; returns `EdgeError::internal` (500) when unregistered; `Deref` works. +- Unit (`router.rs`): a handler taking `State>` sees the value after `with_state`; two different `T`s coexist; re-registering the same `T` is last-write-wins. +- Integration: `#[action] fn h(State(s): State>, Query(q): Query)` compiles and runs (proves macro composition with an existing extractor). +- Concurrency: two in-flight requests each get an independent clone (no cross-request bleed). + +### 3.5 Docs + +- `docs/guide/handlers.md`: add a "Sharing app state" section showing `RouterBuilder::with_state` + `State`. +- Rustdoc on `State`, `with_state` with the `Arc` example and the last-write-wins note. + +--- + +## 4. Workstream B — nested/array `#[secret]` support + +### 4.1 Problem statement + +`#[secret]` must be expressible on fields **below the root**, e.g.: + +```rust +#[derive(AppConfig, Deserialize, Validate)] +struct Settings { + #[validate(nested)] integrations: IntegrationSettings, + #[validate(nested)] partners: Vec, +} +struct IntegrationSettings { #[validate(nested)] datadome: DataDome } +struct DataDome { #[secret] server_side_key: String } // path: integrations.datadome.server_side_key +struct Partner { #[secret] api_key: String } // path: partners[*].api_key +``` + +Today this fails to compile (`enforce_scalar_string_type` rejects the containing types at the root; the derive never recurses). + +### 4.2 Metadata model: path-qualified secret fields + +Extend `SecretField` (`crates/edgezero-core/src/app_config.rs`) to carry a **path** instead of a single `name`. The path is a sequence of segments; each segment is either a named field or an array wildcard. **Segments are owned** (`Cow<'static, str>` / `Vec<_>`), not `&'static`, and the field carries an `optional` flag — both settled by §8 (cross-crate recursion cannot build `&'static` paths, and the runtime must tell "required-missing → error" from "optional-absent → skip"): + +```rust +pub enum SecretPathSegment { + Field(std::borrow::Cow<'static, str>), // object key (Rust field name, verbatim) + ArrayEach, // every element of an array +} + +pub struct SecretField { + pub kind: SecretKind, + pub path: Vec, // was: name: &'static str + pub optional: bool, // #[secret] on Option +} +``` + +Because paths are owned and must compose across crates, `AppConfigMeta` changes from an associated `const SECRET_FIELDS` to a method `fn secret_fields() -> Vec` (§8 / B-3): a parent prepends its `Field`/`ArrayEach` segment onto each child's `secret_fields()`. + +- A top-level scalar keeps working: its path is `vec![Field("api_token")]` (length 1) — **backward compatible in behavior**, though the struct/trait shape changes (see 4.6 for the compat call-out). +- `store_ref` siblings referenced by `SecretKind::KeyInNamedStore { store_ref_field }` are resolved **relative to the same parent object** as the secret field (i.e. sibling within the innermost containing object). Document this scoping rule explicitly. + +**Array support (B-1) — resolved: implement `ArrayEach` from day one.** The problem statement's own inventory includes an array secret (`partners[*].api_key`), and §8 [B, HIGH] requires an object-only plan *only* if trusted-server's `Settings` audit confirms no secret leaves inside arrays. Absent that confirmation, arrays are in scope from the start; the derive and the runtime walk both handle `ArrayEach` (see the implementation plan `docs/superpowers/plans/2026-07-02-edgezero-nested-secrets.md`). + +### 4.3 Derive changes (`crates/edgezero-macros/src/app_config.rs`) + +Recurse into fields whose type is itself an `AppConfig`-derived struct (or a `Vec<_>`/`[_]` of one), accumulating the path: + +1. Keep the current scan for direct `#[secret]` fields, but emit `path = [Field(name)]` instead of `name`. +2. Add recursion: for a field annotated to recurse (see B-2 below), descend into the referenced type and prefix every `SecretField` it produces with `Field(field_name)` (or `Field(field_name), ArrayEach` for a `Vec`). +3. Preserve all existing compile-time guards (no `#[serde(rename/flatten/skip*)]` on the path, no container `rename_all` when any secret exists) **along the entire path**, since a rename anywhere desyncs the JSON key from the emitted path segment. + +**Open design question (B-2): how does the derive know which fields to recurse into?** The macro sees only syntax, not resolved types, so it cannot know a field's type also derives `AppConfig`. Two options: + +- **(Recommended) Explicit opt-in attribute**, e.g. `#[app_config(nested)]` on the containing field (and `#[app_config(nested)]` on a `Vec` field for array recursion). Mirrors `#[validate(nested)]`, is unambiguous, and keeps the derive purely syntactic. The sub-struct must itself derive `AppConfig` (enforced at runtime via the `AppConfigRoot` marker / a generated const assertion). +- **(Alternative) Type-name heuristic** — recurse into any field whose type path "looks like" a struct. Rejected: brittle, silently wrong for third-party types, and can't see through aliases. + +With explicit opt-in, the derive emits, for each nested field, code that references the sub-struct's own `SECRET_FIELDS` and prefixes the path — so recursion composes without the parent macro needing the child's fields: + +```rust +// sketch of emitted metadata for `integrations: IntegrationSettings` (#[app_config(nested)]) +// concatenate, at const-eval where possible, or via a generated fn: +// prefix [Field("integrations")] onto each of ::SECRET_FIELDS +``` + +> Implementation note: `&'static [SecretField]` concatenation across crates is not trivial at `const` if paths must be `&'static [SecretPathSegment]`. Two viable lowerings: (a) generate a `const` block per struct that hand-builds the full flattened array literal by inlining child segments the macro can see through the opt-in type path; or (b) change `AppConfigMeta` from an associated `const` to an associated `fn secret_fields() -> Vec` (owned, path segments `Vec<...>`), letting recursion build owned vectors at runtime. **Recommendation:** prefer (b) — a `fn secret_fields() -> Cow<'static, [SecretField]>` — because it makes cross-crate recursion straightforward and the walk runs once per request anyway (allocation cost negligible vs. the network fetch). Flag this as **B-3** for maintainer decision, since it changes the `AppConfigMeta` trait shape. + +Also relax `enforce_scalar_string_type` to additionally accept `Option` on `#[secret]` fields (optional secrets are common in real config); an absent/`None` optional secret is skipped by the walk rather than erroring. Keep rejecting non-string scalar types. + +### 4.4 Runtime `secret_walk` changes (`crates/edgezero-core/src/extractor.rs`) + +Replace the top-level-only loop with a **path navigator**: + +```rust +// For each SecretField, walk `data` along field.path: +// Field(name) -> descend into object key `name` +// ArrayEach -> iterate every element of the current array, applying the +// remainder of the path to each +// At the leaf: the string value is a secret KEY NAME; resolve it via the +// existing SecretKind logic (KeyInDefault / KeyInNamedStore / StoreRef) and +// replace in place. `store_ref_field` is looked up in the leaf's PARENT object. +``` + +- Preserve exact error semantics: missing/non-string leaf → `EdgeError::config_out_of_date` with the **dotted path** (e.g. `integrations.datadome.server_side_key`, `partners[3].api_key`) as the field hint. This improves on today's single-name hint. +- `Option` secret that is absent → skip (no error). +- `StoreRef` leaves are still skipped (value is a store id). +- Keep the push/runtime validation split intact (`validate_excluding_secrets` at push; `cfg.validate()` at runtime after resolution). Push-time secret detection also reflects over the new path metadata. + +### 4.5 CLI touchpoints (`edgezero-cli`) + +`run_config_validate_typed` / `run_config_push_typed` / `run_config_diff_typed` reflect over `SECRET_FIELDS` (now paths) to know which fields hold key-names vs. values. Update those reflections to walk paths. `build_config_envelope` is unchanged (it serializes the typed struct verbatim; secret leaves already hold key names at push time). Verify the Spin lowercase-secret-name collision check still operates over the new path metadata. + +### 4.6 Backward compatibility + +- **Behavioral:** existing top-level `#[secret]` configs (e.g. `app-demo`'s `api_token`) resolve identically — their path is length 1. +- **Source-level (breaking within edgezero):** `SecretField.name: &'static str` → `SecretField.path: &'static [SecretPathSegment]` (and possibly `AppConfigMeta::SECRET_FIELDS` const → `secret_fields()` fn, per B-3). Every in-tree consumer (`secret_walk`, the CLI reflections, tests, `app-demo`) updates in the same PR. No external consumers exist yet besides `app-demo`. Provide a helper `SecretField::dotted_path() -> String` for error messages and CLI output. + +### 4.7 Tests (edgezero) + +- Derive UI tests (trybuild, matching the existing `crates/edgezero-macros/tests/ui/` style): + - nested `#[app_config(nested)]` object with a `#[secret]` leaf compiles; emits the expected path. + - `#[secret]` on `Option` compiles; on `Vec`/non-string still errors. + - `#[serde(rename)]` anywhere along a secret path errors. + - nested field annotated `#[app_config(nested)]` whose type does not derive `AppConfig` errors clearly. + - (if arrays land) `Vec` with `#[app_config(nested)]` emits `ArrayEach`. +- Runtime `secret_walk` tests: nested object leaf resolves from default store; nested `KeyInNamedStore` resolves `store_ref` sibling in the same parent; absent `Option` secret is skipped; missing required nested leaf errors with the dotted path; (if arrays) each element resolved independently. +- End-to-end `AppConfig` extractor test with a 2-level nested secret over an `InMemorySecretStore`. + +### 4.8 Docs + +- `docs/guide/configuration.md` (and the blob-app-config spec/guide): document nested/array `#[secret]`, the `#[app_config(nested)]` opt-in, the `store_ref` sibling scoping rule, and the dotted-path error format. + +--- + +## 5. Sequencing, dependencies, acceptance + +- **A and B are independent.** Either can land first; both are prerequisites for trusted-server work (A → TS Phase 4 extractors; B → TS Phase 3 secret externalization). +- **Suggested order:** B first (it is the higher-risk design and gates the operator-facing secret migration), A alongside or after. Not a hard requirement. + +**Acceptance criteria (edgezero CI gates apply):** + +1. `cargo fmt` / clippy clean across `edgezero-core`, `edgezero-macros`, all adapters. +2. New unit + UI + integration tests (3.4, 4.7) pass. +3. `app-demo` still builds and serves on all four adapters; its top-level `#[secret]` still resolves. +4. `edgezero-cli` `config validate/push/diff` operate correctly over a config with a nested secret. +5. Rustdoc + guide updates (3.5, 4.8) merged. + +--- + +## 6. Risks & open questions + +| ID | Question | Recommendation | +|----|----------|----------------| +| B-1 | Are there secrets inside **arrays** in `Settings`, or only nested objects? | Audit `Settings` in TS Phase 3 scoping; design `SecretPathSegment::ArrayEach` in from day one but implement only if needed. | +| B-2 | How does the derive decide which fields to recurse into? | Explicit `#[app_config(nested)]` opt-in on the field (mirrors `#[validate(nested)]`); reject the type-heuristic alternative. | +| B-3 | Keep `AppConfigMeta::SECRET_FIELDS` as an associated `const`, or switch to `fn secret_fields()`? | Switch to a `fn` returning owned/`Cow` path segments — makes cross-crate recursion tractable; per-request cost is negligible. Maintainer decision as it reshapes the public trait. | +| A-1 | Name the extractor `State` or `Extension`? | `State` + `with_state` primary; optionally `Extension` alias. | +| A-2 | Should `State` also be exposed via a `RequestContext` accessor (not just the extractor)? | Optional; add `ctx.state::()` only if a non-`#[action]` call site needs it. Extractor is sufficient for TS. | +| GEN | Should this spec live in the edgezero repo instead of trusted-server? | It is filed here (trusted-server) as part of the umbrella migration; **hand a copy to the edgezero maintainer** to implement upstream, or relocate to `edgezero/docs/superpowers/specs/` if preferred. | + +--- + +## 7. Files to touch (edgezero repo) + +**Workstream A** +- `crates/edgezero-core/src/router.rs` — `RouterBuilder::with_state`, `RouterInner.state_inserters`, dispatch insertion (before `RequestContext::new`, alongside the introspection injects from PR #300). +- `crates/edgezero-core/src/extractor.rs` — `State` extractor (+ `Deref`/`DerefMut`/`into_inner`). Making `State` `pub` here is sufficient; **no `lib.rs` crate-root re-export** (§8 [A, minor] — no extractor is re-exported at the crate root today; consumers use `edgezero_core::extractor::State`). +- `docs/guide/handlers.md`. + +**Workstream B** +- `crates/edgezero-core/src/app_config.rs` — `SecretPathSegment`, reshaped `SecretField`, `AppConfigMeta` (const→fn per B-3), `dotted_path()` helper. +- `crates/edgezero-macros/src/app_config.rs` — recursion, `#[app_config(nested)]` parsing, relaxed scalar rule for `Option`, path-aware guards. +- `crates/edgezero-core/src/extractor.rs` — path-navigating `secret_walk`. +- `crates/edgezero-cli/src/config.rs` — path-aware secret reflection in validate/push/diff. +- `crates/edgezero-macros/tests/ui/*` + core/CLI tests. +- `docs/guide/configuration.md`, blob-app-config guide. + +--- + +## 8. Maintainer-review corrections (verified against `origin/main` @ `42843b1`, 2026-07-02) + +A line-by-line review of every claim in §2–§7 against the actual code. **Both workstreams are implementable as designed; no blockers.** The current-mechanics claims (§2.1–§2.4) are all accurate. The items below are corrections and design call-outs to fold in before implementation. + +### Verified accurate + +- **A:** `RequestContext` wraps `Request` and reads handles from `request.extensions()` (`context.rs:13`, `kv_store_default` at `context.rs:123`); `FromRequest for Kv` matches the §2.1 quote (`extractor.rs:480`); `RequestContext::new(request, params)` (`context.rs:131`) and the owned-`request` dispatch site (`router.rs:267`, `RouteMatch::Found` at `router.rs:73`) make the "insert into `extensions_mut()` before `RequestContext::new`" plan sound. `RouterBuilder` derives `Default` (`router.rs:79`), so adding `state_inserters: Vec<_>` is non-breaking. `http` is a direct dep; `Extensions::insert`'s bound really is `Clone + Send + Sync + 'static`, so the spec's `T` bound is exactly right. `#[action]` emits `::from_request(&__ctx).await?` (`action.rs:85`) — so `State` needs **zero** macro change. `EdgeError::internal` → 500 (`error.rs:101,190`). +- **B:** `SecretField { kind, name: &'static str }` (`app_config.rs:41`); `SecretKind::{KeyInDefault, KeyInNamedStore{store_ref_field}, StoreRef}` (`app_config.rs:53`); `SECRET_FIELDS` is an associated `const` on `AppConfigMeta` (`app_config.rs:34`); `is_scalar_string_type` accepts bare `String` only and rejects `Option`/`Vec`/nested (`macros/app_config.rs:275`); the serde `rename`/`flatten`/`skip*` and container `rename_all` guards exist (`macros/app_config.rs:336,363`); `secret_walk` is top-level `as_object_mut()` + per-field get/insert (`extractor.rs:827`) inside the `extract_from_handle` chain fetch → `verify()` → `secret_walk` → `serde_path_to_error` → `cfg.validate()` (`extractor.rs:766`); `EdgeError::config_out_of_date` is the real error used at the missing-leaf path (`extractor.rs:838`); push/runtime split via `validate_excluding_secrets` (`app_config.rs:204`) vs runtime `cfg.validate()` holds; app-demo's top-level `#[secret] api_token` (`app-demo-core/src/config.rs:24`) makes the length-1-path compat claim testable. + +### Corrections to fold in + +- **[A, minor] §3.2 use the `http` facade, not bare `http::Extensions`.** Core routes `http` through `crate::http` (`router.rs:13` already does `use crate::http::…`; alias at `http.rs:25`). Write `type StateInserter = Arc` and the closure param as `&mut crate::http::Extensions`. Bare `http::Extensions` compiles but violates the crate's facade rule / CLAUDE.md. +- **[A, minor] §3.1/§7 the "re-export `State` in `lib.rs`" step is inaccurate.** No extractor is re-exported at the crate root today (`lib.rs:23` is just `pub mod extractor;`); consumers use `edgezero_core::extractor::State`. Making `State` `pub` in `extractor.rs` is sufficient. A crate-root re-export is optional and, if added, must sit under the existing `#![expect(clippy::pub_use, …)]` at `lib.rs:7`. Drop it from the required file-touch list. +- **[A, nit] §3.2 completeness:** also add the field to `RouterInner` (`router.rs:260`), a param to the private `RouterService::new` (`router.rs:343`), and pass `self.state_inserters` from `build()` (`router.rs:160`). +- **[B, IMPORTANT] §4.6 consumer list omits `validate_excluding_secrets`, and it is not a mechanical rename.** `validate_excluding_secrets` (`app_config.rs:204`) removes secret-field validators via `bag.remove(field.name)` on the **top-level** error map (line ~220). For a *nested* secret the failing validator lives under the parent inside a `ValidationErrorsKind::Struct`/`List`, so a flat remove-by-key will not find it → the nested secret's push-time validator would not be excluded, breaking the push/runtime split for exactly the new case. This needs nested-`ValidationErrors` navigation (the `first_violating_field` walk at `extractor.rs:926` is the pattern to reuse), not a `.name`→`.path` swap. Add to the consumer list **and** flag as real work. +- **[B, IMPORTANT] §4.3 reword point 3.** The derive is purely syntactic and cannot see a child struct's fields, so guards cannot be enforced "along the entire path" from the parent. They hold because **each struct on the path independently derives `AppConfig` and self-enforces** — which the B-2 `#[app_config(nested)]` opt-in + `AppConfigRoot` bound is what guarantees. Reword accordingly. +- **[B, IMPORTANT] B-3 is effectively forced to option (b).** Cross-crate `const` concatenation of `&'static [SecretPathSegment]` with a parent-prepended prefix is not expressible in stable `const` (the macro cannot see the child's segments — same syntactic limit as above), so option (a) is not viable. Go with `fn secret_fields() -> Cow<'static, [SecretField]>`. Note the churn is wider than §4.6 states: **every** `impl AppConfigMeta`/`SECRET_FIELDS` site flips, including ~10 hand-rolled test impls in `app_config.rs`/`extractor.rs`/`config.rs` tests. +- **[B, minor] §4.5 point at the real reflection helpers.** The per-field top-level `raw_table.get(field.name)` lookups are in `run_adapter_typed_checks` (`config.rs:1296`) and `typed_secret_checks` (`config.rs:1338`), reached *through* `run_config_{validate,push,diff}_typed`. The Spin lowercase-collision check is `validate_typed_secrets` in `adapter-spin/src/cli.rs:514` (keys on the secret **value**, so it survives the path reshape; only the printed field name becomes a dotted path). +- **[B, minor] §4.7 the nested `KeyInNamedStore` "innermost parent" scoping is new behavior with no existing fixture.** app-demo has only `KeyInDefault` (`api_token`) and `StoreRef` (`vault`) — no `KeyInNamedStore` field — so that test needs a purpose-built fixture; it cannot lean on app-demo. +- **[B, nit] §4.2/§4.4 dotted-path rendering:** the spec uses both `partners[*]` (static metadata) and `partners[3]` (runtime error). Have `SecretField::dotted_path()` render `ArrayEach` as `[*]` for the static path and per-index `[n]` at runtime; the existing `[{idx}]` convention (`extractor.rs:959`) already matches. +- **[GEN, resolved] §6 GEN row** ("should this spec live in edgezero or trusted-server?") is now answered — it lives at `docs/superpowers/specs/` in the edgezero repo. + +### Second-pass blockers (must fold into Workstream B before it is plan-ready) + +A follow-up review found additional issues the first pass missed. All verified against `origin/main` @ `42843b1`. + +- **[B, BLOCKER] An active CI guard forbids the exact nesting this spec introduces.** `crates/edgezero-cli/src/bin/check_no_nested_app_config.rs` (spec 10.2.1) detects any `AppConfig`-derived struct used as a field inside another `AppConfig`-derived struct — unwrapping `Option`/`Vec`/`Box`/`Rc`/`Arc`/tuples/arrays — and CI runs it as "Nested AppConfig audit" (`.github/workflows/test.yml:58`, scanning `examples/app-demo` + `crates/edgezero-cli/src/templates`). Workstream B's whole premise (a sub-struct that itself derives `AppConfig`, opted-in via `#[app_config(nested)]`) is a violation today. The guard must be **inverted**, not deleted: "nested `AppConfig` is allowed **iff** the containing field carries `#[app_config(nested)]`." This is a required plan item, and it changes the audit binary + its tests. +- **[B, BLOCKER] Optional secrets need metadata.** §4.3 accepts `Option` and §4.4 skips an absent optional, but §4.2's `SecretField` carries only `{ kind, path }`. The runtime walk and the CLI reflections cannot distinguish "required leaf missing → error" from "optional leaf absent → skip" without an explicit flag. Add `optional: bool` (or fold it into `SecretKind`). +- **[B, BLOCKER] The path model is internally inconsistent — commit to owned segments.** §4.2 shows `path: &'static [SecretPathSegment]` while §4.3/B-3 conclude nested recursion must build owned/`Cow` paths (the only viable cross-crate lowering). These contradict. Resolve by making paths owned: `secret_fields() -> Vec` with `path: Vec` (or `Cow<'static, _>`). Update §4.2 to match B-3 rather than leaving a `&'static` shape it cannot satisfy. +- **[B, HIGH] Register the helper attribute.** `crates/edgezero-macros/src/lib.rs:20` is `#[proc_macro_derive(AppConfig, attributes(secret))]` — it must become `attributes(secret, app_config)` or the `#[app_config(nested)]` opt-in fails to parse. +- **[B, HIGH] `TypedSecretEntry.field_name` is a borrowed `&'static`/`&'entry str`.** `crates/edgezero-adapter/src/registry.rs:178` borrows the field name; dotted/array paths (`partners[3].api_key`) are computed strings with no `'static` backing. Make `field_name` owned (`String`/`Cow<'static, str>`) or carry an owned label alongside the borrowed entry, and thread that through the Spin collision check. +- **[B, HIGH] Enforce container `rename_all` on nested-only parents.** `crates/edgezero-macros/src/app_config.rs:75` gates the `rename_all` guard on `!annotations.is_empty()` (direct `#[secret]` fields only). A parent whose secrets live entirely in `#[app_config(nested)]` children has empty direct annotations, so a container `#[serde(rename_all=…)]` there would silently desync the emitted Rust field-path segment from the serialized key. Extend the guard to also fire when the struct has any `#[app_config(nested)]` field. +- **[B, HIGH] Decide array scope now, not later.** The problem statement and examples (§4.1, `partners[*].api_key`) include array secrets, yet B-1 defers `ArrayEach`. Do not write an object-only plan unless trusted-server's `Settings` audit confirms **no** secret leaves inside arrays; otherwise include `ArrayEach` from the start. + +### Go / No-Go + +Split into **two independent implementation plans** (matching §5's "A and B are independent"): + +- **Workstream A (`State`) is plan-ready now** — no blockers; router insertion before `RequestContext::new` (`router.rs:267`) and the generic `#[action]` `FromRequest` call (`action.rs:87`) both fit as designed. +- **Workstream B (nested/array secrets) should start only after** folding in the second-pass blockers above — especially: inverting the nested-AppConfig CI guard, `optional` metadata, owned path segments, helper-attribute registration, owned `TypedSecretEntry` labels, nested-only `rename_all` enforcement, and a settled array scope. + +### Baseline note + +Local `main` (`b298bc1`, "Extract run_typed_preflight…") is a **broken divergent tip**: it deletes `config.rs` (3365 lines) while leaving `mod config;`/`pub use config::…` in `lib.rs`, so `edgezero-cli` does not compile there, and it is not an ancestor of `origin/main`. `run_typed_preflight` exists nowhere in the tree — that refactor's new files were never committed. All §4.5 claims were therefore verified against `origin/main` (`42843b1`), where `config.rs` is intact; the spec's function names are correct against that tree. diff --git a/examples/app-demo/Cargo.lock b/examples/app-demo/Cargo.lock index 4eab9b78..0c8bb82e 100644 --- a/examples/app-demo/Cargo.lock +++ b/examples/app-demo/Cargo.lock @@ -871,6 +871,7 @@ dependencies = [ "proc-macro2", "quote", "serde", + "serde_json", "syn 2.0.118", "toml", "validator", diff --git a/examples/app-demo/crates/app-demo-core/src/handlers.rs b/examples/app-demo/crates/app-demo-core/src/handlers.rs index a854c0c2..8358ac5c 100644 --- a/examples/app-demo/crates/app-demo-core/src/handlers.rs +++ b/examples/app-demo/crates/app-demo-core/src/handlers.rs @@ -308,6 +308,7 @@ pub async fn secrets_echo( mod tests { use super::*; use async_trait::async_trait; + use edgezero_core::blob_envelope::BlobEnvelope; use edgezero_core::body::Body; use edgezero_core::config_store::{ConfigStore, ConfigStoreError, ConfigStoreHandle}; use edgezero_core::context::RequestContext; @@ -318,7 +319,9 @@ mod tests { use edgezero_core::proxy::{ProxyClient, ProxyHandle, ProxyResponse}; use edgezero_core::response::IntoResponse as _; use edgezero_core::secret_store::{InMemorySecretStore, SecretHandle}; - use edgezero_core::store_registry::{ConfigStoreBinding, KvRegistry}; + use edgezero_core::store_registry::{ + ConfigRegistry, ConfigStoreBinding, KvRegistry, StoreRegistry, + }; use futures::executor::block_on; use std::collections::{BTreeMap, HashMap}; use std::sync::{Arc, Mutex}; @@ -419,6 +422,84 @@ mod tests { } } + struct FixedStore(String); + + #[async_trait(?Send)] + impl ConfigStore for FixedStore { + async fn get(&self, _key: &str) -> Result, ConfigStoreError> { + Ok(Some(self.0.clone())) + } + } + + #[test] + fn introspection_routes_are_wired() { + let router = crate::build_router(); + + // manifest: 200 + JSON body whose [app].name is "app-demo". + let manifest_req = request_builder() + .method(Method::GET) + .uri("/_app-demo/manifest") + .body(Body::empty()) + .unwrap(); + let manifest_resp = block_on(router.oneshot(manifest_req)).unwrap(); + assert_eq!(manifest_resp.status(), StatusCode::OK); + assert_eq!( + manifest_resp.headers().get("content-type").unwrap(), + "application/json" + ); + let manifest_body: serde_json::Value = manifest_resp.into_body().to_json().unwrap(); + assert_eq!(manifest_body["app"]["name"], "app-demo"); + + // routes: 200 + [{method,path}] including the root route. + let routes_req = request_builder() + .method(Method::GET) + .uri("/_app-demo/routes") + .body(Body::empty()) + .unwrap(); + let routes_resp = block_on(router.oneshot(routes_req)).unwrap(); + assert_eq!(routes_resp.status(), StatusCode::OK); + let routes_body: serde_json::Value = routes_resp.into_body().to_json().unwrap(); + let arr = routes_body.as_array().expect("routes array"); + assert!(arr + .iter() + .any(|entry| entry["method"] == "GET" && entry["path"] == "/")); + + // /config: seed a default config store with a valid envelope so a wired + // route returns 200 (a routing miss would be 404, proving nothing). + let data = serde_json::json!({ "greeting": "hi", "api_token": "demo_api_token" }); + let blob = + serde_json::to_string(&BlobEnvelope::new(data, "2026-01-01T00:00:00Z".to_owned())) + .unwrap(); + let registry: ConfigRegistry = StoreRegistry::new( + [( + "app_config".to_owned(), + ConfigStoreBinding { + handle: ConfigStoreHandle::new(Arc::new(FixedStore(blob))), + default_key: "app_config".to_owned(), + }, + )] + .into_iter() + .collect::>(), + "app_config".to_owned(), + ); + let mut config_req = request_builder() + .method(Method::GET) + .uri("/_app-demo/config") + .body(Body::empty()) + .unwrap(); + config_req.extensions_mut().insert(registry); + let config_resp = block_on(router.oneshot(config_req)).unwrap(); + assert_eq!( + config_resp.status(), + StatusCode::OK, + "/config should be wired and 200 with a store" + ); + // Raw envelope `data`: secret field holds the KEY NAME, not a resolved value. + let config_body: serde_json::Value = config_resp.into_body().to_json().unwrap(); + assert_eq!(config_body["api_token"], "demo_api_token"); + assert_eq!(config_body["greeting"], "hi"); + } + #[test] fn build_proxy_target_merges_segments_and_query() { let original = Uri::from_static("/proxy/status?foo=bar"); diff --git a/examples/app-demo/edgezero.toml b/examples/app-demo/edgezero.toml index bd8b93cd..f5462639 100644 --- a/examples/app-demo/edgezero.toml +++ b/examples/app-demo/edgezero.toml @@ -101,6 +101,32 @@ handler = "app_demo_core::handlers::kv_note_delete" adapters = ["axum", "cloudflare", "fastly", "spin"] description = "Delete a note by id" +# -- Introspection routes ------------------------------------------------------ + +[[triggers.http]] +id = "manifest" +path = "/_app-demo/manifest" +methods = ["GET"] +handler = "edgezero_core::introspection::manifest" +adapters = ["axum", "cloudflare", "fastly", "spin"] +description = "App manifest as JSON" + +[[triggers.http]] +id = "config" +path = "/_app-demo/config" +methods = ["GET"] +handler = "edgezero_core::introspection::config" +adapters = ["axum", "cloudflare", "fastly", "spin"] +description = "Effective app config (secret-safe)" + +[[triggers.http]] +id = "routes" +path = "/_app-demo/routes" +methods = ["GET"] +handler = "edgezero_core::introspection::routes" +adapters = ["axum", "cloudflare", "fastly", "spin"] +description = "Registered route table" + # -- Secrets demo route -------------------------------------------------------- [[triggers.http]]