diff --git a/Cargo.lock b/Cargo.lock index 2100fc07..1878e7f3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -824,7 +824,9 @@ dependencies = [ name = "edgezero-macros" version = "0.1.0" dependencies = [ + "async-trait", "edgezero-core", + "futures", "log", "proc-macro2", "quote", diff --git a/crates/edgezero-adapter-axum/src/dev_server.rs b/crates/edgezero-adapter-axum/src/dev_server.rs index 36486719..147e1658 100644 --- a/crates/edgezero-adapter-axum/src/dev_server.rs +++ b/crates/edgezero-adapter-axum/src/dev_server.rs @@ -340,7 +340,9 @@ pub fn run_app() -> anyhow::Result<()> { .and_then(|raw| LevelFilter::from_str(raw).ok()) .unwrap_or(LevelFilter::Info); - let _logger_init = SimpleLogger::new().with_level(level).init(); + if !A::owns_logging() { + let _logger_init = SimpleLogger::new().with_level(level).init(); + } let resolution = resolve_addr(&env); for warning in &resolution.warnings { diff --git a/crates/edgezero-adapter-cloudflare/src/lib.rs b/crates/edgezero-adapter-cloudflare/src/lib.rs index c630cc1c..edd9224f 100644 --- a/crates/edgezero-adapter-cloudflare/src/lib.rs +++ b/crates/edgezero-adapter-cloudflare/src/lib.rs @@ -101,8 +101,11 @@ pub async fn run_app( ctx: Context, ) -> Result { // Best-effort: if a logger is already installed, ignore the error rather - // than panicking — every Worker request re-enters this function. - drop(init_logger()); + // than panicking — every Worker request re-enters this function. Skipped + // entirely when the app owns logging. + if !A::owns_logging() { + drop(init_logger()); + } let stores = A::stores(); let env_config = env_config_from_worker(&env, stores); let app = A::build_app(); diff --git a/crates/edgezero-adapter-fastly/src/lib.rs b/crates/edgezero-adapter-fastly/src/lib.rs index a05a02f6..e3c40e4a 100644 --- a/crates/edgezero-adapter-fastly/src/lib.rs +++ b/crates/edgezero-adapter-fastly/src/lib.rs @@ -25,6 +25,8 @@ use edgezero_core::app::{Hooks, StoresMetadata}; #[cfg(feature = "fastly")] use edgezero_core::env_config::EnvConfig; #[cfg(feature = "fastly")] +use edgezero_core::http::Extensions; +#[cfg(feature = "fastly")] use edgezero_core::manifest::ResolvedLoggingConfig; #[cfg(feature = "fastly")] #[derive(Debug, Clone)] @@ -111,15 +113,44 @@ fn logging_from_env(env: &EnvConfig) -> FastlyLogging { #[cfg(feature = "fastly")] #[inline] pub fn run_app(req: fastly::Request) -> Result { + run_app_with_request_extensions::(req, |_req, _extensions| {}) +} + +/// Like [`run_app`], but runs `extend` against a scratch +/// [`Extensions`](edgezero_core::http::Extensions) populated from the raw +/// `fastly::Request` (TLS JA4, H2 fingerprint, client IP, …) before the request +/// is converted; the scratch values are merged into the core request's +/// extensions and are visible to middleware and the `State`/extractor layer. +/// +/// # Errors +/// Returns an error if logger setup fails or any required store cannot be opened. +#[cfg(feature = "fastly")] +#[inline] +pub fn run_app_with_request_extensions( + req: fastly::Request, + extend: F, +) -> Result +where + A: Hooks, + F: FnOnce(&fastly::Request, &mut Extensions), +{ let stores = A::stores(); let env = env_config_from_runtime_dictionary(stores); let logging = logging_from_env(&env); - if logging.use_fastly_logger { + if logging.use_fastly_logger && !A::owns_logging() { let endpoint = logging.endpoint.as_deref().unwrap_or("stdout"); init_logger(endpoint, logging.level, logging.echo_stdout)?; } let app = A::build_app(); - request::dispatch_with_registries(&app, req, stores.config, stores.kv, stores.secrets, &env) + request::dispatch_with_registries( + &app, + req, + stores.config, + stores.kv, + stores.secrets, + &env, + extend, + ) } /// Build an [`EnvConfig`] from the optional `edgezero_runtime_env` @@ -202,7 +233,7 @@ pub fn run_app_with_config( req: fastly::Request, config_store_name: Option<&str>, ) -> Result { - if logging.use_fastly_logger { + if logging.use_fastly_logger && !A::owns_logging() { let endpoint = logging.endpoint.as_deref().unwrap_or("stdout"); init_logger(endpoint, logging.level, logging.echo_stdout)?; } diff --git a/crates/edgezero-adapter-fastly/src/proxy.rs b/crates/edgezero-adapter-fastly/src/proxy.rs index 2947f33b..b65fa0e6 100644 --- a/crates/edgezero-adapter-fastly/src/proxy.rs +++ b/crates/edgezero-adapter-fastly/src/proxy.rs @@ -46,11 +46,13 @@ fn build_fastly_request(method: Method, uri: &Uri, headers: &HeaderMap) -> Fastl let mut fastly_request = FastlyRequest::new(method.clone(), uri.to_string()); fastly_request.set_method(method); + // Append (not set) so a multi-value client header survives; `Host` below is + // set explicitly as a single value. for (name, value) in headers { if name.as_str().eq_ignore_ascii_case("host") { continue; } - fastly_request.set_header(name.as_str(), value.clone()); + fastly_request.append_header(name.as_str(), value.clone()); } if let Some(host) = uri.host() { @@ -64,9 +66,13 @@ fn convert_response(fastly_response: &mut FastlyResponse) -> ProxyResponse { let status = fastly_response.get_status(); let mut proxy_response = ProxyResponse::new(status, Body::empty()); - for header in fastly_response.get_header_names() { - if let Some(value) = fastly_response.get_header(header) { - proxy_response.headers_mut().insert(header, value.clone()); + // Preserve multi-value ORIGIN response headers (e.g. Set-Cookie): read ALL + // values per name and append, instead of first-value + insert (which + // replaced). `get_header_names()` yields `&HeaderName`, usable for both + // `get_header_all` and `append`. + for name in fastly_response.get_header_names() { + for value in fastly_response.get_header_all(name) { + proxy_response.headers_mut().append(name, value.clone()); } } @@ -212,6 +218,23 @@ mod tests { } } + #[test] + fn convert_response_preserves_multi_value_set_cookie() { + let mut fastly_response = FastlyResponse::from_status(200); + fastly_response.append_header("set-cookie", "a=1"); + fastly_response.append_header("set-cookie", "b=2"); + + let proxy_response = convert_response(&mut fastly_response); + + let cookies: Vec = proxy_response + .headers() + .get_all("set-cookie") + .into_iter() + .map(|value| value.to_str().expect("utf8").to_owned()) + .collect(); + assert_eq!(cookies, vec!["a=1".to_owned(), "b=2".to_owned()]); + } + #[test] fn stream_handles_brotli() { let mut compressed = Vec::new(); diff --git a/crates/edgezero-adapter-fastly/src/request.rs b/crates/edgezero-adapter-fastly/src/request.rs index f196a2ee..b7eba428 100644 --- a/crates/edgezero-adapter-fastly/src/request.rs +++ b/crates/edgezero-adapter-fastly/src/request.rs @@ -8,7 +8,7 @@ use edgezero_core::body::Body; use edgezero_core::config_store::ConfigStoreHandle; use edgezero_core::env_config::EnvConfig; use edgezero_core::error::EdgeError; -use edgezero_core::http::{request_builder, Request}; +use edgezero_core::http::{request_builder, Extensions, Request}; use edgezero_core::key_value_store::KvHandle; use edgezero_core::proxy::ProxyHandle; use edgezero_core::secret_store::SecretHandle; @@ -151,6 +151,7 @@ impl<'app> FastlyService<'app> { secrets, ..Default::default() }, + |_req, _extensions| {}, ) } @@ -276,12 +277,31 @@ fn dispatch_core_request( from_core_response(response).map_err(|err| map_edge_error(&err)) } -fn dispatch_with_handles( +/// Run an app-provided closure against a scratch `Extensions` populated from the +/// RAW `fastly::Request` (JA4 / H2 / etc.), BEFORE `into_core_request` consumes +/// the request. Returns the scratch bag to be `extend`ed into the core request. +fn apply_request_extend(req: &FastlyRequest, extend: F) -> Extensions +where + F: FnOnce(&FastlyRequest, &mut Extensions), +{ + let mut scratch = Extensions::default(); + extend(req, &mut scratch); + scratch +} + +fn dispatch_with_handles( app: &App, req: FastlyRequest, stores: Stores, -) -> Result { - let core_request = into_core_request(req).map_err(|err| map_edge_error(&err))?; + extend: F, +) -> Result +where + F: FnOnce(&FastlyRequest, &mut Extensions), +{ + // Read raw-request signals into a scratch bag BEFORE conversion consumes `req`. + let scratch = apply_request_extend(&req, extend); + let mut core_request = into_core_request(req).map_err(|err| map_edge_error(&err))?; + core_request.extensions_mut().extend(scratch); dispatch_core_request(app, core_request, stores) } @@ -292,14 +312,18 @@ fn dispatch_with_handles( /// id default). KV failures escalate via [`resolve_kv_handle`]'s /// `kv_required=true` path; missing config / secret stores degrade silently /// with a one-time warning. -pub(crate) fn dispatch_with_registries( +pub(crate) fn dispatch_with_registries( app: &App, req: FastlyRequest, config_meta: Option, kv_meta: Option, secret_meta: Option, env: &EnvConfig, -) -> Result { + extend: F, +) -> Result +where + F: FnOnce(&FastlyRequest, &mut Extensions), +{ let kv_registry = build_kv_registry(kv_meta, env)?; let config_registry = build_config_registry(config_meta, env); let secret_registry = build_secret_registry(secret_meta, env); @@ -312,6 +336,7 @@ pub(crate) fn dispatch_with_registries( secret_registry, ..Default::default() }, + extend, ) } @@ -573,6 +598,65 @@ mod synthesis_tests { SecretHandle::new(Arc::new(NoopSecretStore)) } + #[test] + fn apply_request_extend_populates_scratch_from_raw_request() { + use edgezero_core::http::Method; + + #[derive(Clone, Debug, PartialEq)] + struct Ja4(String); + + let raw = FastlyRequest::new(Method::GET, "http://example.test/"); + let scratch = apply_request_extend(&raw, |req, extensions| { + // A real closure would call req.get_tls_ja4(); deriving from the URL + // keeps the assertion deterministic under Viceroy. + let marker = req.get_url_str().to_owned(); + extensions.insert(Ja4(marker)); + }); + + assert_eq!( + scratch.get::(), + Some(&Ja4("http://example.test/".to_owned())) + ); + } + + #[test] + fn extended_request_extensions_are_visible_to_handler() { + use edgezero_core::body::Body; + use edgezero_core::context::RequestContext; + use edgezero_core::http::{request_builder, Method, StatusCode}; + use edgezero_core::router::RouterService; + use futures::executor::block_on; + + #[derive(Clone)] + struct Ja4(String); + + async fn handler(ctx: RequestContext) -> Result { + let ja4 = ctx + .request() + .extensions() + .get::() + .map_or_else(|| "missing".to_owned(), |value| value.0.clone()); + Ok(ja4) + } + + // Mirror what `dispatch_with_handles` does: a scratch bag built from the + // raw request is `extend`ed into the core request before dispatch. + let mut scratch = Extensions::default(); + scratch.insert(Ja4("t13d1516h2".to_owned())); + + let mut core_request = request_builder() + .method(Method::GET) + .uri("/ja4") + .body(Body::empty()) + .expect("request"); + core_request.extensions_mut().extend(scratch); + + let service = RouterService::builder().get("/ja4", handler).build(); + let response = block_on(service.oneshot(core_request)).expect("response"); + assert_eq!(response.status(), StatusCode::OK); + assert_eq!(response.body().as_bytes().expect("buffered"), b"t13d1516h2"); + } + #[test] fn synthesis_wraps_bare_kv_handle_under_default_when_no_registry() { let stores = Stores { diff --git a/crates/edgezero-adapter-fastly/src/response.rs b/crates/edgezero-adapter-fastly/src/response.rs index 075b235a..f9eb4d02 100644 --- a/crates/edgezero-adapter-fastly/src/response.rs +++ b/crates/edgezero-adapter-fastly/src/response.rs @@ -25,8 +25,11 @@ pub fn from_core_response(response: Response) -> Result = fastly_response + .get_header_all("set-cookie") + .map(|value| value.to_str().expect("utf8").to_owned()) + .collect(); + assert_eq!(cookies, vec!["a=1".to_owned(), "b=2".to_owned()]); + } + #[test] fn stream_body_is_written_to_fastly_response() { let response = response_builder() diff --git a/crates/edgezero-adapter-spin/src/cli.rs b/crates/edgezero-adapter-spin/src/cli.rs index 11c1d6a2..dd6a8ec6 100644 --- a/crates/edgezero-adapter-spin/src/cli.rs +++ b/crates/edgezero-adapter-spin/src/cli.rs @@ -541,7 +541,7 @@ impl Adapter for SpinCliAdapter { value = entry.key_value, )); } - if let Some(prev_field) = seen.insert(spin_var.clone(), entry.field_name) { + if let Some(prev_field) = seen.insert(spin_var.clone(), entry.field_name.as_str()) { return Err(format!( "Spin variable `{spin_var}` would receive values from BOTH `#[secret]` field `{prev_field}` AND `#[secret]` field `{this_field}`; Spin's flat variable namespace cannot disambiguate them. Pick distinct `#[secret]` values whose lowercased forms differ.", this_field = entry.field_name, diff --git a/crates/edgezero-adapter-spin/src/lib.rs b/crates/edgezero-adapter-spin/src/lib.rs index b8234fe5..db282c05 100644 --- a/crates/edgezero-adapter-spin/src/lib.rs +++ b/crates/edgezero-adapter-spin/src/lib.rs @@ -111,8 +111,11 @@ pub fn init_logger() -> Result<(), log::SetLoggerError> { pub async fn run_app(req: SpinRequest) -> anyhow::Result { // Best-effort: every Spin `#[http_service]` re-enters this function, so a // second `log::set_logger` call returns Err — drop the result instead of - // `.expect()` to avoid panicking on every subsequent request. - drop(init_logger()); + // `.expect()` to avoid panicking on every subsequent request. Skipped + // entirely when the app owns logging. + if !A::owns_logging() { + drop(init_logger()); + } let env = EnvConfig::from_env(); let stores = A::stores(); let app = A::build_app(); diff --git a/crates/edgezero-adapter/src/registry.rs b/crates/edgezero-adapter/src/registry.rs index 6a24ce87..9cdc5da0 100644 --- a/crates/edgezero-adapter/src/registry.rs +++ b/crates/edgezero-adapter/src/registry.rs @@ -176,8 +176,8 @@ impl<'ctx> AdapterPushContext<'ctx> { /// v2 source-compat; construction goes through `new`. #[non_exhaustive] pub struct TypedSecretEntry<'entry> { - /// Rust struct field name (e.g. `"api_token"`). - pub field_name: &'entry str, + /// Dotted secret-field path label (e.g. `"partners[3].api_key"`). + pub field_name: String, /// Blob value — i.e. the secret-store KEY NAME. pub key_value: &'entry str, /// Logical secret-store id this key targets. @@ -188,9 +188,13 @@ impl<'entry> TypedSecretEntry<'entry> { /// Construct a new entry from its three components. #[must_use] #[inline] - pub fn new(store_id: &'entry str, field_name: &'entry str, key_value: &'entry str) -> Self { + pub fn new>( + store_id: &'entry str, + field_name: Name, + key_value: &'entry str, + ) -> Self { Self { - field_name, + field_name: field_name.into(), key_value, store_id, } diff --git a/crates/edgezero-cli/src/bin/check_no_nested_app_config.rs b/crates/edgezero-cli/src/bin/check_no_nested_app_config.rs index 1aa71c42..62151e0e 100644 --- a/crates/edgezero-cli/src/bin/check_no_nested_app_config.rs +++ b/crates/edgezero-cli/src/bin/check_no_nested_app_config.rs @@ -63,6 +63,7 @@ use walkdir::WalkDir; // Pass 1: collect struct identifiers that derive AppConfig // --------------------------------------------------------------------------- +#[derive(Default)] struct AppConfigStructCollector { app_config_structs: HashSet, } @@ -166,6 +167,9 @@ impl<'ast> Visit<'ast> for NestedAppConfigVisitor<'_, '_> { if let Some(inner_name) = type_contains_app_config_struct(&field.ty, self.app_config_structs) { + if field_has_nested_optin(field) { + continue; // opted in via #[app_config(nested)] — allowed + } let span = field .ident .as_ref() @@ -177,6 +181,33 @@ impl<'ast> Visit<'ast> for NestedAppConfigVisitor<'_, '_> { } } +/// Returns `true` only for a well-formed `#[app_config(nested)]`. A malformed +/// `#[app_config(...)]` returns `false` -> the field is treated as NOT opted +/// in, so the guard still FLAGS the nesting (loud CI failure) rather than +/// silently waving it through. This is safe here (unlike the derive's +/// `nested_optin`, which must hard-error): the guard runs only over +/// already-compiling code, and the derive's strict `nested_optin` has already +/// rejected any malformed `#[app_config(...)]` before this binary ever runs. +fn field_has_nested_optin(field: &syn::Field) -> bool { + field.attrs.iter().any(|attr| { + if !attr.path().is_ident("app_config") { + return false; + } + // Must actually see `nested`. A bare `#[app_config()]` parses Ok but + // never sets `found`, so `.is_ok()` alone would wrongly report opt-in. + let mut found = false; + let parsed = attr.parse_nested_meta(|meta| { + if meta.path.is_ident("nested") { + found = true; + Ok(()) + } else { + Err(meta.error("unknown app_config option")) + } + }); + parsed.is_ok() && found + }) +} + // --------------------------------------------------------------------------- // Type-unwrapping helpers // --------------------------------------------------------------------------- @@ -339,9 +370,9 @@ fn main() { if violations > 0 { eprintln!( "\n{violations} nested-AppConfig violation(s). \ - A struct with #[derive(AppConfig)] must not contain fields whose \ - type resolves to another #[derive(AppConfig)] struct, even through \ - Option/Vec/Box wrappers (spec \u{00a7}3.3)." + A field whose type resolves to another #[derive(AppConfig)] struct \ + (even through Option/Vec/Box wrappers) must opt in with \ + #[app_config(nested)]; otherwise nesting is rejected (spec \u{00a7}3.3)." ); process::exit(1); } @@ -351,3 +382,50 @@ fn main() { println!("check_no_nested_app_config: OK"); } + +#[cfg(test)] +mod tests { + use super::*; + + const NESTED_VEC_WITH_OPT_IN: &str = " + #[derive(edgezero_core::AppConfig)] struct Inner { #[secret] k: String } + #[derive(edgezero_core::AppConfig)] struct Outer { #[app_config(nested)] inner: Vec } + "; + + const NESTED_WITHOUT_OPT_IN: &str = " + #[derive(edgezero_core::AppConfig)] struct Inner { #[secret] k: String } + #[derive(edgezero_core::AppConfig)] struct Outer { inner: Inner } + "; + + const NESTED_WITH_OPT_IN: &str = " + #[derive(edgezero_core::AppConfig)] struct Inner { #[secret] k: String } + #[derive(edgezero_core::AppConfig)] struct Outer { #[app_config(nested)] inner: Inner } + "; + + fn violations_in(src: &str) -> usize { + let file = syn::parse_file(src).expect("parse"); + let mut collector = AppConfigStructCollector::default(); + visit::visit_file(&mut collector, &file); + // NB: `new(source_path, app_config_structs)` — path FIRST, per the real + // signature above. + let mut visitor = + NestedAppConfigVisitor::new(Path::new("t.rs"), &collector.app_config_structs); + visit::visit_file(&mut visitor, &file); + visitor.violations + } + + #[test] + fn allows_nesting_with_opt_in() { + assert_eq!(violations_in(NESTED_WITH_OPT_IN), 0); + } + + #[test] + fn allows_vec_nesting_with_opt_in() { + assert_eq!(violations_in(NESTED_VEC_WITH_OPT_IN), 0); + } + + #[test] + fn flags_nesting_without_opt_in() { + assert_eq!(violations_in(NESTED_WITHOUT_OPT_IN), 1); + } +} diff --git a/crates/edgezero-cli/src/config.rs b/crates/edgezero-cli/src/config.rs index 4ca508bd..d01be6b5 100644 --- a/crates/edgezero-cli/src/config.rs +++ b/crates/edgezero-cli/src/config.rs @@ -25,7 +25,8 @@ use edgezero_adapter::registry::{ self as adapter_registry, ReadConfigEntry, ResolvedStoreId, TypedSecretEntry, }; use edgezero_core::app_config::{ - self, AppConfigError, AppConfigLoadOptions, AppConfigMeta, SecretKind, + self, AppConfigError, AppConfigLoadOptions, AppConfigMeta, SecretField, SecretKind, + SecretPathSegment, }; use edgezero_core::blob_envelope::BlobEnvelope; use edgezero_core::env_config::EnvConfig; @@ -167,6 +168,22 @@ enum RecheckOutcome { Write, } +/// One resolved `#[secret]` leaf located in the raw app-config TOML. +/// +/// `label` carries concrete `[n]` array indices (the runtime dotted +/// form used in CLI output and errors). `store_ref_value` is the sibling +/// store id resolved from the leaf's INNERMOST parent table — populated +/// only for `KeyInNamedStore` leaves. +#[derive(Debug)] +struct ResolvedTomlLeaf<'raw> { + /// Dotted runtime label, e.g. `partners[1].api_key`. + label: String, + /// Sibling store id for `KeyInNamedStore`; `None` otherwise. + store_ref_value: Option<&'raw str>, + /// The secret leaf's string value (a secret-store KEY NAME). + value: &'raw str, +} + /// Raw flow — no typed `C`. Runs every check the typed flow runs /// *except* the typed deserialise, the validator rules, the secret /// presence / store-ref checks, and the Spin config-vs-secret @@ -1287,17 +1304,93 @@ pub(crate) fn reject_merged_id_collisions( Ok(()) } +/// Collect every concrete secret leaf a `SecretField` resolves to in the +/// raw app-config TOML, navigating `Field` (table descent) and `ArrayEach` +/// (per-element) segments. `label` uses concrete `[n]` indices and, for a +/// `KeyInNamedStore` leaf, `store_ref_value` is resolved from the leaf's +/// innermost parent table. Absent optional leaves yield nothing; a missing +/// required leaf yields an `Err` carrying the dotted label. +fn collect_secret_leaves<'raw>( + root: &'raw Value, + field: &SecretField, +) -> Result>, String> { + fn walk<'raw>( + node: &'raw Value, + field: &SecretField, + remaining: &[SecretPathSegment], + rendered: &str, + out: &mut Vec>, + ) -> Result<(), String> { + match remaining.split_first() { + Some((SecretPathSegment::Field(name), [])) => { + let parent = node.as_table().ok_or_else(|| { + format!("expected a table containing `{name}` at `{rendered}`") + })?; + let leaf_label = if rendered.is_empty() { + name.to_string() + } else { + format!("{rendered}.{name}") + }; + match parent.get(name.as_ref()).and_then(Value::as_str) { + Some(value) => { + let store_ref_value = match field.kind { + SecretKind::KeyInNamedStore { store_ref_field } => { + parent.get(store_ref_field).and_then(Value::as_str) + } + SecretKind::KeyInDefault | SecretKind::StoreRef => None, + }; + out.push(ResolvedTomlLeaf { + label: leaf_label, + store_ref_value, + value, + }); + Ok(()) + } + None if field.optional && parent.get(name.as_ref()).is_none() => Ok(()), + None => Err(format!( + "`#[secret]` field `{leaf_label}` is missing or not a string" + )), + } + } + Some((SecretPathSegment::Field(name), rest)) => { + let table = node + .as_table() + .ok_or_else(|| format!("expected a table at `{rendered}`"))?; + let next_rendered = if rendered.is_empty() { + name.to_string() + } else { + format!("{rendered}.{name}") + }; + match table.get(name.as_ref()) { + Some(child) => walk(child, field, rest, &next_rendered, out), + None if field.optional => Ok(()), + None => Err(format!("missing `{next_rendered}`")), + } + } + Some((SecretPathSegment::ArrayEach, rest)) => { + let arr = node + .as_array() + .ok_or_else(|| format!("expected an array at `{rendered}`"))?; + for (idx, item) in arr.iter().enumerate() { + let indexed = format!("{rendered}[{idx}]"); + walk(item, field, rest, &indexed, out)?; + } + Ok(()) + } + None => Ok(()), + } + } + let mut out = Vec::new(); + walk(root, field, &field.path, "", &mut out)?; + Ok(out) +} + /// Typed-only adapter dispatch: feed each adapter the `#[secret]` /// (`KeyInDefault` and `KeyInNamedStore` — `StoreRef` values are /// runtime store ids, not flat-namespace candidates) so adapters /// whose secret store has a flat-namespace constraint (Spin) can /// detect within-secrets collisions. fn run_adapter_typed_checks(ctx: &ValidationContext) -> Result<(), String> { - let raw_table = ctx - .raw_config - .as_table() - .ok_or_else(|| "raw app-config was not a TOML table after load".to_owned())?; - let default_store_id = ctx .manifest() .stores @@ -1305,22 +1398,25 @@ fn run_adapter_typed_checks(ctx: &ValidationContext) -> Result .as_ref() .map(StoreDeclaration::default_id); let mut entries: Vec> = Vec::new(); - for field in C::SECRET_FIELDS { - match field.kind { - SecretKind::KeyInDefault => { - let opt_value = raw_table.get(field.name).and_then(Value::as_str); - if let (Some(key_value), Some(store_id)) = (opt_value, default_store_id) { - entries.push(TypedSecretEntry::new(store_id, field.name, key_value)); + for field in C::secret_fields() { + for leaf in collect_secret_leaves(&ctx.raw_config, &field)? { + match field.kind { + SecretKind::KeyInDefault => { + if let Some(store_id) = default_store_id { + entries.push(TypedSecretEntry::new(store_id, leaf.label, leaf.value)); + } } - } - SecretKind::KeyInNamedStore { store_ref_field } => { - let opt_store = raw_table.get(store_ref_field).and_then(Value::as_str); - let opt_value = raw_table.get(field.name).and_then(Value::as_str); - if let (Some(store_id), Some(key_value)) = (opt_store, opt_value) { - entries.push(TypedSecretEntry::new(store_id, field.name, key_value)); + SecretKind::KeyInNamedStore { .. } => { + let store_id = leaf.store_ref_value.ok_or_else(|| { + format!( + "`#[secret(store_ref = \"...\")]` field `{}` is missing its store_ref sibling", + leaf.label + ) + })?; + entries.push(TypedSecretEntry::new(store_id, leaf.label, leaf.value)); } + SecretKind::StoreRef => {} } - SecretKind::StoreRef => {} } } @@ -1340,70 +1436,59 @@ fn typed_secret_checks( _typed: &C, ctx: &ValidationContext, ) -> Result<(), String> { - let raw_table = ctx - .raw_config - .as_table() - .ok_or_else(|| "raw app-config was not a TOML table after load".to_owned())?; - - for field in C::SECRET_FIELDS { - let value = raw_table - .get(field.name) - .and_then(Value::as_str) - .ok_or_else(|| { - format!( - "{}: `#[secret]` field `{}` is missing or not a string at the top level", + for field in C::secret_fields() { + for leaf in collect_secret_leaves(&ctx.raw_config, &field)? { + let label = leaf.label; + let value = leaf.value; + if value.is_empty() { + return Err(format!( + "{}: `#[secret]` field `{}` must be non-empty", ctx.app_config_path.display(), - field.name - ) - })?; - if value.is_empty() { - return Err(format!( - "{}: `#[secret]` field `{}` must be non-empty", - ctx.app_config_path.display(), - field.name - )); - } - match field.kind { - SecretKind::KeyInDefault => { - if ctx.manifest().stores.secrets.is_none() { - return Err(format!( - "{}: `#[secret]` field `{}` requires `[stores.secrets]` to be declared in {}", - ctx.app_config_path.display(), - field.name, - ctx.manifest_path.display() - )); - } + label + )); } - SecretKind::KeyInNamedStore { .. } => { - // The field value is a key within a named store; the named - // store is identified by the sibling `#[secret(store_ref)]` - // field. Verify the store section is at least declared. - if ctx.manifest().stores.secrets.is_none() { - return Err(format!( - "{}: `#[secret(store_ref = \"...\")]` field `{}` requires `[stores.secrets]` to be declared in {}", - ctx.app_config_path.display(), - field.name, - ctx.manifest_path.display() - )); + match field.kind { + SecretKind::KeyInDefault => { + if ctx.manifest().stores.secrets.is_none() { + return Err(format!( + "{}: `#[secret]` field `{}` requires `[stores.secrets]` to be declared in {}", + ctx.app_config_path.display(), + label, + ctx.manifest_path.display() + )); + } } - } - SecretKind::StoreRef => { - let secrets = ctx.manifest().stores.secrets.as_ref().ok_or_else(|| { - format!( - "{}: `#[secret(store_ref)]` field `{}` requires `[stores.secrets]` to be declared in {}", - ctx.app_config_path.display(), - field.name, - ctx.manifest_path.display() - ) - })?; - if !secrets.ids.iter().any(|id| id == value) { - return Err(format!( - "{}: `#[secret(store_ref)]` field `{}` = {:?} is not in [stores.secrets].ids ({:?})", - ctx.app_config_path.display(), - field.name, - value, - secrets.ids - )); + SecretKind::KeyInNamedStore { .. } => { + // The field value is a key within a named store; the named + // store is identified by the sibling `#[secret(store_ref)]` + // field. Verify the store section is at least declared. + if ctx.manifest().stores.secrets.is_none() { + return Err(format!( + "{}: `#[secret(store_ref = \"...\")]` field `{}` requires `[stores.secrets]` to be declared in {}", + ctx.app_config_path.display(), + label, + ctx.manifest_path.display() + )); + } + } + SecretKind::StoreRef => { + let secrets = ctx.manifest().stores.secrets.as_ref().ok_or_else(|| { + format!( + "{}: `#[secret(store_ref)]` field `{}` requires `[stores.secrets]` to be declared in {}", + ctx.app_config_path.display(), + label, + ctx.manifest_path.display() + ) + })?; + if !secrets.ids.iter().any(|id| id == value) { + return Err(format!( + "{}: `#[secret(store_ref)]` field `{}` = {:?} is not in [stores.secrets].ids ({:?})", + ctx.app_config_path.display(), + label, + value, + secrets.ids + )); + } } } } @@ -1543,8 +1628,8 @@ fn format_app_config_error(err: &AppConfigError) -> String { mod tests { use super::*; use crate::test_support::{manifest_guard, EnvOverride}; - use edgezero_core::app_config::SecretField; use serde::{Deserialize, Serialize}; + use std::borrow::Cow; #[cfg(unix)] use std::ffi::OsString; use std::fs; @@ -1647,16 +1732,20 @@ source = "target/wasm32-wasip2/release/demo.wasm" } impl AppConfigMeta for FixtureConfig { - const SECRET_FIELDS: &'static [SecretField] = &[ - SecretField { - kind: SecretKind::KeyInDefault, - name: "api_token", - }, - SecretField { - kind: SecretKind::StoreRef, - name: "vault", - }, - ]; + fn secret_fields() -> Vec { + vec![ + SecretField { + kind: SecretKind::KeyInDefault, + path: vec![SecretPathSegment::Field(Cow::Borrowed("api_token"))], + optional: false, + }, + SecretField { + kind: SecretKind::StoreRef, + path: vec![SecretPathSegment::Field(Cow::Borrowed("vault"))], + optional: false, + }, + ] + } } fn setup_project(manifest: &str, app_config: &str) -> (TempDir, PathBuf, PathBuf) { @@ -1864,10 +1953,13 @@ serve = "echo" greeting: String, } impl AppConfigMeta for SecretValidatorConfig { - const SECRET_FIELDS: &'static [SecretField] = &[SecretField { - kind: SecretKind::KeyInDefault, - name: "api_token", - }]; + fn secret_fields() -> Vec { + vec![SecretField { + kind: SecretKind::KeyInDefault, + path: vec![SecretPathSegment::Field(Cow::Borrowed("api_token"))], + optional: false, + }] + } } let app_config = r#" @@ -1895,6 +1987,224 @@ ids = ["default"] .expect("secret-field validator must be skipped on typed validate"); } + // ---------- Task 6: path-aware nested / array secret reflection ---------- + + // Real nested derive: integrations.datadome.server_side_key (KeyInDefault), + // partners[*].api_key (KeyInDefault). + #[derive(Debug, Deserialize, Serialize, Validate, edgezero_core::AppConfig)] + #[serde(deny_unknown_fields)] + struct DataDome { + #[secret] + server_side_key: String, + } + #[derive(Debug, Deserialize, Serialize, Validate, edgezero_core::AppConfig)] + #[serde(deny_unknown_fields)] + struct Integrations { + #[app_config(nested)] + #[validate(nested)] + datadome: DataDome, + } + #[derive(Debug, Deserialize, Serialize, Validate, edgezero_core::AppConfig)] + #[serde(deny_unknown_fields)] + struct Partner { + #[secret] + api_key: String, + } + #[derive(Debug, Deserialize, Serialize, Validate, edgezero_core::AppConfig)] + #[serde(deny_unknown_fields)] + struct NestedCliConfig { + #[app_config(nested)] + #[validate(nested)] + integrations: Integrations, + #[app_config(nested)] + #[validate(nested)] + partners: Vec, + } + + const NESTED_MANIFEST: &str = r#" +[app] +name = "demo-app" + +[adapters.axum.adapter] +crate = "crates/demo-axum" +[adapters.axum.commands] +build = "echo" +deploy = "echo" +serve = "echo" + +[stores.config] +ids = ["app_config"] + +[stores.secrets] +ids = ["default"] +"#; + + #[test] + fn validate_typed_accepts_well_formed_nested_and_array_secrets() { + let app_config = r#" +[integrations.datadome] +server_side_key = "dd_key" + +[[partners]] +api_key = "p0" + +[[partners]] +api_key = "p1" +"#; + let (_dir, manifest_path, _) = setup_project(NESTED_MANIFEST, app_config); + run_config_validate_typed::(&args_for(&manifest_path)) + .expect("well-formed nested + array secret config validates"); + } + + #[test] + fn validate_typed_reports_dotted_path_for_empty_array_secret() { + // partners[1].api_key is empty -> typed_secret_checks must reject it and + // name the INDEXED dotted path. + let app_config = r#" +[integrations.datadome] +server_side_key = "dd_key" + +[[partners]] +api_key = "p0" + +[[partners]] +api_key = "" +"#; + let (_dir, manifest_path, _) = setup_project(NESTED_MANIFEST, app_config); + let err = run_config_validate_typed::(&args_for(&manifest_path)) + .expect_err("empty array secret must be rejected"); + assert!( + err.contains("partners[1].api_key"), + "error names the indexed dotted path: {err}" + ); + } + + #[test] + fn validate_typed_rejects_missing_required_nested_leaf_at_deserialize() { + // A MISSING required nested leaf fails serde DESERIALIZATION + // before `typed_secret_checks`/`run_adapter_typed_checks` ever run — so + // this is deserialize-path coverage, NOT proof of the path-aware + // collector. The direct collector test below covers that. + let app_config = r#" +[integrations.datadome] + +[[partners]] +api_key = "p0" +"#; + let (_dir, manifest_path, _) = setup_project(NESTED_MANIFEST, app_config); + let err = run_config_validate_typed::(&args_for(&manifest_path)) + .expect_err("missing nested leaf must be rejected"); + assert!( + err.contains("server_side_key"), + "error names the missing nested leaf: {err}" + ); + } + + // Direct coverage of the path-aware TOML collector (the new logic). + // Bypasses `run_config_validate_typed` so deserialization does not preempt + // it — proves the collector itself resolves array indices and reports the + // dotted label for a present-but-invalid / missing leaf. + #[test] + fn collect_secret_leaves_resolves_array_indices_and_dotted_labels() { + let raw: Value = toml::from_str( + r#" +[[partners]] +api_key = "p0" + +[[partners]] +api_key = "p1" +"#, + ) + .expect("toml"); + + let field = SecretField { + kind: SecretKind::KeyInDefault, + path: vec![ + SecretPathSegment::Field(Cow::Borrowed("partners")), + SecretPathSegment::ArrayEach, + SecretPathSegment::Field(Cow::Borrowed("api_key")), + ], + optional: false, + }; + let leaves = collect_secret_leaves(&raw, &field).expect("collect"); + let labels: Vec<&str> = leaves.iter().map(|leaf| leaf.label.as_str()).collect(); + assert_eq!(labels, vec!["partners[0].api_key", "partners[1].api_key"]); + let values: Vec<&str> = leaves.iter().map(|leaf| leaf.value).collect(); + assert_eq!(values, vec!["p0", "p1"]); + } + + #[test] + fn collect_secret_leaves_errors_on_missing_required_leaf_with_dotted_label() { + let raw: Value = toml::from_str( + r#" +[integrations.datadome] +other = "x" +"#, + ) + .expect("toml"); + + let field = SecretField { + kind: SecretKind::KeyInDefault, + path: vec![ + SecretPathSegment::Field(Cow::Borrowed("integrations")), + SecretPathSegment::Field(Cow::Borrowed("datadome")), + SecretPathSegment::Field(Cow::Borrowed("server_side_key")), + ], + optional: false, + }; + let err = collect_secret_leaves(&raw, &field).expect_err("missing required leaf"); + assert!( + err.contains("integrations.datadome.server_side_key"), + "collector error names the dotted path: {err}" + ); + } + + #[derive(Debug, Deserialize, Serialize, Validate, edgezero_core::AppConfig)] + #[serde(deny_unknown_fields)] + struct Vaulted { + #[secret(store_ref = "vault")] + token: String, + #[secret(store_ref)] + vault: String, + } + #[derive(Debug, Deserialize, Serialize, Validate, edgezero_core::AppConfig)] + #[serde(deny_unknown_fields)] + struct NamedStoreCliConfig { + #[app_config(nested)] + #[validate(nested)] + vaulted: Vaulted, + } + + #[test] + fn validate_typed_accepts_nested_named_store_with_sibling() { + let manifest = r#" +[app] +name = "demo-app" + +[adapters.axum.adapter] +crate = "crates/demo-axum" +[adapters.axum.commands] +build = "echo" +deploy = "echo" +serve = "echo" + +[stores.config] +ids = ["app_config"] + +[stores.secrets] +ids = ["default", "named"] +default = "default" +"#; + let app_config = r#" +[vaulted] +token = "tok_key" +vault = "named" +"#; + let (_dir, manifest_path, _) = setup_project(manifest, app_config); + run_config_validate_typed::(&args_for(&manifest_path)) + .expect("nested named-store secret with a declared store validates"); + } + // ---------- Spin checks ---------- fn spin_manifest(extra_section: &str) -> String { @@ -2176,7 +2486,7 @@ ids = ["default"] // Regression: `#[secret(store_ref)]` values are logical // store ids (resolved at runtime), not Spin variable names — // they must not enter the Spin collision set. Earlier the - // walker treated every SECRET_FIELDS entry as a potential + // walker treated every secret_fields() entry as a potential // Spin var, so a perfectly valid `vault = "default"` plus a // config key whose flattened name happened to be `default` // would falsely trip a collision. @@ -2202,16 +2512,20 @@ ids = ["default"] vault: String, } impl AppConfigMeta for StoreRefRegressionConfig { - const SECRET_FIELDS: &'static [SecretField] = &[ - SecretField { - kind: SecretKind::KeyInDefault, - name: "api_token", - }, - SecretField { - kind: SecretKind::StoreRef, - name: "vault", - }, - ]; + fn secret_fields() -> Vec { + vec![ + SecretField { + kind: SecretKind::KeyInDefault, + path: vec![SecretPathSegment::Field(Cow::Borrowed("api_token"))], + optional: false, + }, + SecretField { + kind: SecretKind::StoreRef, + path: vec![SecretPathSegment::Field(Cow::Borrowed("vault"))], + optional: false, + }, + ] + } } let manifest = r#" @@ -2474,6 +2788,70 @@ deep = true /// The runtime extractor (`secret_walk`) reads that name to look up /// the resolved value in the secret store. Stripping the field would /// cause `ConfigOutOfDate` on every request after a push. + #[test] + fn build_config_envelope_preserves_nested_and_array_secret_names() { + use edgezero_core::blob_envelope::BlobEnvelope; + + // Push serialises the typed struct verbatim, so nested + array secret + // KEY NAMES must survive into envelope.data at their full path — the + // runtime walk reads them there. (`build_config_envelope` only needs + // `Serialize`.) + #[derive(Debug, Serialize)] + struct DataDome { + server_side_key: String, + } + #[derive(Debug, Serialize)] + struct Integrations { + datadome: DataDome, + } + #[derive(Debug, Serialize)] + struct Partner { + api_key: String, + } + #[derive(Debug, Serialize)] + struct NestedPushConfig { + integrations: Integrations, + partners: Vec, + } + + let typed = NestedPushConfig { + integrations: Integrations { + datadome: DataDome { + server_side_key: "dd_key".to_owned(), + }, + }, + partners: vec![ + Partner { + api_key: "p0".to_owned(), + }, + Partner { + api_key: "p1".to_owned(), + }, + ], + }; + + let json = build_config_envelope(&typed).expect("envelope serialises"); + let envelope: BlobEnvelope = serde_json::from_str(&json).expect("envelope parses"); + assert_eq!( + envelope.data["integrations"]["datadome"]["server_side_key"].as_str(), + Some("dd_key"), + "nested secret key name must survive at its path: {:?}", + envelope.data + ); + assert_eq!( + envelope.data["partners"][0]["api_key"].as_str(), + Some("p0"), + "array secret key name (element 0) must survive: {:?}", + envelope.data + ); + assert_eq!( + envelope.data["partners"][1]["api_key"].as_str(), + Some("p1"), + "array secret key name (element 1) must survive: {:?}", + envelope.data + ); + } + #[test] fn build_config_envelope_preserves_secret_field_values() { use edgezero_core::blob_envelope::BlobEnvelope; @@ -2744,10 +3122,13 @@ default = "one" greeting: String, } impl AppConfigMeta for SecretValidatorConfig { - const SECRET_FIELDS: &'static [SecretField] = &[SecretField { - kind: SecretKind::KeyInDefault, - name: "api_token", - }]; + fn secret_fields() -> Vec { + vec![SecretField { + kind: SecretKind::KeyInDefault, + path: vec![SecretPathSegment::Field(Cow::Borrowed("api_token"))], + optional: false, + }] + } } let app_config = r#" @@ -3295,6 +3676,82 @@ ids = ["default"] // Medium 1 — diff runs typed_secret_checks + adapter_typed_checks // ------------------------------------------------------------------- + /// A NESTED `#[secret]` that is present but empty must be caught by the + /// path-aware `typed_secret_checks` on `diff` — before any remote read — + /// and the error must name the dotted path. + #[test] + fn diff_typed_rejects_empty_nested_secret() { + #[derive(Debug, Deserialize, Serialize, Validate)] + #[serde(deny_unknown_fields)] + struct DiffInner { + server_side_key: String, + } + #[derive(Debug, Deserialize, Serialize, Validate)] + #[serde(deny_unknown_fields)] + struct DiffNestedConfig { + greeting: String, + #[validate(nested)] + integrations: DiffInner, + } + impl AppConfigMeta for DiffNestedConfig { + fn secret_fields() -> Vec { + vec![SecretField { + kind: SecretKind::KeyInDefault, + path: vec![ + SecretPathSegment::Field(Cow::Borrowed("integrations")), + SecretPathSegment::Field(Cow::Borrowed("server_side_key")), + ], + optional: false, + }] + } + } + + let app_config = r#" +greeting = "hello" + +[integrations] +server_side_key = "" +"#; + let manifest = r#" +[app] +name = "demo-app" + +[adapters.axum.adapter] +crate = "crates/demo-axum" +[adapters.axum.commands] +build = "echo" +deploy = "echo" +serve = "echo" + +[stores.config] +ids = ["app_config"] + +[stores.secrets] +ids = ["default"] +"#; + let (_dir, manifest_path, _) = setup_project(manifest, app_config); + let diff_args = ConfigDiffArgs { + adapter: "axum".to_owned(), + app_config: None, + exit_code: false, + format: DiffFormat::Unified, + key: None, + local: false, + manifest: manifest_path.clone(), + no_env: true, + runtime_config: None, + store: None, + }; + // The nested empty secret must be rejected by the path-aware + // typed_secret_checks before the remote-read step, naming the path. + let err = run_config_diff_typed::(&diff_args) + .expect_err("empty nested #[secret] must be rejected by diff typed_secret_checks"); + assert!( + err.contains("integrations.server_side_key") && err.contains("non-empty"), + "error names the nested dotted secret path: {err}" + ); + } + /// Medium 1 — spec 3.3.2: `run_config_diff_typed` must run the same /// structural checks as push, including `typed_secret_checks`. A /// `#[secret]` field that is present but empty must be rejected even @@ -3313,10 +3770,13 @@ ids = ["default"] greeting: String, } impl AppConfigMeta for DiffSecretConfig { - const SECRET_FIELDS: &'static [SecretField] = &[SecretField { - kind: SecretKind::KeyInDefault, - name: "api_token", - }]; + fn secret_fields() -> Vec { + vec![SecretField { + kind: SecretKind::KeyInDefault, + path: vec![SecretPathSegment::Field(Cow::Borrowed("api_token"))], + optional: false, + }] + } } let app_config_empty_secret = r#" diff --git a/crates/edgezero-core/src/app.rs b/crates/edgezero-core/src/app.rs index ea5ac161..7fc32b50 100644 --- a/crates/edgezero-core/src/app.rs +++ b/crates/edgezero-core/src/app.rs @@ -127,6 +127,14 @@ pub trait Hooks { App::default_name() } + /// When `true`, an adapter's `run_app` skips its own logger initialization; + /// the app is responsible for installing a `log` backend. Default `false`. + #[must_use] + #[inline] + fn owns_logging() -> bool { + false + } + /// Build the router service for the application. fn routes() -> RouterService; @@ -240,6 +248,11 @@ mod tests { assert_eq!(app.name(), App::default_name()); } + #[test] + fn default_hooks_do_not_own_logging() { + assert!(!DefaultHooks::owns_logging()); + } + #[test] fn default_hooks_use_default_name_and_into_router() { let app = DefaultHooks::build_app(); diff --git a/crates/edgezero-core/src/app_config.rs b/crates/edgezero-core/src/app_config.rs index de85aa15..2cd16d90 100644 --- a/crates/edgezero-core/src/app_config.rs +++ b/crates/edgezero-core/src/app_config.rs @@ -14,6 +14,7 @@ //! [`load_app_config_raw_with_options`]. use std::any; +use std::borrow::Cow; use std::collections::HashMap; use std::env; use std::fs; @@ -27,24 +28,61 @@ use toml::value::Datetime; use toml::Value; use validator::{Validate, ValidationErrors}; -/// Per-field metadata emitted by `#[derive(AppConfig)]`. The -/// derive enumerates every field annotated with `#[secret]` / -/// `#[secret(store_ref)]`; `config validate` and `config push` -/// reflect over this array to gate secret-aware behaviour. -pub trait AppConfigMeta { - /// Every `#[secret]` / `#[secret(store_ref)]` field on the struct. - const SECRET_FIELDS: &'static [SecretField]; +/// One segment of a [`SecretField`] path. +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum SecretPathSegment { + /// Every element of an array/`Vec` at this position. + ArrayEach, + /// An object key — a Rust field name, verbatim (no `serde(rename)`). + Field(Cow<'static, str>), } /// One field's worth of secret-annotation metadata. -#[derive(Clone, Copy, Debug, Eq, PartialEq)] +/// +/// The `path` locates the secret leaf from the config root. A top-level +/// scalar has a length-1 path `[Field("api_token")]`. +#[derive(Clone, Debug, Eq, PartialEq)] pub struct SecretField { - /// Whether the field's value is a key in the default secret store - /// or the logical id of a `[stores.secrets]` entry. + /// Which secret-store resolution this field participates in. pub kind: SecretKind, - /// Rust field name verbatim (no `serde(rename)` translation — - /// `#[secret]` rejects renames at compile time). - pub name: &'static str, + /// `true` for `#[secret]` on `Option`: an absent leaf is + /// skipped by the runtime walk instead of erroring. + pub optional: bool, + /// Path from the config root to the secret leaf. + pub path: Vec, +} + +impl SecretField { + /// Human-readable dotted path for error messages and CLI output. + /// `ArrayEach` renders as `[*]` (the static form); the runtime walk + /// renders per-index `[n]` as it descends. + #[inline] + #[must_use] + pub fn dotted_path(&self) -> String { + let mut out = String::new(); + for segment in &self.path { + match segment { + SecretPathSegment::Field(name) => { + if !out.is_empty() { + out.push('.'); + } + out.push_str(name); + } + SecretPathSegment::ArrayEach => out.push_str("[*]"), + } + } + out + } +} + +/// Per-field metadata emitted by `#[derive(AppConfig)]`. `config validate` +/// / `config push` and the runtime secret walk reflect over this to gate +/// secret-aware behaviour. +pub trait AppConfigMeta { + /// Every `#[secret]` / `#[secret(store_ref)]` leaf on the struct, + /// including those reached through `#[app_config(nested)]` children, + /// each carrying its full path from this struct's root. + fn secret_fields() -> Vec; } /// Discriminator on a [`SecretField`] capturing which secret-store @@ -208,23 +246,75 @@ pub fn validate_excluding_secrets( let Err(mut errors) = result else { return Ok(()); }; - // validator 0.20 exposes errors_mut() -> &mut HashMap, ValidationErrorsKind>. - // `bag.remove(field.name)` works because `field.name` is `&'static str` - // and `Cow<'static, str>: Borrow` (the earlier comment cited the - // wrong key type — this is the corrected form). - let bag = errors.errors_mut(); - for field in C::SECRET_FIELDS { + for field in C::secret_fields() { if matches!(field.kind, SecretKind::StoreRef) { continue; // store_id field; validator stays } - bag.remove(field.name); + prune_secret_leaf(&mut errors, &field.path); } - if bag.is_empty() { + if errors.errors().is_empty() { return Ok(()); } Err(errors) } +/// Remove the per-field validator error for the secret leaf at `path`, +/// descending `ValidationErrorsKind::Struct`/`List` containers, and prune any +/// container that becomes empty so a fully-cleared branch disappears. Without +/// the prune, an empty `Struct`/`List` marker would keep `errors` non-empty and +/// make `validate_excluding_secrets` wrongly return `Err`. +fn prune_secret_leaf(errors: &mut ValidationErrors, path: &[SecretPathSegment]) { + use validator::ValidationErrorsKind; + + let Some((head, rest)) = path.split_first() else { + return; + }; + let SecretPathSegment::Field(name) = head else { + // `ArrayEach` only appears immediately after a `Field` (the root is + // always a struct), so it is consumed by the peek below, never a head. + return; + }; + + // Leaf reached: drop the validator error keyed by this field name. + if rest.is_empty() { + errors.errors_mut().remove(name.as_ref()); + return; + } + + // A `Field` immediately followed by `ArrayEach` targets a `List` nested + // under the field's key; consume the `ArrayEach` here so the recursive + // descent sees each element's own remaining path. + let (kind_is_array, tail) = + if let Some((SecretPathSegment::ArrayEach, array_tail)) = rest.split_first() { + (true, array_tail) + } else { + (false, rest) + }; + + let mut clear = false; + if kind_is_array { + if let Some(ValidationErrorsKind::List(items)) = errors.errors_mut().get_mut(name.as_ref()) + { + for inner in items.values_mut() { + prune_secret_leaf(inner, tail); + } + items.retain(|_, inner| !inner.errors().is_empty()); + clear = items.is_empty(); + } + } + if !kind_is_array { + if let Some(ValidationErrorsKind::Struct(inner)) = + errors.errors_mut().get_mut(name.as_ref()) + { + prune_secret_leaf(inner, tail); + clear = inner.errors().is_empty(); + } + } + if clear { + errors.errors_mut().remove(name.as_ref()); + } +} + /// Load and validate a typed app-config from `.toml`. /// /// `env_overlay` is on by default; pass [`AppConfigLoadOptions`] @@ -618,7 +708,9 @@ mod tests { } impl AppConfigMeta for FixtureConfig { - const SECRET_FIELDS: &'static [SecretField] = &[]; + fn secret_fields() -> Vec { + vec![] + } } fn write_fixture(contents: &str) -> NamedTempFile { @@ -1104,7 +1196,9 @@ greeting = "hello" } // Hand-rolled AppConfigMeta — matches the same shape as the rest of this test. impl AppConfigMeta for Fixture { - const SECRET_FIELDS: &'static [SecretField] = &[]; + fn secret_fields() -> Vec { + vec![] + } } impl validator::Validate for Fixture { fn validate(&self) -> Result<(), validator::ValidationErrors> { @@ -1136,7 +1230,9 @@ greeting = "hello" greeting: String, } impl AppConfigMeta for Fixture { - const SECRET_FIELDS: &'static [SecretField] = &[]; + fn secret_fields() -> Vec { + vec![] + } } let cfg = Fixture { greeting: "hello".into(), @@ -1154,10 +1250,13 @@ greeting = "hello" greeting: String, } impl AppConfigMeta for Fixture { - const SECRET_FIELDS: &'static [SecretField] = &[SecretField { - name: "api_token", - kind: SecretKind::KeyInDefault, - }]; + fn secret_fields() -> Vec { + vec![SecretField { + kind: SecretKind::KeyInDefault, + path: vec![SecretPathSegment::Field(Cow::Borrowed("api_token"))], + optional: false, + }] + } } let cfg = Fixture { api_token: "short".into(), @@ -1179,10 +1278,13 @@ greeting = "hello" greeting: String, } impl AppConfigMeta for Fixture { - const SECRET_FIELDS: &'static [SecretField] = &[SecretField { - name: "api_token", - kind: SecretKind::KeyInDefault, - }]; + fn secret_fields() -> Vec { + vec![SecretField { + kind: SecretKind::KeyInDefault, + path: vec![SecretPathSegment::Field(Cow::Borrowed("api_token"))], + optional: false, + }] + } } let cfg = Fixture { api_token: "x".into(), @@ -1199,10 +1301,13 @@ greeting = "hello" store_id: String, } impl AppConfigMeta for Fixture { - const SECRET_FIELDS: &'static [SecretField] = &[SecretField { - name: "store_id", - kind: SecretKind::StoreRef, - }]; + fn secret_fields() -> Vec { + vec![SecretField { + kind: SecretKind::StoreRef, + path: vec![SecretPathSegment::Field(Cow::Borrowed("store_id"))], + optional: false, + }] + } } let cfg = Fixture { store_id: "short".into(), @@ -1210,4 +1315,210 @@ greeting = "hello" // StoreRef keeps its validator — short store_id still fails. validate_excluding_secrets(&cfg).unwrap_err(); } + + #[test] + fn validate_excluding_secrets_prunes_nested_secret_leaf_validator() { + #[derive(Validate)] + struct Inner { + #[validate(length(min = 100))] + server_side_key: String, // holds a short KEY NAME at push time + } + #[derive(Validate)] + struct Outer { + #[validate(nested)] + integrations: Inner, + } + impl AppConfigMeta for Outer { + fn secret_fields() -> Vec { + vec![SecretField { + kind: SecretKind::KeyInDefault, + path: vec![ + SecretPathSegment::Field(Cow::Borrowed("integrations")), + SecretPathSegment::Field(Cow::Borrowed("server_side_key")), + ], + optional: false, + }] + } + } + + let cfg = Outer { + integrations: Inner { + server_side_key: "dd_key".to_owned(), // 6 chars < 100 + }, + }; + // The only failure is the nested secret leaf's validator -> pruned -> Ok. + validate_excluding_secrets(&cfg).unwrap(); + } + + #[test] + fn validate_excluding_secrets_keeps_nested_non_secret_failures() { + #[derive(Validate)] + struct Inner { + #[validate(length(min = 100))] + note: String, // NON-secret, must still fail + #[validate(length(min = 100))] + server_side_key: String, + } + #[derive(Validate)] + struct Outer { + #[validate(nested)] + integrations: Inner, + } + impl AppConfigMeta for Outer { + fn secret_fields() -> Vec { + vec![SecretField { + kind: SecretKind::KeyInDefault, + path: vec![ + SecretPathSegment::Field(Cow::Borrowed("integrations")), + SecretPathSegment::Field(Cow::Borrowed("server_side_key")), + ], + optional: false, + }] + } + } + + let cfg = Outer { + integrations: Inner { + server_side_key: "dd_key".to_owned(), + note: "short".to_owned(), + }, + }; + validate_excluding_secrets(&cfg).unwrap_err(); // `note` still fails + } + + #[test] + fn validate_excluding_secrets_prunes_array_secret_leaf_keeps_siblings() { + #[derive(Validate)] + struct Outer { + #[validate(nested)] + partners: Vec, + } + #[derive(Validate)] + struct Partner { + #[validate(length(min = 100))] + api_key: String, // secret leaf (a key NAME at push time) + #[validate(length(min = 100))] + label: String, // NON-secret sibling + } + impl AppConfigMeta for Outer { + fn secret_fields() -> Vec { + vec![SecretField { + kind: SecretKind::KeyInDefault, + path: vec![ + SecretPathSegment::Field(Cow::Borrowed("partners")), + SecretPathSegment::ArrayEach, + SecretPathSegment::Field(Cow::Borrowed("api_key")), + ], + optional: false, + }] + } + } + + // Every element fails BOTH validators at push time. + let cfg = Outer { + partners: vec![ + Partner { + api_key: "k0".to_owned(), + label: "s".to_owned(), + }, + Partner { + api_key: "k1".to_owned(), + label: "s".to_owned(), + }, + ], + }; + // `api_key` (secret) pruned from every List element; `label` + // (non-secret) survives in every element -> overall Err. + let err = validate_excluding_secrets(&cfg).expect_err("non-secret siblings still fail"); + let rendered = format!("{err:?}"); + assert!( + rendered.contains("label"), + "non-secret sibling must survive" + ); + assert!( + !rendered.contains("api_key"), + "secret leaf must be pruned from every array element" + ); + } + + #[test] + fn validate_excluding_secrets_prunes_array_all_secret_failures_to_ok() { + #[derive(Validate)] + struct Outer { + #[validate(nested)] + partners: Vec, + } + #[derive(Validate)] + struct Partner { + #[validate(length(min = 100))] + api_key: String, // the ONLY validated field, and it's the secret leaf + } + impl AppConfigMeta for Outer { + fn secret_fields() -> Vec { + vec![SecretField { + kind: SecretKind::KeyInDefault, + path: vec![ + SecretPathSegment::Field(Cow::Borrowed("partners")), + SecretPathSegment::ArrayEach, + SecretPathSegment::Field(Cow::Borrowed("api_key")), + ], + optional: false, + }] + } + } + + // Every element's only failure is the secret leaf -> each List element + // clears -> the empty List is pruned -> `partners` removed -> Ok. + let cfg = Outer { + partners: vec![ + Partner { + api_key: "k0".to_owned(), + }, + Partner { + api_key: "k1".to_owned(), + }, + ], + }; + validate_excluding_secrets(&cfg).expect( + "an array branch whose only failures are secret leaves must fully prune to Ok(())", + ); + } + + #[test] + fn dotted_path_renders_nested_and_array_segments() { + use super::{SecretField, SecretKind, SecretPathSegment::*}; + use std::borrow::Cow; + + let top = SecretField { + kind: SecretKind::KeyInDefault, + path: vec![Field(Cow::Borrowed("api_token"))], + optional: false, + }; + assert_eq!(top.dotted_path(), "api_token"); + + let nested = SecretField { + kind: SecretKind::KeyInDefault, + path: vec![ + Field(Cow::Borrowed("integrations")), + Field(Cow::Borrowed("datadome")), + Field(Cow::Borrowed("server_side_key")), + ], + optional: false, + }; + assert_eq!( + nested.dotted_path(), + "integrations.datadome.server_side_key" + ); + + let array = SecretField { + kind: SecretKind::KeyInDefault, + path: vec![ + Field(Cow::Borrowed("partners")), + ArrayEach, + Field(Cow::Borrowed("api_key")), + ], + optional: false, + }; + assert_eq!(array.dotted_path(), "partners[*].api_key"); + } } diff --git a/crates/edgezero-core/src/extractor.rs b/crates/edgezero-core/src/extractor.rs index 9bb4086f..8b3fc941 100644 --- a/crates/edgezero-core/src/extractor.rs +++ b/crates/edgezero-core/src/extractor.rs @@ -1,11 +1,14 @@ +use std::any; +use std::future::Future; use std::ops::{Deref, DerefMut}; +use std::pin::Pin; use async_trait::async_trait; use http::header; use serde::de::DeserializeOwned; use validator::Validate; -use crate::app_config::{AppConfigMeta, SecretKind}; +use crate::app_config::{AppConfigMeta, SecretField, SecretKind, SecretPathSegment}; use crate::blob_envelope::BlobEnvelope; use crate::config_store::ConfigStoreHandle; use crate::context::RequestContext; @@ -528,6 +531,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 @@ -820,7 +883,7 @@ where Ok(cfg) } -/// Walk `C::SECRET_FIELDS` and replace each `#[secret]` key NAME in `data` +/// Walk `C::secret_fields()` and replace each `#[secret]` key NAME in `data` /// with the resolved secret VALUE from the appropriate secret store. /// /// `StoreRef` fields are skipped — their value is a store id, not a key. @@ -828,68 +891,159 @@ async fn secret_walk(ctx: &RequestContext, data: &mut serde_json::Value) -> R where C: AppConfigMeta, { - let data_obj = data - .as_object_mut() - .ok_or_else(|| EdgeError::internal(anyhow::anyhow!("blob `data` is not a JSON object")))?; - for field in C::SECRET_FIELDS { - let key_name = data_obj - .get(field.name) - .and_then(|val| val.as_str()) - .ok_or_else(|| { + 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<'walk>( + ctx: &'walk RequestContext, + node: &'walk mut serde_json::Value, + field: &'walk SecretField, + remaining: &'walk [SecretPathSegment], + rendered: String, +) -> Pin> + 'walk>> { + Box::pin(async move { + match remaining.split_first() { + // Leaf reached: `node` is the PARENT object; the last Field is the key. + Some((SecretPathSegment::Field(name), [])) => { + resolve_leaf(ctx, node, field, name.as_ref(), &rendered).await + } + // Descend into an object key. + Some((SecretPathSegment::Field(name), rest)) => { + let next_rendered = join_field(&rendered, name.as_ref()); + match node.get_mut(name.as_ref()) { + // Absent optional subtree: key missing OR serialized as null. + None | Some(serde_json::Value::Null) if field.optional => Ok(()), + Some(child) => { + resolve_secret_field(ctx, child, field, rest, next_rendered).await + } + None => Err(EdgeError::config_out_of_date( + format!("missing or non-object value at `{next_rendered}`"), + next_rendered, + )), + } + } + // Iterate every array element. + Some((SecretPathSegment::ArrayEach, rest)) => { + let Some(items) = node.as_array_mut() else { + if field.optional { + return Ok(()); + } + return Err(EdgeError::config_out_of_date( + format!("expected an array at `{rendered}`"), + rendered, + )); + }; + for (idx, item) in items.iter_mut().enumerate() { + let indexed = format!("{rendered}[{idx}]"); + resolve_secret_field(ctx, item, field, rest, indexed).await?; + } + Ok(()) + } + None => Ok(()), + } + }) +} + +fn join_field(prefix: &str, name: &str) -> String { + if prefix.is_empty() { + name.to_owned() + } else { + format!("{prefix}.{name}") + } +} + +/// Resolve one leaf: `parent` is the innermost containing object; `key` is the +/// secret field name; `store_ref_field` (for `KeyInNamedStore`) is a sibling +/// within `parent`. +async fn resolve_leaf( + ctx: &RequestContext, + parent: &mut serde_json::Value, + field: &SecretField, + key: &str, + rendered_parent: &str, +) -> Result<(), EdgeError> { + if matches!(field.kind, SecretKind::StoreRef) { + return Ok(()); // store id, not a secret key + } + let leaf_path = join_field(rendered_parent, key); + + let Some(parent_obj) = parent.as_object_mut() else { + if field.optional { + return Ok(()); + } + return Err(EdgeError::config_out_of_date( + format!("expected an object containing `{key}` at `{rendered_parent}`"), + leaf_path, + )); + }; + + let key_name = match parent_obj.get(key) { + Some(serde_json::Value::String(name)) => name.clone(), + // An optional secret is absent when the key is MISSING *or* serialized + // as JSON `null`. serde emits `Option::None` as `null` (and `#[secret]` + // bans `skip_serializing_if`, so the key is never omitted), so both + // cases must skip — not just the missing-key case. + None | Some(serde_json::Value::Null) if field.optional => return Ok(()), + _ => { + return Err(EdgeError::config_out_of_date( + format!("missing or non-string value at `{leaf_path}`"), + leaf_path, + )) + } + }; + + let (bound, resolved_store_id) = match field.kind { + SecretKind::KeyInDefault => { + let bound = ctx.secret_store_default().ok_or_else(|| { EdgeError::config_out_of_date( - format!("missing or non-string value at `{}`", field.name), - field.name.to_owned(), + format!( + "secret field `{leaf_path}` has kind KeyInDefault but no default secret \ + store is registered" + ), + leaf_path.clone(), ) - })? - .to_owned(); - let (bound, resolved_store_id) = match field.kind { - SecretKind::KeyInDefault => { - let bound = ctx.secret_store_default().ok_or_else(|| { - EdgeError::config_out_of_date( - format!( - "secret field `{}` has kind KeyInDefault but no default secret \ - store is registered", - field.name, - ), - field.name.to_owned(), - ) - })?; - let id = bound.store_name().to_owned(); - (bound, id) - } - SecretKind::StoreRef => continue, - SecretKind::KeyInNamedStore { store_ref_field } => { - let store_id_str = data_obj - .get(store_ref_field) - .and_then(|val| val.as_str()) - .ok_or_else(|| { - EdgeError::config_out_of_date( - format!( - "missing store_ref `{store_ref_field}` for secret field `{}`", - field.name - ), - field.name.to_owned(), - ) - })? - .to_owned(); - let bound = ctx.secret_store(&store_id_str).ok_or_else(|| { + })?; + let id = bound.store_name().to_owned(); + (bound, id) + } + SecretKind::StoreRef => return Ok(()), + SecretKind::KeyInNamedStore { store_ref_field } => { + let store_id_str = parent_obj + .get(store_ref_field) + .and_then(|val| val.as_str()) + .ok_or_else(|| { EdgeError::config_out_of_date( format!( - "blob declared store_ref `{store_id_str}` but \ - [stores.secrets] has no such id" + "missing store_ref `{store_ref_field}` for secret field `{leaf_path}`" ), - field.name.to_owned(), + leaf_path.clone(), ) - })?; - (bound, store_id_str) - } - }; - let secret = bound - .require_str(&key_name) - .await - .map_err(|err| map_secret_error(err, field.name, &resolved_store_id, &key_name))?; - data_obj.insert(field.name.to_owned(), serde_json::Value::String(secret)); - } + })? + .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(()) } @@ -976,7 +1130,7 @@ fn first_violating_field(errors: &validator::ValidationErrors) -> Option #[cfg(test)] mod tests { use super::*; - use crate::app_config::{AppConfigMeta, SecretField, SecretKind}; + use crate::app_config::{AppConfigMeta, SecretField, SecretKind, SecretPathSegment}; use crate::blob_envelope::BlobEnvelope; use crate::body::Body; use crate::config_store::{ConfigStore, ConfigStoreError, ConfigStoreHandle}; @@ -987,10 +1141,16 @@ mod tests { use crate::store_registry::StoreRegistry; use futures::executor::block_on; use serde::{Deserialize, Serialize}; + use std::borrow::Cow; use std::collections::HashMap; use std::sync::Arc; use validator::Validate; + #[derive(Clone, Debug, PartialEq)] + struct AppStateFixture { + name: String, + } + #[derive(Debug, Deserialize, PartialEq)] struct FormData { age: Option, @@ -1047,7 +1207,9 @@ mod tests { } impl AppConfigMeta for FixtureCfg { - const SECRET_FIELDS: &'static [SecretField] = &[]; + fn secret_fields() -> Vec { + vec![] + } } // Fixture config type with one KeyInDefault secret field. Used by AppConfig tests. @@ -1060,10 +1222,75 @@ mod tests { } impl AppConfigMeta for SecretCfg { - const SECRET_FIELDS: &'static [SecretField] = &[SecretField { - name: "api_token", - kind: SecretKind::KeyInDefault, - }]; + fn secret_fields() -> Vec { + vec![SecretField { + kind: SecretKind::KeyInDefault, + path: vec![SecretPathSegment::Field(Cow::Borrowed("api_token"))], + optional: false, + }] + } + } + + // Array leaf: partners[*].api_key + struct ArrayCfg; + impl AppConfigMeta for ArrayCfg { + fn secret_fields() -> Vec { + vec![SecretField { + kind: SecretKind::KeyInDefault, + path: vec![ + SecretPathSegment::Field(Cow::Borrowed("partners")), + SecretPathSegment::ArrayEach, + SecretPathSegment::Field(Cow::Borrowed("api_key")), + ], + optional: false, + }] + } + } + + // Nested KeyInNamedStore: vaulted.token resolves against the store named by + // its SIBLING vaulted.vault (the sibling-in-innermost-parent scoping rule). + struct NamedStoreCfg; + impl AppConfigMeta for NamedStoreCfg { + fn secret_fields() -> Vec { + vec![SecretField { + kind: SecretKind::KeyInNamedStore { + store_ref_field: "vault", + }, + path: vec![ + SecretPathSegment::Field(Cow::Borrowed("vaulted")), + SecretPathSegment::Field(Cow::Borrowed("token")), + ], + optional: false, + }] + } + } + + // Nested object leaf: integrations.datadome.server_side_key + struct NestedCfg; + impl AppConfigMeta for NestedCfg { + fn secret_fields() -> Vec { + vec![SecretField { + kind: SecretKind::KeyInDefault, + path: vec![ + SecretPathSegment::Field(Cow::Borrowed("integrations")), + SecretPathSegment::Field(Cow::Borrowed("datadome")), + SecretPathSegment::Field(Cow::Borrowed("server_side_key")), + ], + optional: false, + }] + } + } + + // Optional top-level leaf: maybe_key + struct OptionalCfg; + impl AppConfigMeta for OptionalCfg { + fn secret_fields() -> Vec { + vec![SecretField { + kind: SecretKind::KeyInDefault, + path: vec![SecretPathSegment::Field(Cow::Borrowed("maybe_key"))], + optional: true, + }] + } } fn ctx(body: Body, params: PathParams) -> RequestContext { @@ -2224,6 +2451,126 @@ mod tests { } } + // Build a RequestContext whose default secret store maps `default/{key}` -> + // `value`, for exercising `secret_walk` directly. + fn ctx_with_default_secret_store(key: &str, value: &str) -> RequestContext { + ctx_with_default_secret_store_map(&[(key, value)]) + } + + // Multi-entry variant of `ctx_with_default_secret_store`. + fn ctx_with_default_secret_store_map(entries: &[(&str, &str)]) -> RequestContext { + let store = InMemorySecretStore::new(entries.iter().map(|(key, value)| { + ( + format!("default/{key}"), + bytes::Bytes::from((*value).to_owned()), + ) + })); + let bound = BoundSecretStore::new(SecretHandle::new(Arc::new(store)), "default".to_owned()); + let registry: SecretRegistry = StoreRegistry::single_id("default".to_owned(), bound); + let mut request = request_builder() + .method(Method::GET) + .uri("/cfg") + .body(Body::empty()) + .expect("request"); + request.extensions_mut().insert(registry); + RequestContext::new(request, PathParams::default()) + } + + // Build a RequestContext whose secret store `store_id` maps + // `{store_id}/{key}` -> `value`, resolvable via `ctx.secret_store(store_id)`. + fn ctx_with_named_secret_store(store_id: &str, key: &str, value: &str) -> RequestContext { + let store = InMemorySecretStore::new([( + format!("{store_id}/{key}"), + bytes::Bytes::from(value.to_owned()), + )]); + let bound = BoundSecretStore::new(SecretHandle::new(Arc::new(store)), store_id.to_owned()); + let registry: SecretRegistry = StoreRegistry::single_id(store_id.to_owned(), bound); + let mut request = request_builder() + .method(Method::GET) + .uri("/cfg") + .body(Body::empty()) + .expect("request"); + request.extensions_mut().insert(registry); + RequestContext::new(request, PathParams::default()) + } + + #[test] + fn secret_walk_resolves_nested_object_leaf() { + let ctx = ctx_with_default_secret_store("dd_key", "resolved-dd"); + let mut data = serde_json::json!({ + "integrations": { "datadome": { "server_side_key": "dd_key" } } + }); + block_on(secret_walk::(&ctx, &mut data)).expect("walk"); + assert_eq!( + data["integrations"]["datadome"]["server_side_key"], + serde_json::json!("resolved-dd") + ); + } + + #[test] + fn secret_walk_resolves_each_array_element() { + let ctx = ctx_with_default_secret_store_map(&[("k0", "v0"), ("k1", "v1")]); + let mut data = serde_json::json!({ + "partners": [ { "api_key": "k0" }, { "api_key": "k1" } ] + }); + block_on(secret_walk::(&ctx, &mut data)).expect("walk"); + assert_eq!(data["partners"][0]["api_key"], serde_json::json!("v0")); + assert_eq!(data["partners"][1]["api_key"], serde_json::json!("v1")); + } + + #[test] + fn secret_walk_resolves_nested_named_store_via_sibling_in_parent() { + let ctx = ctx_with_named_secret_store("named", "tok_key", "TOK"); + let mut data = serde_json::json!({ + "vaulted": { "token": "tok_key", "vault": "named" } + }); + block_on(secret_walk::(&ctx, &mut data)).expect("walk"); + assert_eq!(data["vaulted"]["token"], serde_json::json!("TOK")); + // The store_ref sibling is left intact (it names a store, not a secret). + assert_eq!(data["vaulted"]["vault"], serde_json::json!("named")); + } + + #[test] + fn secret_walk_nested_named_store_missing_sibling_errors_with_dotted_path() { + let ctx = ctx_with_named_secret_store("named", "tok_key", "TOK"); + let mut data = serde_json::json!({ "vaulted": { "token": "tok_key" } }); // no `vault` + let err = block_on(secret_walk::(&ctx, &mut data)) + .expect_err("missing store_ref sibling"); + assert_eq!(err.status(), StatusCode::SERVICE_UNAVAILABLE); + assert!(err.to_string().contains("vaulted.token")); + } + + #[test] + fn secret_walk_skips_absent_optional_leaf() { + let ctx = ctx_with_default_secret_store("unused", "unused"); + let mut data = serde_json::json!({ "greeting": "hi" }); // no maybe_key + block_on(secret_walk::(&ctx, &mut data)).expect("absent optional is fine"); + assert!(data.get("maybe_key").is_none()); + } + + #[test] + fn secret_walk_skips_null_optional_leaf() { + // serde serializes `Option::None` as JSON `null` (the key is present, + // not omitted). The walk must skip a null optional leaf, not error it. + let ctx = ctx_with_default_secret_store("unused", "unused"); + let mut data = serde_json::json!({ "maybe_key": null }); + block_on(secret_walk::(&ctx, &mut data)) + .expect("null optional is skipped, not treated as non-string"); + assert_eq!(data["maybe_key"], serde_json::json!(null)); // left untouched + } + + #[test] + fn secret_walk_missing_required_nested_leaf_errors_with_dotted_path() { + let ctx = ctx_with_default_secret_store("dd_key", "resolved-dd"); + let mut data = serde_json::json!({ "integrations": { "datadome": {} } }); + let err = block_on(secret_walk::(&ctx, &mut data)) + .expect_err("missing required nested leaf"); + assert_eq!(err.status(), StatusCode::SERVICE_UNAVAILABLE); + assert!(err + .to_string() + .contains("integrations.datadome.server_side_key")); + } + #[test] fn app_config_named_reads_different_key() { struct KeyEchoStore; @@ -2327,10 +2674,13 @@ mod tests { } impl AppConfigMeta for SecretLen { - const SECRET_FIELDS: &'static [SecretField] = &[SecretField { - name: "api_token", - kind: SecretKind::KeyInDefault, - }]; + fn secret_fields() -> Vec { + vec![SecretField { + kind: SecretKind::KeyInDefault, + path: vec![SecretPathSegment::Field(Cow::Borrowed("api_token"))], + optional: false, + }] + } } struct BlobStore(String); @@ -2368,10 +2718,13 @@ mod tests { } impl AppConfigMeta for SecretLen { - const SECRET_FIELDS: &'static [SecretField] = &[SecretField { - name: "api_token", - kind: SecretKind::KeyInDefault, - }]; + fn secret_fields() -> Vec { + vec![SecretField { + kind: SecretKind::KeyInDefault, + path: vec![SecretPathSegment::Field(Cow::Borrowed("api_token"))], + optional: false, + }] + } } struct BlobStore(String); @@ -2400,4 +2753,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/router.rs b/crates/edgezero-core/src/router.rs index eae25a9f..5b9881de 100644 --- a/crates/edgezero-core/src/router.rs +++ b/crates/edgezero-core/src/router.rs @@ -8,7 +8,7 @@ use tower_service::Service; use crate::context::RequestContext; use crate::error::EdgeError; use crate::handler::{BoxHandler, IntoHandler, IntrospectionNeeds}; -use crate::http::{HandlerFuture, Method, Request, Response}; +use crate::http::{Extensions, HandlerFuture, Method, Request, Response}; use crate::introspection::{ManifestJson, RouteTable}; use crate::middleware::{BoxMiddleware, Middleware, Next}; use crate::params::PathParams; @@ -73,6 +73,9 @@ pub struct RouterBuilder { middlewares: Vec, route_info: Vec, routes: HashMap>, + /// App state registered via [`RouterBuilder::with_state`], keyed by type. + /// Cloned into every request's extensions at dispatch. + state_extensions: Extensions, } impl RouterBuilder { @@ -115,6 +118,7 @@ impl RouterBuilder { self.middlewares, route_index, self.manifest_json, + self.state_extensions, ) } @@ -193,6 +197,25 @@ impl RouterBuilder { self.manifest_json = Some(json.into()); self } + + /// Register a value cloned into every request's extensions before + /// dispatch, making it available to the [`State`] extractor and to + /// `RequestContext`-based handlers. + /// + /// Typically `T = Arc`. Registering the same `T` twice is + /// last-write-wins. Cost is one `T::clone` (an `Arc` bump for + /// `Arc`) per registered state per request. + /// + /// [`State`]: crate::extractor::State + #[must_use] + #[inline] + pub fn with_state(mut self, value: T) -> Self + where + T: Clone + Send + Sync + 'static, + { + self.state_extensions.insert(value); + self + } } struct RouterInner { @@ -200,6 +223,7 @@ struct RouterInner { middlewares: Vec, route_index: Arc<[RouteInfo]>, routes: HashMap>, + state_extensions: Extensions, } impl RouterInner { @@ -224,6 +248,12 @@ impl RouterInner { .extensions_mut() .insert(RouteTable(Arc::clone(&self.route_index))); } + // App-owned state registered via RouterBuilder::with_state. + // Runs after introspection inserts; `extend` overwrites by + // TypeId, so app state wins last-write on any collision. + request + .extensions_mut() + .extend(self.state_extensions.clone()); let ctx = RequestContext::new(request, params); let next = Next::new(&self.middlewares, entry.handler.as_ref()); next.run(ctx).await @@ -299,6 +329,7 @@ impl RouterService { middlewares: Vec, route_index: Arc<[RouteInfo]>, manifest_json: Option>, + state_extensions: Extensions, ) -> Self { Self { inner: Arc::new(RouterInner { @@ -306,6 +337,7 @@ impl RouterService { middlewares, route_index, routes, + state_extensions, }), } } @@ -772,4 +804,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 bb584cd5..18039b5a 100644 --- a/crates/edgezero-macros/Cargo.toml +++ b/crates/edgezero-macros/Cargo.toml @@ -25,7 +25,10 @@ validator = { workspace = true, features = ["derive"] } # `edgezero-core` re-exports `AppConfig`; the derive tests assert # against the trait/types over the re-export path the way downstream # users will. Cargo allows dev-dep cycles (only the main dep edge -# matters for build ordering). -edgezero-core = { workspace = true } +# matters for build ordering). `test-utils` exposes `InMemorySecretStore` +# so the end-to-end nested-secret test can drive the runtime secret walk. +async-trait = { workspace = true } +edgezero-core = { workspace = true, features = ["test-utils"] } +futures = { workspace = true } tempfile = { workspace = true } trybuild = { workspace = true } diff --git a/crates/edgezero-macros/src/app.rs b/crates/edgezero-macros/src/app.rs index 9d52e3a8..d9f2cc73 100644 --- a/crates/edgezero-macros/src/app.rs +++ b/crates/edgezero-macros/src/app.rs @@ -9,24 +9,82 @@ use syn::parse::{Parse, ParseStream}; use syn::{parse_macro_input, Ident, LitStr, Token}; use validator::Validate as _; +#[derive(Debug)] struct AppArgs { app_ident: Option, + owns_logging: Option, path: LitStr, + state: Option, } impl Parse for AppArgs { fn parse(input: ParseStream) -> syn::Result { let path: LitStr = input.parse()?; - let app_ident = if input.peek(Token![,]) { + let mut app_ident: Option = None; + let mut owns_logging: Option = None; + let mut state: Option = None; + let mut seen_keyword = false; + + while input.peek(Token![,]) { input.parse::()?; - Some(input.parse::()?) - } else { - None - }; + + // Keyword argument: `Ident = Value`. + if input.peek(Ident) && input.peek2(Token![=]) { + let key: Ident = input.parse()?; + input.parse::()?; + seen_keyword = true; + match key.to_string().as_str() { + "owns_logging" => { + if owns_logging.is_some() { + return Err(syn::Error::new( + key.span(), + "duplicate `owns_logging` argument", + )); + } + let value: syn::LitBool = input.parse()?; + owns_logging = Some(value.value); + } + "state" => { + if state.is_some() { + return Err(syn::Error::new(key.span(), "duplicate `state` argument")); + } + state = Some(input.parse::()?); + } + other => { + return Err(syn::Error::new( + key.span(), + format!( + "unknown `app!` argument `{other}`; expected `state` or `owns_logging`" + ), + )); + } + } + continue; + } + + // Bare identifier: the optional custom App type name, only before keywords. + if input.peek(Ident) { + if seen_keyword || app_ident.is_some() { + return Err(input.error( + "the custom App identifier must come immediately after the manifest path, before keyword arguments", + )); + } + app_ident = Some(input.parse::()?); + continue; + } + + return Err(input.error("expected a custom App identifier or `key = value` argument")); + } + if !input.is_empty() { return Err(input.error("unexpected tokens after app! macro arguments")); } - Ok(Self { app_ident, path }) + Ok(Self { + app_ident, + owns_logging, + path, + state, + }) } } @@ -150,12 +208,18 @@ pub fn expand_app(input: TokenStream) -> TokenStream { let stores_tokens = build_stores_tokens(&manifest); let manifest_path_lit = LitStr::new(&manifest_path.to_string_lossy(), Span::call_site()); + let owns_logging_lit = args.owns_logging.unwrap_or(false); + // Emitted only when `state = ` is given; `Option: ToTokens` + // renders `None` as nothing, so an app without `state` is unchanged. + let state_call = args.state.as_ref().map(|state_expr| { + quote! { builder = builder.with_state(#state_expr); } + }); - // The emitted `Hooks` impl below explicitly defines `configure` and - // `build_app` even though their bodies mirror the trait defaults. This is - // required because `missing_trait_methods` (restriction = deny) forbids - // relying on trait defaults in the impl. If `Hooks::configure` or - // `Hooks::build_app` defaults change, update these emitted bodies to match. + // The emitted `Hooks` impl below explicitly defines `configure`, + // `owns_logging`, and `build_app` even though their bodies mirror the trait + // defaults. This is required because `missing_trait_methods` (restriction = + // deny) forbids relying on trait defaults in the impl. If those `Hooks` + // defaults change, update these emitted bodies to match. let output = quote! { // Force a rebuild when the manifest file changes (include_bytes tracks it as a build input). const _: &[u8] = include_bytes!(#manifest_path_lit); @@ -169,6 +233,10 @@ pub fn expand_app(input: TokenStream) -> TokenStream { fn configure(_app: &mut edgezero_core::app::App) {} + fn owns_logging() -> bool { + #owns_logging_lit + } + fn name() -> &'static str { #app_name_lit } @@ -185,6 +253,7 @@ pub fn expand_app(input: TokenStream) -> TokenStream { pub fn build_router() -> edgezero_core::router::RouterService { let mut builder = edgezero_core::router::RouterService::builder(); builder = builder.with_manifest_json(#manifest_json_lit); + #state_call #(#middleware_tokens)* #(#route_tokens)* builder.build() @@ -266,7 +335,107 @@ fn route_for_method(method: &str, path: &LitStr, handler: &syn::ExprPath) -> Tok #[cfg(test)] mod tests { - use super::parse_handler_path; + use super::{parse_handler_path, AppArgs}; + use syn::parse_str; + + #[test] + fn app_args_parses_app_ident_then_keyword() { + let args: AppArgs = + parse_str(r#""edgezero.toml", MyApp, owns_logging = false"#).expect("parse"); + assert_eq!( + args.app_ident.map(|ident| ident.to_string()), + Some("MyApp".to_owned()) + ); + assert_eq!(args.owns_logging, Some(false)); + } + + #[test] + fn app_args_parses_owns_logging_true() { + let args: AppArgs = parse_str(r#""edgezero.toml", owns_logging = true"#).expect("parse"); + assert_eq!(args.owns_logging, Some(true)); + assert!(args.app_ident.is_none()); + } + + #[test] + fn app_args_parses_path_and_app_ident() { + let args: AppArgs = parse_str(r#""edgezero.toml", MyApp"#).expect("parse"); + assert_eq!( + args.app_ident.map(|ident| ident.to_string()), + Some("MyApp".to_owned()) + ); + assert_eq!(args.owns_logging, None); + } + + #[test] + fn app_args_parses_path_only() { + let args: AppArgs = parse_str(r#""edgezero.toml""#).expect("parse"); + assert_eq!(args.path.value(), "edgezero.toml"); + assert!(args.app_ident.is_none()); + assert_eq!(args.owns_logging, None); + assert!(args.state.is_none()); + } + + #[test] + fn app_args_parses_state_expr() { + let args: AppArgs = + parse_str(r#""edgezero.toml", state = crate::app_state()"#).expect("parse"); + let rendered = args.state.map(|expr| quote::quote!(#expr).to_string()); + assert_eq!(rendered, Some("crate :: app_state ()".to_owned())); + assert!(args.app_ident.is_none()); + assert_eq!(args.owns_logging, None); + } + + #[test] + fn app_args_parses_state_with_app_ident_and_owns_logging() { + let args: AppArgs = + parse_str(r#""edgezero.toml", MyApp, state = crate::app_state(), owns_logging = true"#) + .expect("parse"); + assert_eq!( + args.app_ident.map(|ident| ident.to_string()), + Some("MyApp".to_owned()) + ); + assert_eq!(args.owns_logging, Some(true)); + assert!(args.state.is_some()); + } + + #[test] + fn app_args_rejects_duplicate_state() { + let err = parse_str::(r#""edgezero.toml", state = a(), state = b()"#) + .expect_err("duplicate state"); + assert!(err.to_string().contains("duplicate `state`"), "got: {err}"); + } + + #[test] + fn app_args_rejects_duplicate_key() { + let err = + parse_str::(r#""edgezero.toml", owns_logging = true, owns_logging = false"#) + .expect_err("duplicate"); + assert!( + err.to_string().contains("duplicate `owns_logging`"), + "got: {err}" + ); + } + + #[test] + fn app_args_rejects_ident_after_keyword() { + let err = parse_str::(r#""edgezero.toml", owns_logging = true, MyApp"#) + .expect_err("ident after keyword"); + assert!( + err.to_string() + .contains("must come immediately after the manifest path"), + "got: {err}" + ); + } + + #[test] + fn app_args_rejects_unknown_key() { + let err = + parse_str::(r#""edgezero.toml", bogus = true"#).expect_err("unknown key"); + assert!( + err.to_string().contains("unknown `app!` argument `bogus`"), + "got: {err}" + ); + } #[test] fn parse_handler_path_accepts_absolute_crate_path() { diff --git a/crates/edgezero-macros/src/app_config.rs b/crates/edgezero-macros/src/app_config.rs index 3444cba9..e941d3f1 100644 --- a/crates/edgezero-macros/src/app_config.rs +++ b/crates/edgezero-macros/src/app_config.rs @@ -3,7 +3,7 @@ //! Scans the input struct for `#[secret]` / `#[secret(store_ref)]` //! field annotations, enforces the compile-time constraints, and //! emits `impl ::edgezero_core::app_config::AppConfigMeta` with the -//! `SECRET_FIELDS` array. +//! `secret_fields()` method. use std::collections::{HashMap, HashSet}; @@ -12,8 +12,8 @@ use proc_macro2::{Span, TokenStream as TokenStream2}; use quote::quote; use syn::punctuated::Punctuated; use syn::{ - parse_macro_input, Attribute, Data, DeriveInput, Expr, ExprLit, Field, Fields, Ident, Lit, - Meta, MetaNameValue, Path, Type, + parse_macro_input, Attribute, Data, DeriveInput, Expr, ExprLit, Field, Fields, GenericArgument, + Ident, Lit, Meta, MetaNameValue, Path, PathArguments, Type, }; /// Recognised `#[secret(...)]` annotation kinds. @@ -36,10 +36,24 @@ enum SecretAnnotation { struct FieldAnnotation { kind: SecretAnnotation, name: Ident, + /// `true` when the annotated field is `Option`. + optional: bool, +} + +/// A `#[app_config(nested)]` field to recurse into when emitting +/// `secret_fields()`. +struct NestedDescriptor<'field> { + /// The element type whose `secret_fields()` are prepended: the field + /// type for an object, or the `Vec`/slice element type for an array. + child_ty: &'field Type, + /// The Rust field name, emitted verbatim as a `Field` path segment. + field_name: Ident, + /// `true` when the field is `Vec` / `[T]` (emit `Field` + `ArrayEach`). + is_array: bool, } /// Inspect the input struct, emit `impl AppConfigMeta` with the -/// `SECRET_FIELDS` array. Errors surface as `compile_error!` tokens +/// `secret_fields()` method. Errors surface as `compile_error!` tokens /// substituted in place of the impl. #[inline] pub fn derive(tokens: TokenStream) -> TokenStream { @@ -50,29 +64,21 @@ pub fn derive(tokens: TokenStream) -> TokenStream { } fn expand(input: &DeriveInput) -> Result { - let struct_ident = &input.ident; - let (impl_generics, type_generics, where_clause) = input.generics.split_for_impl(); - let fields = struct_fields(input)?; // Enforce serde skip/flatten bans on EVERY field (not just secret ones). enforce_no_disallowed_serde_attrs_on_all_fields(fields)?; - let mut annotations: Vec = Vec::new(); - for field in fields { - if let Some(annotation) = scan_field(field)? { - annotations.push(annotation); - } - } + let (annotations, nested_descriptors) = classify_fields(fields)?; - // SECRET_FIELDS emits the Rust field name verbatim. A container- + // secret_fields() emits the Rust field name verbatim. A container- // level `#[serde(rename_all = ...)]` would desync that metadata // from what `config validate` (and the Spin collision check) sees - // on the wire — silently — so reject it whenever any - // secret field is present. Structs with no secret fields are - // unaffected: SECRET_FIELDS is empty and the validator never - // compares names. - if !annotations.is_empty() { + // on the wire — silently — so reject it whenever any secret field is + // present, whether direct or reached through a nested child. Structs + // with no secret paths are unaffected: secret_fields() is empty and + // the validator never compares names. + if !annotations.is_empty() || !nested_descriptors.is_empty() { enforce_no_container_rename_all(&input.attrs)?; } @@ -125,8 +131,67 @@ fn expand(input: &DeriveInput) -> Result { } } - let entries = annotations.iter().map(|annotation| { + Ok(emit_impl(input, &annotations, &nested_descriptors)) +} + +/// Classify every field as a direct `#[secret]` annotation or a +/// `#[app_config(nested)]` recursion descriptor. A field may not be both. +fn classify_fields( + fields: &Punctuated, +) -> syn::Result<(Vec, Vec>)> { + let mut annotations: Vec = Vec::new(); + let mut nested_descriptors: Vec = Vec::new(); + for field in fields { + let is_nested = nested_optin(field)?; + match scan_field(field)? { + Some(_) if is_nested => { + return Err(syn::Error::new_spanned( + field, + "a field may not be both `#[secret]` and `#[app_config(nested)]`", + )); + } + Some(annotation) => annotations.push(annotation), + None if is_nested => { + // The emitter writes `Field(field_name)` verbatim, so a + // `#[serde(rename/flatten/skip*)]` on the nested parent would + // desync the path segment from the serialized key — banned on + // any secret path. + enforce_no_disallowed_serde_attrs(field)?; + let Some(field_name) = field.ident.clone() else { + return Err(syn::Error::new_spanned( + field, + "`#[app_config(nested)]` requires a named field", + )); + }; + let (child_ty, is_array) = nested_child_type(&field.ty); + nested_descriptors.push(NestedDescriptor { + child_ty, + field_name, + is_array, + }); + } + None => {} + } + } + Ok((annotations, nested_descriptors)) +} + +/// Emit `impl AppConfigMeta` (with the `secret_fields()` body), the +/// `AppConfigRoot` marker impl, and a per-child `AppConfigRoot` bound +/// assertion. +fn emit_impl( + input: &DeriveInput, + annotations: &[FieldAnnotation], + nested_descriptors: &[NestedDescriptor<'_>], +) -> TokenStream2 { + let struct_ident = &input.ident; + let (impl_generics, type_generics, where_clause) = input.generics.split_for_impl(); + + // Direct `#[secret]` leaves: length-1 `Field` path, `optional` set from + // `Option`. + let direct_entries = annotations.iter().map(|annotation| { let name_lit = annotation.name.to_string(); + let optional = annotation.optional; let kind_tokens = match &annotation.kind { SecretAnnotation::KeyInDefault => { quote!(::edgezero_core::app_config::SecretKind::KeyInDefault) @@ -143,26 +208,95 @@ fn expand(input: &DeriveInput) -> Result { }; quote! { ::edgezero_core::app_config::SecretField { - name: #name_lit, kind: #kind_tokens, + path: ::std::vec![::edgezero_core::app_config::SecretPathSegment::Field( + ::std::borrow::Cow::Borrowed(#name_lit) + )], + optional: #optional, } } }); - Ok(quote! { + // Nested children: prepend `Field(field)` (object) or `Field(field)` + + // `ArrayEach` (`Vec`/slice) onto every leaf the child reports. + let nested_pushes = nested_descriptors.iter().map(|descriptor| { + let field_lit = descriptor.field_name.to_string(); + let child_ty = descriptor.child_ty; + let prefix = if descriptor.is_array { + quote! { + ::std::vec![ + ::edgezero_core::app_config::SecretPathSegment::Field( + ::std::borrow::Cow::Borrowed(#field_lit) + ), + ::edgezero_core::app_config::SecretPathSegment::ArrayEach, + ] + } + } else { + quote! { + ::std::vec![ + ::edgezero_core::app_config::SecretPathSegment::Field( + ::std::borrow::Cow::Borrowed(#field_lit) + ), + ] + } + }; + quote! { + for mut __f in <#child_ty as ::edgezero_core::app_config::AppConfigMeta>::secret_fields() { + let mut __p = #prefix; + __p.append(&mut __f.path); + __f.path = __p; + __out.push(__f); + } + } + }); + + let secret_fields_body = if nested_descriptors.is_empty() { + quote! { ::std::vec![#(#direct_entries),*] } + } else { + quote! { + let mut __out: ::std::vec::Vec<::edgezero_core::app_config::SecretField> = + ::std::vec![#(#direct_entries),*]; + #(#nested_pushes)* + __out + } + }; + + // A nested child must go through `#[derive(AppConfig)]` — the + // `AppConfigRoot` marker — not merely impl `AppConfigMeta` by hand. + // The closure is never called, but coercing it to `fn()` type-checks + // its body, enforcing the bound with a clear error span per child. + let nested_child_tys: Vec<&Type> = nested_descriptors + .iter() + .map(|descriptor| descriptor.child_ty) + .collect(); + let root_assertion = if nested_child_tys.is_empty() { + quote! {} + } else { + quote! { + const _: fn() = || { + fn __assert_app_config_root<__T: ::edgezero_core::app_config::AppConfigRoot>() {} + #( __assert_app_config_root::<#nested_child_tys>(); )* + }; + } + }; + + quote! { + #root_assertion + #[automatically_derived] impl #impl_generics ::edgezero_core::app_config::AppConfigMeta for #struct_ident #type_generics #where_clause { - const SECRET_FIELDS: &'static [::edgezero_core::app_config::SecretField] = - &[#(#entries),*]; + fn secret_fields() -> ::std::vec::Vec<::edgezero_core::app_config::SecretField> { + #secret_fields_body + } } #[automatically_derived] impl #impl_generics ::edgezero_core::app_config::AppConfigRoot for #struct_ident #type_generics #where_clause {} - }) + } } /// Borrow the struct's named fields, or error with a clear message. @@ -212,10 +346,84 @@ fn scan_field(field: &Field) -> Result, syn::Error> { } let kind = parse_secret_kind(first)?; - enforce_scalar_string_type(field)?; + let optional = secret_string_optionality(&field.ty).ok_or_else(|| { + syn::Error::new_spanned( + &field.ty, + "`#[secret]` may only annotate `String` or `Option`", + ) + })?; + // A `#[secret(store_ref)]` value is a store id — structural, always + // present. `Option` there is undefined (an absent store cannot + // resolve its dependent `KeyInNamedStore` sibling), so reject it. + if optional && matches!(kind, SecretAnnotation::StoreRef) { + return Err(syn::Error::new_spanned( + &field.ty, + "`#[secret(store_ref)]` may not be `Option`: a store id is structural and must always be present", + )); + } enforce_no_disallowed_serde_attrs(field)?; - Ok(Some(FieldAnnotation { kind, name })) + Ok(Some(FieldAnnotation { + kind, + name, + optional, + })) +} + +/// Whether `field` carries `#[app_config(nested)]`. Returns `Err` (not +/// `false`) on a malformed `#[app_config(...)]` such as `#[app_config(bogus)]` +/// or an empty `#[app_config()]`, so a typo is a hard compile error rather +/// than a silently-ignored non-recursion (which would drop the child's +/// secrets). +fn nested_optin(field: &Field) -> syn::Result { + let mut found = false; + for attr in &field.attrs { + if !attr.path().is_ident("app_config") { + continue; + } + // Track whether THIS attribute actually named `nested`. A bare + // `#[app_config]` / empty `#[app_config()]` parses Ok with the closure + // never firing; without this guard it would leave `found` false and the + // field would be silently NOT recursed, dropping the child's secrets. + let mut this_attr_nested = false; + attr.parse_nested_meta(|meta| { + if meta.path.is_ident("nested") { + this_attr_nested = true; + Ok(()) + } else { + Err(meta.error("`#[app_config(...)]` only accepts `nested`")) + } + })?; + if !this_attr_nested { + return Err(syn::Error::new_spanned( + attr, + "`#[app_config]` requires an option; the only supported one is \ + `nested` (e.g. `#[app_config(nested)]`)", + )); + } + found = true; + } + Ok(found) +} + +/// The child element type to recurse into and whether it is an array element. +/// `Vec` / `[T]` -> (T, true); otherwise (`field_ty`, false). +fn nested_child_type(ty: &Type) -> (&Type, bool) { + if let Type::Path(type_path) = ty { + if let Some(last) = type_path.path.segments.last() { + if last.ident == "Vec" { + if let PathArguments::AngleBracketed(bracketed) = &last.arguments { + if let Some(GenericArgument::Type(inner)) = bracketed.args.first() { + return (inner, true); + } + } + } + } + } + if let Type::Slice(slice) = ty { + return (&slice.elem, true); + } + (ty, false) } /// Decode `#[secret]` (`KeyInDefault`), `#[secret(store_ref)]` @@ -258,18 +466,27 @@ fn parse_secret_kind(attr: &Attribute) -> Result { } } -/// `#[secret]` may only annotate a scalar string field. Per we -/// accept bare `String` only — generic or qualified forms (e.g. -/// `Option`, `Cow<'_, str>`) are intentionally rejected so -/// `cfg.api_token` resolves to a value at every call site. -fn enforce_scalar_string_type(field: &Field) -> Result<(), syn::Error> { - if !is_scalar_string_type(&field.ty) { - return Err(syn::Error::new_spanned( - &field.ty, - "`#[secret]` / `#[secret(store_ref)]` may only annotate a scalar string field (e.g. `String`)", - )); +/// Classify a `#[secret]` field's type: `String` -> `Some(false)`, +/// `Option` -> `Some(true)`, anything else (e.g. `Vec`, +/// `Cow<'_, str>`, non-string scalars) -> `None`. +fn secret_string_optionality(ty: &Type) -> Option { + if is_scalar_string_type(ty) { + return Some(false); } - Ok(()) + if let Type::Path(type_path) = ty { + if let Some(last) = type_path.path.segments.last() { + if last.ident == "Option" { + if let PathArguments::AngleBracketed(bracketed) = &last.arguments { + if let Some(GenericArgument::Type(inner)) = bracketed.args.first() { + if is_scalar_string_type(inner) { + return Some(true); + } + } + } + } + } + } + None } fn is_scalar_string_type(ty: &Type) -> bool { @@ -326,13 +543,14 @@ fn enforce_no_disallowed_serde_attrs_on_all_fields( Ok(()) } -/// Container-level guard: a struct that carries any `#[secret]` field -/// must not also carry `#[serde(rename_all = ...)]`. The derive emits -/// `SECRET_FIELDS` with Rust field names verbatim, but `rename_all` -/// would translate the on-the-wire key name (e.g. `kebab-case` → -/// `api-token`), silently desyncing the typed `config validate` secret -/// checks from what the deserialiser actually accepts. Reject this at -/// compile time so the desync can't ship. +/// Container-level guard: a struct that carries any `#[secret]` field or +/// any `#[app_config(nested)]` child must not also carry +/// `#[serde(rename_all = ...)]`. The derive emits `secret_fields()` paths +/// with Rust field names verbatim, but `rename_all` would translate the +/// on-the-wire key name (e.g. `kebab-case` → `api-token`), silently +/// desyncing the typed `config validate` secret checks from what the +/// deserialiser actually accepts. Reject this at compile time so the +/// desync can't ship. fn enforce_no_container_rename_all(attrs: &[Attribute]) -> Result<(), syn::Error> { for attr in attrs { if !attr.path().is_ident("serde") { @@ -348,7 +566,7 @@ fn enforce_no_container_rename_all(attrs: &[Attribute]) -> Result<(), syn::Error if offending { return Err(syn::Error::new_spanned( attr, - "`#[derive(AppConfig)]` rejects `#[serde(rename_all = ...)]` on structs with `#[secret]` fields: SECRET_FIELDS uses Rust field names verbatim, so a container rename would silently desync `config validate` from runtime deserialisation", + "`#[derive(AppConfig)]` rejects `#[serde(rename_all = ...)]` on structs with `#[secret]` fields or `#[app_config(nested)]` children: secret_fields() uses Rust field names verbatim, so a container rename would silently desync `config validate` from runtime deserialisation", )); } } @@ -380,7 +598,7 @@ fn enforce_no_disallowed_serde_attrs(field: &Field) -> Result<(), syn::Error> { "skip_serializing" => Some("skip_serializing"), // `skip_serializing_if = "..."` also omits the // field from round-trips (config push reads - // SECRET_FIELDS, then serialises the typed + // secret_fields(), then serialises the typed // struct), so reject it alongside the // unconditional skip family. "skip_serializing_if" => Some("skip_serializing_if"), diff --git a/crates/edgezero-macros/src/lib.rs b/crates/edgezero-macros/src/lib.rs index 17a572d9..3c5538b3 100644 --- a/crates/edgezero-macros/src/lib.rs +++ b/crates/edgezero-macros/src/lib.rs @@ -17,7 +17,7 @@ pub fn app(input: TokenStream) -> TokenStream { app::expand_app(input) } -#[proc_macro_derive(AppConfig, attributes(secret))] +#[proc_macro_derive(AppConfig, attributes(secret, app_config))] #[inline] pub fn app_config_derive(input: TokenStream) -> TokenStream { app_config::derive(input) diff --git a/crates/edgezero-macros/tests/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/crates/edgezero-macros/tests/app_config_derive.rs b/crates/edgezero-macros/tests/app_config_derive.rs index 3fe92fbc..ab40b573 100644 --- a/crates/edgezero-macros/tests/app_config_derive.rs +++ b/crates/edgezero-macros/tests/app_config_derive.rs @@ -3,7 +3,8 @@ #[cfg(test)] mod tests { - use edgezero_core::app_config::{AppConfigMeta as _, AppConfigRoot, SecretField, SecretKind}; + use edgezero_core::app_config::{AppConfigMeta, AppConfigRoot, SecretKind}; + use validator::Validate as _; #[derive(serde::Deserialize, validator::Validate, edgezero_core::AppConfig)] #[serde(deny_unknown_fields)] @@ -12,14 +13,13 @@ mod tests { } // The `#[secret]`-annotated fields below are exercised only via the - // `SECRET_FIELDS` associated constant the derive emits — Rust still - // counts them as "never read", so silence the dead-code lint at the - // struct level. + // `secret_fields()` method the derive emits — Rust still counts them + // as "never read", so silence the dead-code lint at the struct level. #[derive(serde::Deserialize, validator::Validate, edgezero_core::AppConfig)] #[serde(deny_unknown_fields)] #[expect( dead_code, - reason = "fields exist only to feed `#[derive(AppConfig)]`; the SECRET_FIELDS array reads them via the derive, not via Rust field access" + reason = "fields exist only to feed `#[derive(AppConfig)]`; secret_fields() reads them via the derive, not via Rust field access" )] struct ConfigKeyInDefault { _greeting: String, @@ -31,7 +31,7 @@ mod tests { #[serde(deny_unknown_fields)] #[expect( dead_code, - reason = "fields exist only to feed `#[derive(AppConfig)]`; the SECRET_FIELDS array reads them via the derive, not via Rust field access" + reason = "fields exist only to feed `#[derive(AppConfig)]`; secret_fields() reads them via the derive, not via Rust field access" )] struct ConfigStoreRef { _greeting: String, @@ -43,7 +43,7 @@ mod tests { #[serde(deny_unknown_fields)] #[expect( dead_code, - reason = "fields exist only to feed `#[derive(AppConfig)]`; the SECRET_FIELDS array reads them via the derive, not via Rust field access" + reason = "fields exist only to feed `#[derive(AppConfig)]`; secret_fields() reads them via the derive, not via Rust field access" )] struct ConfigBothKinds { _greeting: String, @@ -57,7 +57,7 @@ mod tests { #[serde(deny_unknown_fields)] #[expect( dead_code, - reason = "fields exist only to feed `#[derive(AppConfig)]`; the SECRET_FIELDS array reads them via the derive, not via Rust field access" + reason = "fields exist only to feed `#[derive(AppConfig)]`; secret_fields() reads them via the derive, not via Rust field access" )] struct ConfigKeyInNamedStore { #[secret(store_ref = "vault")] @@ -66,46 +66,99 @@ mod tests { vault: String, } + // Optional secret: `#[secret]` on `Option` -> `optional: true`. + #[derive(serde::Deserialize, validator::Validate, edgezero_core::AppConfig)] + #[serde(deny_unknown_fields)] + #[expect( + dead_code, + reason = "fields exist only to feed `#[derive(AppConfig)]`; secret_fields() reads them via the derive, not via Rust field access" + )] + struct ConfigOptionalSecret { + #[secret] + api_token: Option, + } + + // Nested object + array recursion. + #[derive(serde::Deserialize, validator::Validate, edgezero_core::AppConfig)] + #[serde(deny_unknown_fields)] + #[expect( + dead_code, + reason = "fields exist only to feed `#[derive(AppConfig)]`; secret_fields() reads them via the derive, not via Rust field access" + )] + struct DataDome { + #[secret] + server_side_key: String, + } + + #[derive(serde::Deserialize, validator::Validate, edgezero_core::AppConfig)] + #[serde(deny_unknown_fields)] + struct Integrations { + #[app_config(nested)] + #[validate(nested)] + datadome: DataDome, + } + + #[derive(serde::Deserialize, validator::Validate, edgezero_core::AppConfig)] + #[serde(deny_unknown_fields)] + #[expect( + dead_code, + reason = "fields exist only to feed `#[derive(AppConfig)]`; secret_fields() reads them via the derive, not via Rust field access" + )] + struct Partner { + #[secret] + api_key: String, + #[secret] + maybe: Option, + } + + #[derive(serde::Deserialize, validator::Validate, edgezero_core::AppConfig)] + #[serde(deny_unknown_fields)] + struct Settings { + #[app_config(nested)] + #[validate(nested)] + integrations: Integrations, + #[app_config(nested)] + #[validate(nested)] + partners: Vec, + } + + /// Reflect each derived `SecretField` down to the tuple the + /// assertions compare: `(dotted_path, kind, optional)`. + fn reflect() -> Vec<(String, SecretKind, bool)> { + C::secret_fields() + .into_iter() + .map(|field| (field.dotted_path(), field.kind, field.optional)) + .collect() + } + #[test] fn no_secret_annotation_yields_empty_secret_fields() { - assert!(ConfigNoSecrets::SECRET_FIELDS.is_empty()); + assert!(ConfigNoSecrets::secret_fields().is_empty()); } #[test] fn plain_secret_attribute_yields_key_in_default() { assert_eq!( - ConfigKeyInDefault::SECRET_FIELDS, - &[SecretField { - name: "api_token", - kind: SecretKind::KeyInDefault, - }] + reflect::(), + vec![("api_token".to_owned(), SecretKind::KeyInDefault, false)] ); } #[test] fn secret_store_ref_attribute_yields_store_ref() { assert_eq!( - ConfigStoreRef::SECRET_FIELDS, - &[SecretField { - name: "vault", - kind: SecretKind::StoreRef, - }] + reflect::(), + vec![("vault".to_owned(), SecretKind::StoreRef, false)] ); } #[test] fn both_secret_kinds_are_collected_in_source_order() { assert_eq!( - ConfigBothKinds::SECRET_FIELDS, - &[ - SecretField { - name: "api_token", - kind: SecretKind::KeyInDefault, - }, - SecretField { - name: "vault", - kind: SecretKind::StoreRef, - }, + reflect::(), + vec![ + ("api_token".to_owned(), SecretKind::KeyInDefault, false), + ("vault".to_owned(), SecretKind::StoreRef, false), ] ); } @@ -113,22 +166,54 @@ mod tests { #[test] fn key_in_named_store_attribute_yields_correct_secret_fields() { assert_eq!( - ConfigKeyInNamedStore::SECRET_FIELDS, - &[ - SecretField { - name: "api_token", - kind: SecretKind::KeyInNamedStore { + reflect::(), + vec![ + ( + "api_token".to_owned(), + SecretKind::KeyInNamedStore { store_ref_field: "vault", }, - }, - SecretField { - name: "vault", - kind: SecretKind::StoreRef, - }, + false, + ), + ("vault".to_owned(), SecretKind::StoreRef, false), ] ); } + #[test] + fn optional_string_secret_sets_optional_flag() { + assert_eq!( + reflect::(), + vec![("api_token".to_owned(), SecretKind::KeyInDefault, true)] + ); + } + + #[test] + fn nested_and_array_paths_are_emitted() { + let mut paths = reflect::(); + paths.sort_by(|left, right| left.0.cmp(&right.0)); + assert_eq!( + paths, + vec![ + ( + "integrations.datadome.server_side_key".to_owned(), + SecretKind::KeyInDefault, + false, + ), + ( + "partners[*].api_key".to_owned(), + SecretKind::KeyInDefault, + false + ), + ( + "partners[*].maybe".to_owned(), + SecretKind::KeyInDefault, + true + ), + ], + ); + } + #[test] fn derive_emits_app_config_root_impl() { // The trait is a marker; we just need it to compile and the @@ -155,6 +240,14 @@ mod tests { cases.compile_fail("tests/ui/non_secret_with_serde_flatten.rs"); cases.compile_fail("tests/ui/non_secret_with_serde_skip_serializing.rs"); cases.compile_fail("tests/ui/non_secret_with_serde_skip_serializing_if.rs"); + // `#[app_config(nested)]` recursion + `Option` secret guards. + // The `secret_*.rs` glob above already covers + // `secret_on_option_non_string.rs` and `secret_store_ref_optional.rs`. + cases.compile_fail("tests/ui/app_config_empty.rs"); + cases.compile_fail("tests/ui/app_config_nested_on_non_appconfig.rs"); + cases.compile_fail("tests/ui/app_config_unknown_option.rs"); + cases.compile_fail("tests/ui/nested_field_serde_rename.rs"); + cases.compile_fail("tests/ui/nested_parent_rename_all.rs"); cases.pass("tests/ui/secret_with_store_ref_named.rs"); } } diff --git a/crates/edgezero-macros/tests/app_macro.rs b/crates/edgezero-macros/tests/app_macro.rs new file mode 100644 index 00000000..58185135 --- /dev/null +++ b/crates/edgezero-macros/tests/app_macro.rs @@ -0,0 +1,21 @@ +//! Integration coverage: `app!(..., owns_logging = true)` emits a `Hooks` impl +//! whose `owns_logging()` returns `true`. The manifest path resolves against +//! this crate's `CARGO_MANIFEST_DIR`, so the fixture is `tests/fixtures/...`. + +// The macro emits `pub struct OwnedLoggingApp;`, a `Hooks` impl, and a free +// `build_router()` at this module scope. +edgezero_core::app!( + "tests/fixtures/owns_logging.toml", + OwnedLoggingApp, + owns_logging = true +); + +#[cfg(test)] +mod tests { + use edgezero_core::app::Hooks as _; + + #[test] + fn app_macro_emits_owns_logging_true() { + assert!(super::OwnedLoggingApp::owns_logging()); + } +} diff --git a/crates/edgezero-macros/tests/fixtures/owns_logging.toml b/crates/edgezero-macros/tests/fixtures/owns_logging.toml new file mode 100644 index 00000000..2b009868 --- /dev/null +++ b/crates/edgezero-macros/tests/fixtures/owns_logging.toml @@ -0,0 +1,2 @@ +[app] +name = "owns-logging-fixture" diff --git a/crates/edgezero-macros/tests/nested_secrets_e2e.rs b/crates/edgezero-macros/tests/nested_secrets_e2e.rs new file mode 100644 index 00000000..3176272e --- /dev/null +++ b/crates/edgezero-macros/tests/nested_secrets_e2e.rs @@ -0,0 +1,171 @@ +//! End-to-end proof that nested and array `#[secret]` fields resolve +//! through the runtime secret walk of the `AppConfig` extractor. +//! +//! Unlike `app_config_derive.rs` (which only reflects over the metadata +//! `secret_fields()` emits), this test drives the WHOLE chain a downstream +//! app hits at request time: a `BlobEnvelope` holding secret-store KEY NAMES +//! is deserialized through `AppConfig::::from_request`, whose `secret_walk` +//! resolves every nested / array / named-store leaf against a live +//! `InMemorySecretStore` before the struct is materialised. The assertions +//! read the RESOLVED values off the deserialized config. + +#![cfg(test)] + +use async_trait::async_trait; +use edgezero_core::blob_envelope::BlobEnvelope; +use edgezero_core::body::Body; +use edgezero_core::config_store::{ConfigStore, ConfigStoreError, ConfigStoreHandle}; +use edgezero_core::context::RequestContext; +use edgezero_core::extractor::{AppConfig as AppConfigExtractor, FromRequest as _}; +use edgezero_core::http::{request_builder, Method}; +use edgezero_core::params::PathParams; +use edgezero_core::secret_store::{InMemorySecretStore, SecretHandle}; +use edgezero_core::store_registry::{ + BoundSecretStore, ConfigRegistry, ConfigStoreBinding, SecretRegistry, StoreRegistry, +}; +use futures::executor::block_on; +use std::collections::BTreeMap; +use std::sync::Arc; +use validator::Validate as _; + +// --- fixture config (nested objects + `Vec<_>` array + named store) -------- + +// A 2-level `KeyInDefault` nested leaf: `datadome.server_side_key`. +#[derive(serde::Deserialize, validator::Validate, edgezero_core::AppConfig)] +#[serde(deny_unknown_fields)] +struct DataDome { + #[secret] + server_side_key: String, +} + +// One array element carrying a `KeyInDefault` secret: `partners[*].api_key`. +#[derive(serde::Deserialize, validator::Validate, edgezero_core::AppConfig)] +#[serde(deny_unknown_fields)] +struct Partner { + #[secret] + api_key: String, +} + +// The root config, exercising every reachable secret shape at once. +#[derive(serde::Deserialize, validator::Validate, edgezero_core::AppConfig)] +#[serde(deny_unknown_fields)] +struct Settings { + #[app_config(nested)] + #[validate(nested)] + datadome: DataDome, + #[app_config(nested)] + #[validate(nested)] + partners: Vec, + #[app_config(nested)] + #[validate(nested)] + vaulted: Vaulted, +} + +// A nested `KeyInNamedStore` leaf whose `store_ref` sibling (`vault`) lives in +// the SAME inner struct — the innermost-parent scoping rule. +#[derive(serde::Deserialize, validator::Validate, edgezero_core::AppConfig)] +#[serde(deny_unknown_fields)] +struct Vaulted { + #[secret(store_ref = "vault")] + token: String, + #[secret(store_ref)] + vault: String, +} + +// --- wiring helpers --------------------------------------------------------- + +// A minimal `ConfigStore` that returns one fixed blob-envelope string, +// mirroring the hand-written stores in `extractor.rs`'s own tests. +struct BlobStore(String); + +#[async_trait(?Send)] +impl ConfigStore for BlobStore { + async fn get(&self, _key: &str) -> Result, ConfigStoreError> { + Ok(Some(self.0.clone())) + } +} + +// Build a `RequestContext` wired to a config store holding `envelope` plus a +// two-store secret registry: the default store and a `named` store, so both +// the `KeyInDefault` leaves and the `KeyInNamedStore` leaf resolve. +fn ctx_with_stores(envelope: String) -> RequestContext { + let binding = ConfigStoreBinding { + handle: ConfigStoreHandle::new(Arc::new(BlobStore(envelope))), + default_key: "app_config".to_owned(), + }; + let config_registry: ConfigRegistry = + StoreRegistry::single_id("app_config".to_owned(), binding); + + // Default store resolves the `KeyInDefault` leaves (nested + array). + let default_store = InMemorySecretStore::new([ + ("default/dd_key".to_owned(), "DD"), + ("default/p0_key".to_owned(), "P0"), + ("default/p1_key".to_owned(), "P1"), + ]); + let default_bound = BoundSecretStore::new( + SecretHandle::new(Arc::new(default_store)), + "default".to_owned(), + ); + + // Named store resolves the `KeyInNamedStore` leaf via its `vault` sibling. + let named_store = InMemorySecretStore::new([("named/tok_key".to_owned(), "TOK")]); + let named_bound = + BoundSecretStore::new(SecretHandle::new(Arc::new(named_store)), "named".to_owned()); + + let mut by_id: BTreeMap = BTreeMap::new(); + by_id.insert("default".to_owned(), default_bound); + by_id.insert("named".to_owned(), named_bound); + let secret_registry: SecretRegistry = StoreRegistry::new(by_id, "default".to_owned()); + + let mut request = request_builder() + .method(Method::GET) + .uri("/config") + .body(Body::empty()) + .expect("build request"); + request.extensions_mut().insert(config_registry); + request.extensions_mut().insert(secret_registry); + RequestContext::new(request, PathParams::default()) +} + +// The blob at rest holds secret-store KEY NAMES (Model A), not resolved +// values — exactly what `config push` persists. +fn envelope_with_key_names() -> String { + let data = serde_json::json!({ + "datadome": { "server_side_key": "dd_key" }, + "partners": [ { "api_key": "p0_key" }, { "api_key": "p1_key" } ], + "vaulted": { "token": "tok_key", "vault": "named" } + }); + let envelope = BlobEnvelope::new(data, "2026-01-01T00:00:00Z".to_owned()); + serde_json::to_string(&envelope).expect("serialise envelope") +} + +// --- the end-to-end assertions --------------------------------------------- + +#[test] +fn nested_and_named_store_secrets_resolve_through_extractor() { + let ctx = ctx_with_stores(envelope_with_key_names()); + let AppConfigExtractor(cfg) = + block_on(AppConfigExtractor::::from_request(&ctx)).expect("extraction succeeds"); + + // Nested `KeyInDefault`: `datadome.server_side_key` -> default store. + assert_eq!(cfg.datadome.server_side_key, "DD"); + + // Nested `KeyInNamedStore`: `vaulted.token` -> the store named by its + // sibling `vaulted.vault` ("named"). The `store_ref` sibling is left + // verbatim (it names a store, not a secret). + assert_eq!(cfg.vaulted.token, "TOK"); + assert_eq!(cfg.vaulted.vault, "named"); +} + +#[test] +fn array_element_secrets_resolve_per_index() { + let ctx = ctx_with_stores(envelope_with_key_names()); + let AppConfigExtractor(cfg) = + block_on(AppConfigExtractor::::from_request(&ctx)).expect("extraction succeeds"); + + // Each `partners[n].api_key` resolves independently against the default + // store — proving the `ArrayEach` runtime walk. + assert_eq!(cfg.partners.len(), 2); + assert_eq!(cfg.partners[0].api_key, "P0"); + assert_eq!(cfg.partners[1].api_key, "P1"); +} diff --git a/crates/edgezero-macros/tests/ui/app_config_empty.rs b/crates/edgezero-macros/tests/ui/app_config_empty.rs new file mode 100644 index 00000000..f8e9abed --- /dev/null +++ b/crates/edgezero-macros/tests/ui/app_config_empty.rs @@ -0,0 +1,11 @@ +//! An empty `#[app_config()]` must be a hard compile error, not a silent +//! no-op — otherwise the field would not be recursed and the child's +//! `#[secret]` metadata would be dropped without any diagnostic. + +#[derive(serde::Deserialize, validator::Validate, edgezero_core::AppConfig)] +struct Config { + #[app_config()] + child: String, +} + +fn main() {} diff --git a/crates/edgezero-macros/tests/ui/app_config_empty.stderr b/crates/edgezero-macros/tests/ui/app_config_empty.stderr new file mode 100644 index 00000000..4edda2ac --- /dev/null +++ b/crates/edgezero-macros/tests/ui/app_config_empty.stderr @@ -0,0 +1,5 @@ +error: `#[app_config]` requires an option; the only supported one is `nested` (e.g. `#[app_config(nested)]`) + --> tests/ui/app_config_empty.rs:7:5 + | +7 | #[app_config()] + | ^^^^^^^^^^^^^^^ diff --git a/crates/edgezero-macros/tests/ui/app_config_nested_on_non_appconfig.rs b/crates/edgezero-macros/tests/ui/app_config_nested_on_non_appconfig.rs new file mode 100644 index 00000000..62a55b0c --- /dev/null +++ b/crates/edgezero-macros/tests/ui/app_config_nested_on_non_appconfig.rs @@ -0,0 +1,15 @@ +//! `#[app_config(nested)]` on a field whose type does not derive +//! `AppConfig` must fail with a clear `AppConfigRoot` bound error. + +#[derive(serde::Deserialize)] +struct NotAppConfig { + _key: String, +} + +#[derive(serde::Deserialize, validator::Validate, edgezero_core::AppConfig)] +struct Config { + #[app_config(nested)] + child: NotAppConfig, +} + +fn main() {} diff --git a/crates/edgezero-macros/tests/ui/app_config_nested_on_non_appconfig.stderr b/crates/edgezero-macros/tests/ui/app_config_nested_on_non_appconfig.stderr new file mode 100644 index 00000000..893b763a --- /dev/null +++ b/crates/edgezero-macros/tests/ui/app_config_nested_on_non_appconfig.stderr @@ -0,0 +1,40 @@ +error[E0277]: the trait bound `NotAppConfig: AppConfigRoot` is not satisfied + --> tests/ui/app_config_nested_on_non_appconfig.rs:12:12 + | +12 | child: NotAppConfig, + | ^^^^^^^^^^^^ unsatisfied trait bound + | +help: the trait `AppConfigRoot` is not implemented for `NotAppConfig` + --> tests/ui/app_config_nested_on_non_appconfig.rs:5:1 + | + 5 | struct NotAppConfig { + | ^^^^^^^^^^^^^^^^^^^ +help: the trait `AppConfigRoot` is implemented for `Config` + --> tests/ui/app_config_nested_on_non_appconfig.rs:9:51 + | + 9 | #[derive(serde::Deserialize, validator::Validate, edgezero_core::AppConfig)] + | ^^^^^^^^^^^^^^^^^^^^^^^^ +note: required by a bound in `__assert_app_config_root` + --> tests/ui/app_config_nested_on_non_appconfig.rs:9:51 + | + 9 | #[derive(serde::Deserialize, validator::Validate, edgezero_core::AppConfig)] + | ^^^^^^^^^^^^^^^^^^^^^^^^ required by this bound in `__assert_app_config_root` + = note: this error originates in the derive macro `edgezero_core::AppConfig` (in Nightly builds, run with -Z macro-backtrace for more info) + +error[E0277]: the trait bound `NotAppConfig: AppConfigMeta` is not satisfied + --> tests/ui/app_config_nested_on_non_appconfig.rs:12:12 + | +12 | child: NotAppConfig, + | ^^^^^^^^^^^^ unsatisfied trait bound + | +help: the trait `AppConfigMeta` is not implemented for `NotAppConfig` + --> tests/ui/app_config_nested_on_non_appconfig.rs:5:1 + | + 5 | struct NotAppConfig { + | ^^^^^^^^^^^^^^^^^^^ +help: the trait `AppConfigMeta` is implemented for `Config` + --> tests/ui/app_config_nested_on_non_appconfig.rs:9:51 + | + 9 | #[derive(serde::Deserialize, validator::Validate, edgezero_core::AppConfig)] + | ^^^^^^^^^^^^^^^^^^^^^^^^ + = note: this error originates in the derive macro `edgezero_core::AppConfig` (in Nightly builds, run with -Z macro-backtrace for more info) diff --git a/crates/edgezero-macros/tests/ui/app_config_unknown_option.rs b/crates/edgezero-macros/tests/ui/app_config_unknown_option.rs new file mode 100644 index 00000000..8fe0308a --- /dev/null +++ b/crates/edgezero-macros/tests/ui/app_config_unknown_option.rs @@ -0,0 +1,10 @@ +//! `#[app_config(bogus)]` must be a hard compile error (a typo must not +//! be silently ignored — that would drop the child's secrets). + +#[derive(serde::Deserialize, validator::Validate, edgezero_core::AppConfig)] +struct Config { + #[app_config(bogus)] + child: String, +} + +fn main() {} diff --git a/crates/edgezero-macros/tests/ui/app_config_unknown_option.stderr b/crates/edgezero-macros/tests/ui/app_config_unknown_option.stderr new file mode 100644 index 00000000..2db58821 --- /dev/null +++ b/crates/edgezero-macros/tests/ui/app_config_unknown_option.stderr @@ -0,0 +1,5 @@ +error: `#[app_config(...)]` only accepts `nested` + --> tests/ui/app_config_unknown_option.rs:6:18 + | +6 | #[app_config(bogus)] + | ^^^^^ diff --git a/crates/edgezero-macros/tests/ui/key_in_named_store_sibling_not_string.stderr b/crates/edgezero-macros/tests/ui/key_in_named_store_sibling_not_string.stderr index be76c2c4..c9e69eca 100644 --- a/crates/edgezero-macros/tests/ui/key_in_named_store_sibling_not_string.stderr +++ b/crates/edgezero-macros/tests/ui/key_in_named_store_sibling_not_string.stderr @@ -1,4 +1,4 @@ -error: `#[secret]` / `#[secret(store_ref)]` may only annotate a scalar string field (e.g. `String`) +error: `#[secret]` may only annotate `String` or `Option` --> tests/ui/key_in_named_store_sibling_not_string.rs:11:12 | 11 | vault: u32, diff --git a/crates/edgezero-macros/tests/ui/nested_field_serde_rename.rs b/crates/edgezero-macros/tests/ui/nested_field_serde_rename.rs new file mode 100644 index 00000000..548b969f --- /dev/null +++ b/crates/edgezero-macros/tests/ui/nested_field_serde_rename.rs @@ -0,0 +1,18 @@ +//! `#[serde(rename = "...")]` on a `#[app_config(nested)]` field must +//! error — the emitter writes the Rust field name verbatim as a `Field` +//! path segment, which a rename would desync from the serialized key. + +#[derive(serde::Deserialize, validator::Validate, edgezero_core::AppConfig)] +struct Child { + #[secret] + api_key: String, +} + +#[derive(serde::Deserialize, validator::Validate, edgezero_core::AppConfig)] +struct Config { + #[serde(rename = "x")] + #[app_config(nested)] + child: Child, +} + +fn main() {} diff --git a/crates/edgezero-macros/tests/ui/nested_field_serde_rename.stderr b/crates/edgezero-macros/tests/ui/nested_field_serde_rename.stderr new file mode 100644 index 00000000..e3d21b53 --- /dev/null +++ b/crates/edgezero-macros/tests/ui/nested_field_serde_rename.stderr @@ -0,0 +1,5 @@ +error: `#[secret]` is incompatible with `#[serde(rename)]` — the derive emits the Rust field name verbatim and config validate / push round-trip it via TOML + --> tests/ui/nested_field_serde_rename.rs:13:5 + | +13 | #[serde(rename = "x")] + | ^^^^^^^^^^^^^^^^^^^^^^ diff --git a/crates/edgezero-macros/tests/ui/nested_parent_rename_all.rs b/crates/edgezero-macros/tests/ui/nested_parent_rename_all.rs new file mode 100644 index 00000000..fb89b56c --- /dev/null +++ b/crates/edgezero-macros/tests/ui/nested_parent_rename_all.rs @@ -0,0 +1,18 @@ +//! A parent with only `#[app_config(nested)]` children (no direct +//! `#[secret]`) carrying `#[serde(rename_all = ...)]` must error — the +//! rename would desync the emitted `Field(parent_field)` path segment. + +#[derive(serde::Deserialize, validator::Validate, edgezero_core::AppConfig)] +struct Child { + #[secret] + api_key: String, +} + +#[derive(serde::Deserialize, validator::Validate, edgezero_core::AppConfig)] +#[serde(rename_all = "kebab-case")] +struct Config { + #[app_config(nested)] + child_config: Child, +} + +fn main() {} diff --git a/crates/edgezero-macros/tests/ui/nested_parent_rename_all.stderr b/crates/edgezero-macros/tests/ui/nested_parent_rename_all.stderr new file mode 100644 index 00000000..620642a2 --- /dev/null +++ b/crates/edgezero-macros/tests/ui/nested_parent_rename_all.stderr @@ -0,0 +1,5 @@ +error: `#[derive(AppConfig)]` rejects `#[serde(rename_all = ...)]` on structs with `#[secret]` fields or `#[app_config(nested)]` children: secret_fields() uses Rust field names verbatim, so a container rename would silently desync `config validate` from runtime deserialisation + --> tests/ui/nested_parent_rename_all.rs:12:1 + | +12 | #[serde(rename_all = "kebab-case")] + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ diff --git a/crates/edgezero-macros/tests/ui/secret_on_non_scalar.stderr b/crates/edgezero-macros/tests/ui/secret_on_non_scalar.stderr index 817d8c55..f2df9e2e 100644 --- a/crates/edgezero-macros/tests/ui/secret_on_non_scalar.stderr +++ b/crates/edgezero-macros/tests/ui/secret_on_non_scalar.stderr @@ -1,4 +1,4 @@ -error: `#[secret]` / `#[secret(store_ref)]` may only annotate a scalar string field (e.g. `String`) +error: `#[secret]` may only annotate `String` or `Option` --> tests/ui/secret_on_non_scalar.rs:7:17 | 7 | api_tokens: Vec, diff --git a/crates/edgezero-macros/tests/ui/secret_on_option_non_string.rs b/crates/edgezero-macros/tests/ui/secret_on_option_non_string.rs new file mode 100644 index 00000000..4ec447eb --- /dev/null +++ b/crates/edgezero-macros/tests/ui/secret_on_option_non_string.rs @@ -0,0 +1,10 @@ +//! `#[secret]` on `Option` must error — only `String` or +//! `Option` are accepted. + +#[derive(serde::Deserialize, validator::Validate, edgezero_core::AppConfig)] +struct Config { + #[secret] + api_token: Option, +} + +fn main() {} diff --git a/crates/edgezero-macros/tests/ui/secret_on_option_non_string.stderr b/crates/edgezero-macros/tests/ui/secret_on_option_non_string.stderr new file mode 100644 index 00000000..114ab8eb --- /dev/null +++ b/crates/edgezero-macros/tests/ui/secret_on_option_non_string.stderr @@ -0,0 +1,5 @@ +error: `#[secret]` may only annotate `String` or `Option` + --> tests/ui/secret_on_option_non_string.rs:7:16 + | +7 | api_token: Option, + | ^^^^^^^^^^^ diff --git a/crates/edgezero-macros/tests/ui/secret_store_ref_optional.rs b/crates/edgezero-macros/tests/ui/secret_store_ref_optional.rs new file mode 100644 index 00000000..2ea63bff --- /dev/null +++ b/crates/edgezero-macros/tests/ui/secret_store_ref_optional.rs @@ -0,0 +1,10 @@ +//! `#[secret(store_ref)]` on `Option` must error — a store id is +//! structural and must always be present. + +#[derive(serde::Deserialize, validator::Validate, edgezero_core::AppConfig)] +struct Config { + #[secret(store_ref)] + vault: Option, +} + +fn main() {} diff --git a/crates/edgezero-macros/tests/ui/secret_store_ref_optional.stderr b/crates/edgezero-macros/tests/ui/secret_store_ref_optional.stderr new file mode 100644 index 00000000..cd0e028d --- /dev/null +++ b/crates/edgezero-macros/tests/ui/secret_store_ref_optional.stderr @@ -0,0 +1,5 @@ +error: `#[secret(store_ref)]` may not be `Option`: a store id is structural and must always be present + --> tests/ui/secret_store_ref_optional.rs:7:12 + | +7 | vault: Option, + | ^^^^^^^^^^^^^^ diff --git a/crates/edgezero-macros/tests/ui/secret_with_serde_container_rename_all.rs b/crates/edgezero-macros/tests/ui/secret_with_serde_container_rename_all.rs index a50d90fa..594adb5e 100644 --- a/crates/edgezero-macros/tests/ui/secret_with_serde_container_rename_all.rs +++ b/crates/edgezero-macros/tests/ui/secret_with_serde_container_rename_all.rs @@ -1,6 +1,6 @@ //! Container-level `#[serde(rename_all = ...)]` on a struct that has a //! `#[secret]` field must be rejected: the renamer would translate the -//! TOML key to `api-token` while `SECRET_FIELDS` keeps reporting +//! TOML key to `api-token` while `secret_fields()` keeps reporting //! `api_token`, silently desyncing the typed `config validate` secret //! checks and the Spin collision check. diff --git a/crates/edgezero-macros/tests/ui/secret_with_serde_container_rename_all.stderr b/crates/edgezero-macros/tests/ui/secret_with_serde_container_rename_all.stderr index c94cb25d..30040917 100644 --- a/crates/edgezero-macros/tests/ui/secret_with_serde_container_rename_all.stderr +++ b/crates/edgezero-macros/tests/ui/secret_with_serde_container_rename_all.stderr @@ -1,4 +1,4 @@ -error: `#[derive(AppConfig)]` rejects `#[serde(rename_all = ...)]` on structs with `#[secret]` fields: SECRET_FIELDS uses Rust field names verbatim, so a container rename would silently desync `config validate` from runtime deserialisation +error: `#[derive(AppConfig)]` rejects `#[serde(rename_all = ...)]` on structs with `#[secret]` fields or `#[app_config(nested)]` children: secret_fields() uses Rust field names verbatim, so a container rename would silently desync `config validate` from runtime deserialisation --> tests/ui/secret_with_serde_container_rename_all.rs:8:1 | 8 | #[serde(rename_all = "kebab-case")] diff --git a/crates/edgezero-macros/tests/ui/secret_with_serde_skip_serializing_if.rs b/crates/edgezero-macros/tests/ui/secret_with_serde_skip_serializing_if.rs index b0c088b1..9be7c3fc 100644 --- a/crates/edgezero-macros/tests/ui/secret_with_serde_skip_serializing_if.rs +++ b/crates/edgezero-macros/tests/ui/secret_with_serde_skip_serializing_if.rs @@ -1,8 +1,8 @@ //! `#[serde(skip_serializing_if = "...")]` conditionally omits the //! field from serialisation. Combined with `#[secret]`, that would -//! make `config push` (which reads `SECRET_FIELDS`, then serialises +//! make `config push` (which reads `secret_fields()`, then serialises //! the typed struct) drop the secret key under the condition — -//! desyncing the on-the-wire shape from the SECRET_FIELDS invariant +//! desyncing the on-the-wire shape from the secret_fields() invariant //! relies on. Reject at compile time. #[derive(serde::Deserialize, serde::Serialize, validator::Validate, edgezero_core::AppConfig)] diff --git a/docs/guide/configuration.md b/docs/guide/configuration.md index ce283df6..44330b90 100644 --- a/docs/guide/configuration.md +++ b/docs/guide/configuration.md @@ -285,8 +285,10 @@ The function deserialises, runs the `validator` rules (e.g. | `#[secret]` | The field's value is a **key inside the default secret store** declared by `[stores.secrets]`. | | `#[secret(store_ref)]` | The field's value is a **logical store id** that must appear in `[stores.secrets].ids`. | -Only bare `String` fields can carry a `#[secret]` annotation; -combining it with `#[serde(flatten)]`, `#[serde(rename)]`, or +`#[secret]` fields must be `String` or `Option` (an absent +optional secret is skipped at runtime — see +[Nested and array secrets](#nested-and-array-secrets)); combining the +annotation with `#[serde(flatten)]`, `#[serde(rename)]`, or `#[serde(skip)]` is a compile error. The `config validate` command (see [CLI reference](/guide/cli-reference)) checks that every `#[secret(store_ref)]` value matches a declared id. @@ -307,6 +309,83 @@ let value = ctx .await?; ``` +### Nested and array secrets + +`#[secret]` fields don't have to live at the config root. They can sit +inside nested structs and inside `Vec<_>` elements, resolved at runtime +by their **field path** instead of a single top-level name. + +To recurse into a nested type, mark the field with `#[app_config(nested)]` +(it mirrors `#[validate(nested)]`, which you'll usually want alongside it), +and make the nested type itself derive `AppConfig`: + +```rust +#[derive(Debug, Deserialize, Serialize, Validate, edgezero_core::AppConfig)] +#[serde(deny_unknown_fields)] +pub struct Settings { + #[app_config(nested)] + #[validate(nested)] + pub integrations: Integrations, + + #[app_config(nested)] + #[validate(nested)] + pub partners: Vec, +} + +#[derive(Debug, Deserialize, Serialize, Validate, edgezero_core::AppConfig)] +#[serde(deny_unknown_fields)] +pub struct Integrations { + #[app_config(nested)] + #[validate(nested)] + pub datadome: DataDome, +} + +#[derive(Debug, Deserialize, Serialize, Validate, edgezero_core::AppConfig)] +#[serde(deny_unknown_fields)] +pub struct DataDome { + #[secret] + pub server_side_key: String, + + // A named-store secret: `token` is a key in the store named by its + // `vault` sibling. The `store_ref` sibling is resolved within the + // innermost object that contains the secret leaf. + #[secret(store_ref = "vault")] + pub token: String, + #[secret(store_ref)] + pub vault: String, +} + +#[derive(Debug, Deserialize, Serialize, Validate, edgezero_core::AppConfig)] +#[serde(deny_unknown_fields)] +pub struct Partner { + #[secret] + pub api_key: String, + + // An optional secret: absent (or `null`) at runtime -> skipped, not an error. + #[secret] + pub webhook_key: Option, +} +``` + +At request time the extractor walks each secret path and swaps the stored +**key name** for the resolved value: + +- Object nesting resolves the leaf at its full path + (`integrations.datadome.server_side_key`). +- `Vec<_>` arrays resolve the annotated field on **every** element + (`partners[*].api_key`). +- A `#[secret]` on `Option` is skipped when the value is absent + or `null`; a present value is resolved like any other secret. +- `#[secret(store_ref = "…")]` resolves against the store named by its + **sibling** field — the one in the same innermost object as the secret + leaf, not a root-level field. + +The nested type must derive `AppConfig`; marking a field +`#[app_config(nested)]` whose type does not is a compile error. Runtime +resolution failures name the offending leaf by its dotted path, with +concrete array indices — e.g. `integrations.datadome.server_side_key` or +`partners[3].api_key` — so a bad key name points straight at the field. + ### Environment-variable overlay Every key in `.toml` can be overridden at runtime by an env 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/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..81087e39 --- /dev/null +++ b/docs/superpowers/plans/2026-07-02-edgezero-nested-secrets.md @@ -0,0 +1,1946 @@ +# 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). +``` + +- [x] **Step 1: Write the failing metadata unit tests** + +Append to `crates/edgezero-core/src/app_config.rs`'s `#[cfg(test)] mod tests` (module starts near `app_config.rs:599`; it already imports `SecretField`, `SecretKind`): + +```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"); + } +``` + +- [x] **Step 2: Run it (fails to compile)** + +Run: `cargo test -p edgezero-core --lib dotted_path_renders 2>&1 | tail -15` +Expected: FAIL — `SecretPathSegment` / `SecretField.path` / `dotted_path` do not exist. + +- [x] **Step 3: Reshape the metadata types + trait in `app_config.rs`** + +Add `use std::borrow::Cow;` near the top of `crates/edgezero-core/src/app_config.rs` (with the other `use`s). Replace the `AppConfigMeta` trait (`app_config.rs:34-37`), the `SecretField` struct (`app_config.rs:41-48`), and add `SecretPathSegment` + `dotted_path`. `SecretKind` (`app_config.rs:53-69`) is **unchanged**. + +```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`. + +- [x] **Step 4: Run the metadata test (passes)** + +Run: `cargo test -p edgezero-core --lib dotted_path_renders 2>&1 | tail -15` +Expected: PASS. (The crate will not fully build yet — consumers still reference the old shape. Fix them in the next steps.) + +- [x] **Step 5: Update `validate_excluding_secrets` to the new shape (flat behavior preserved)** + +In `crates/edgezero-core/src/app_config.rs:204-226`, the loop currently does `bag.remove(field.name)`. For Task 1, keep flat removal but source the key from the length-1 path. (Task 3 replaces this with nested/list-aware pruning.) Change the loop body: + +```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`). + +- [x] **Step 6: Update `secret_walk` to the new shape (top-level behavior preserved)** + +In `crates/edgezero-core/src/extractor.rs:827-894`, change the import at `extractor.rs:8` to also bring in the path segment, and change the loop to source the key/field name from the length-1 path. (Task 2 replaces the whole body with a recursive navigator.) Minimal Task-1 change: replace `for field in C::SECRET_FIELDS` with `for field in C::secret_fields()`, and replace each `field.name` use with a locally computed `let leaf = field.dotted_path();` for error hints and `let leaf_key = match field.path.last() { Some(SecretPathSegment::Field(n)) => n.as_ref(), _ => /* length-1 guaranteed in Task 1 */ };` for the `data_obj.get(...)`/`insert(...)` calls. Concretely, at the top of the loop: + +```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). + +- [x] **Step 7: Flip the emitter in `edgezero-macros/src/app_config.rs` to `fn` + length-1 paths** + +In `crates/edgezero-macros/src/app_config.rs`, change the per-entry emission (`app_config.rs:128-150`) and the impl block (`app_config.rs:152-166`). The entries currently emit `SecretField { name: #name_lit, kind: #kind_tokens }`; change to owned length-1 paths: + +```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 + {} + }) +} +``` + +- [x] **Step 8: Make `TypedSecretEntry.field_name` owned** + +In `crates/edgezero-adapter/src/registry.rs:174-198`, change `field_name: &'entry str` to owned `String`. **Keep `new`'s param generic as `impl Into`** so the 7 existing `&str`-literal call sites in the Spin tests (`adapter-spin/src/cli.rs:1292/1307/1327/1328/1344/1345/1357/1389/1390`) keep compiling unchanged, while the CLI callers pass owned dotted labels: + +```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). + +- [x] **Step 9: Update the Spin collision check to the owned label** + +In `crates/edgezero-adapter-spin/src/cli.rs:514-552`, the logic keys on `entry.key_value` (the secret value) — unchanged. Only the `seen` map value type shifts from `&str` (borrowing a `&'static` name) to a borrow of the owned `String`. Change the map value binding so it borrows `entry.field_name`: + +```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()`.) + +- [x] **Step 10: Update the CLI consumers + all test `impl AppConfigMeta` sites** + +In `crates/edgezero-cli/src/config.rs`, update the two runtime consumers to the new shape (still flat/length-1 in Task 1 — full path navigation lands in Task 6): + +- `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). + +- [x] **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). + +- [x] **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). + +- [x] **Step 1: Write failing nested/array `secret_walk` tests** + +Append to `crates/edgezero-core/src/extractor.rs` `#[cfg(test)] mod tests`. Mirror the existing `app_config_secret_walk_resolves_key_in_default_store` test (`extractor.rs:2170`) for store setup (`InMemorySecretStore`, `StoreRegistry`, inserting the secret registry into request extensions, building `ctx`). Add three fixtures + tests: + +```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.) + +- [x] **Step 2: Run (fails)** + +Run: `cargo test -p edgezero-core --lib secret_walk_ 2>&1 | tail -25` +Expected: FAIL — nested/array data is not navigated (current walk only reads/writes top-level keys); missing-leaf message lacks the dotted path. + +- [x] **Step 3: Rewrite `secret_walk` as a recursive navigator** + +Replace the body of `secret_walk` (`crates/edgezero-core/src/extractor.rs:827-894`) with a path navigator. Keep the signature (`async fn secret_walk(ctx: &RequestContext, data: &mut serde_json::Value) -> Result<(), EdgeError> where C: AppConfigMeta`). New body: + +```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. + +- [x] **Step 4: Run (passes)** + +Run: `cargo test -p edgezero-core --lib secret_walk_ 2>&1 | tail -25` +Expected: PASS (nested object, array-each, absent-optional-skip, missing-nested-dotted-error). Also confirm the pre-existing top-level walk tests (`extractor.rs:2170`, `:2198`) still pass: + +Run: `cargo test -p edgezero-core --lib app_config_secret_walk 2>&1 | tail -15` +Expected: PASS. + +- [x] **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. + +- [x] **Step 1: Write failing nested-pruning tests** + +Append to `app_config.rs` `#[cfg(test)] mod tests`. Model on `validate_excluding_secrets_skips_secret_field_rules` (`app_config.rs:1148`) but with a nested struct fixture whose nested secret leaf has a failing validator (e.g. `#[validate(length(min = 100))]` on the key-name string, which is short at push time). Assert `validate_excluding_secrets` returns `Ok(())` (the nested secret's validator was pruned) while a **non-secret** nested failure still surfaces `Err`. + +```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. + +- [x] **Step 2: Run (fails)** + +Run: `cargo test -p edgezero-core --lib validate_excluding_secrets_prunes_nested 2>&1 | tail -20` +Expected: FAIL — the flat `bag.remove(first_segment)` removes the top-level `integrations` entry entirely (over-pruning) or fails to prune the nested leaf, so the assertion is wrong. (Either way the Task-1 flat impl is incorrect for nesting.) + +- [x] **Step 3: Implement nested-aware pruning** + +Replace `validate_excluding_secrets`'s loop (`app_config.rs:204-226`) with a path-aware pruner that navigates `ValidationErrorsKind::Struct`/`List` down each secret field's path, removes the leaf validator, and prunes now-empty containers so a fully-cleared branch disappears (rather than leaving an empty `Struct`/`List` marker that would keep `errors` non-empty). The loop: + +```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()); + } +} +``` + +- [x] **Step 4: Run (passes)** + +Run: `cargo test -p edgezero-core --lib validate_excluding_secrets 2>&1 | tail -20` +Expected: PASS — both new tests plus the four pre-existing `validate_excluding_secrets_*` tests (`app_config.rs:1132/1148/1173/1195`). + +- [x] **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). + +- [x] **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))] +``` + +- [x] **Step 2: Write failing derive/UI tests** + +Add happy-path assertions to `crates/edgezero-macros/tests/app_config_derive.rs` (a nested object emits the expected 3-segment path; a `Vec` nested field emits `Field`+`ArrayEach`; `Option` sets `optional: true`). Example: + +```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. + +- [x] **Step 3: Run (fails)** + +Run: `cargo test -p edgezero-macros --test app_config_derive 2>&1 | tail -30` +Expected: FAIL — `#[app_config(nested)]` is not parsed (unknown attribute or ignored); `Option` rejected by `is_scalar_string_type`; nested paths not emitted. + +- [x] **Step 4: Parse `#[app_config(nested)]` and classify fields** + +In `crates/edgezero-macros/src/app_config.rs`, add a helper that detects the opt-in and extracts the recursion child type. A field is a **nested recursion** field iff it carries `#[app_config(nested)]`. Determine object-vs-array from the field type: a `Vec`/`[T]` → array (child `T`, emit `Field(field)` + `ArrayEach`); anything else → object (child = the field type, emit `Field(field)`). + +```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. + +- [x] **Step 5: Emit recursion into `secret_fields()`** + +Extend the emitter (Task 1's `entries`) to also emit, for each nested descriptor, a loop that prepends segments onto the child's `secret_fields()`. Change the `fn secret_fields()` body emission to build a `Vec` imperatively: + +```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. + +- [x] **Step 6: Relax scalar rule to accept `Option`; set `optional`** + +Change `is_scalar_string_type`/`enforce_scalar_string_type` (`app_config.rs:265-284`) to also accept `Option`, and have `scan_field` (`app_config.rs:195-219`) report whether the secret type was optional so the emitter sets `optional`. Add: + +```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. + +- [x] **Step 7: Extend the `rename_all` guard to nested-only parents** + +The guard fires today only when `!annotations.is_empty()` (`app_config.rs:75-77`, direct `#[secret]` fields present). A parent whose secrets are all in `#[app_config(nested)]` children has no direct annotations but its own `rename_all` would still desync the emitted `Field(parent_field)` segment. Change the gate to also fire when any nested descriptor exists: + +```rust + if !annotations.is_empty() || !nested_descriptors.is_empty() { + enforce_no_container_rename_all(&input.attrs)?; + } +``` + +- [x] **Step 8: Run (passes)** + +Run: `cargo test -p edgezero-macros --test app_config_derive 2>&1 | tail -30` +Expected: PASS — happy-path nested/array/optional emission + all UI compile-fail fixtures match their `.stderr`. Regenerate `.stderr` with `TRYBUILD=overwrite cargo test -p edgezero-macros --test app_config_derive` if messages differ, then inspect the diffs for correctness before committing. + +- [x] **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. + +- [x] **Step 1: Write failing guard tests** + +Add a `#[cfg(test)] mod tests` to `crates/edgezero-cli/src/bin/check_no_nested_app_config.rs`. The binary is behind `#![cfg(feature = "nested-app-config-check")]`, so tests run only with that feature. Test the pure helpers by parsing source snippets with `syn::parse_file` and running the collector + visitor: + +```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); + // NB: `new(source_path, app_config_structs)` — path FIRST, per the + // real signature at check_no_nested_app_config.rs:127. + let mut visitor = + NestedAppConfigVisitor::new(std::path::Path::new("t.rs"), &collector.app_config_structs); + syn::visit::visit_file(&mut visitor, &file); + visitor.violations + } + + 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.) + +- [x] **Step 2: Run (fails)** + +Run: `cargo test -p edgezero-cli --features nested-app-config-check --bin check_no_nested_app_config 2>&1 | tail -20` +Expected: FAIL — `allows_nesting_with_opt_in` sees 1 violation (the guard flags all nesting today). + +- [x] **Step 3: Teach the visitor to honor `#[app_config(nested)]`** + +In `NestedAppConfigVisitor::visit_item_struct` (`check_no_nested_app_config.rs:156-181`), before reporting a violation for a field whose type contains an `AppConfig` struct, skip it if the field carries `#[app_config(nested)]`. Add a helper mirroring the macro's detection and guard the report: + +```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| { + if !attr.path().is_ident("app_config") { + return false; + } + // Must actually see `nested`. A bare `#[app_config()]` parses Ok but + // never sets `found`, so `.is_ok()` alone would wrongly report opt-in. + let mut found = false; + let parsed = attr.parse_nested_meta(|meta| { + if meta.path.is_ident("nested") { + found = true; + Ok(()) + } else { + Err(meta.error("unknown app_config option")) + } + }); + parsed.is_ok() && found + }) +} +``` + +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`.) + +- [x] **Step 4: Run (passes) + run the guard over the real trees** + +Run: `cargo test -p edgezero-cli --features nested-app-config-check --bin check_no_nested_app_config 2>&1 | tail -20` +Expected: PASS. + +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). + +- [x] **Step 5: Lint + commit** + +Run: `cargo clippy -p edgezero-cli --features nested-app-config-check --bin check_no_nested_app_config -- -D warnings 2>&1 | tail -15` + +```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). + +- [x] **Step 1: Write failing CLI navigation tests** + +Add tests to `crates/edgezero-cli/src/config.rs` `#[cfg(test)] mod tests`, driven through the **public** `run_config_validate_typed::` entry point (which calls both `typed_secret_checks` and `run_adapter_typed_checks`). `ValidationContext` has private fields and a `ManifestLoader` that is impractical to build by hand, so mirror the existing harness: write a manifest + `demo-app.toml` to a tempdir with `setup_project(manifest, app_config)` (`config.rs:1662`, returns `(TempDir, manifest_path, app_config_path)`) and pass `args_for(&manifest_path)` (`config.rs:1671`). The config type is a **real** nested `#[derive(AppConfig)]` (Task 4 is complete by Task 6), which also proves derive→CLI integration. + +```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_rejects_missing_required_nested_leaf_at_deserialize() { + // A MISSING required nested leaf fails serde DESERIALIZATION + // (config.rs:207) before `typed_secret_checks`/`run_adapter_typed_checks` + // ever run — so this is deserialize-path coverage, NOT proof of the + // path-aware collector. The direct collector test below covers that. + let app_config = r#" +[integrations.datadome] + +[[partners]] +api_key = "p0" +"#; + let (_dir, manifest_path, _) = setup_project(NESTED_MANIFEST, app_config); + let err = run_config_validate_typed::(&args_for(&manifest_path)) + .expect_err("missing nested leaf must be rejected"); + assert!( + err.contains("server_side_key"), + "error names the missing nested leaf: {err}" + ); + } + + // Direct coverage of the path-aware TOML collector (the new logic). + // Bypasses `run_config_validate_typed` so deserialization does not preempt + // it — proves the collector itself resolves array indices and reports the + // dotted label for a present-but-invalid / missing leaf. + #[test] + fn collect_secret_leaves_resolves_array_indices_and_dotted_labels() { + let raw: toml::Value = r#" +[[partners]] +api_key = "p0" + +[[partners]] +api_key = "p1" +"# + .parse() + .expect("toml"); + + let field = SecretField { + kind: SecretKind::KeyInDefault, + path: vec![ + SecretPathSegment::Field(std::borrow::Cow::Borrowed("partners")), + SecretPathSegment::ArrayEach, + SecretPathSegment::Field(std::borrow::Cow::Borrowed("api_key")), + ], + optional: false, + }; + let leaves = collect_secret_leaves(&raw, &field).expect("collect"); + let labels: Vec<&str> = leaves.iter().map(|leaf| leaf.label.as_str()).collect(); + assert_eq!(labels, vec!["partners[0].api_key", "partners[1].api_key"]); + let values: Vec<&str> = leaves.iter().map(|leaf| leaf.value).collect(); + assert_eq!(values, vec!["p0", "p1"]); + } + + #[test] + fn collect_secret_leaves_errors_on_missing_required_leaf_with_dotted_label() { + let raw: toml::Value = r#" +[integrations.datadome] +other = "x" +"# + .parse() + .expect("toml"); + + let field = SecretField { + kind: SecretKind::KeyInDefault, + path: vec![ + SecretPathSegment::Field(std::borrow::Cow::Borrowed("integrations")), + SecretPathSegment::Field(std::borrow::Cow::Borrowed("datadome")), + SecretPathSegment::Field(std::borrow::Cow::Borrowed("server_side_key")), + ], + optional: false, + }; + let err = collect_secret_leaves(&raw, &field).expect_err("missing required leaf"); + assert!( + err.contains("integrations.datadome.server_side_key"), + "collector error names the dotted path: {err}" + ); + } +``` + +Nested `KeyInNamedStore` CLI case (proves the store_ref sibling is resolved within the innermost parent table, and the named store must be declared in `[stores.secrets].ids`): + +```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"); + } +``` + +- [x] **Step 2: Factor a TOML path leaf-collector** + +Add a helper that, given the raw `&toml::Value` table and a `&SecretField`, yields each resolved leaf as `(label: String, value: &str, store_ref_value: Option<&str>)`, where `label` uses concrete `[n]` indices and `store_ref_value` is resolved from the leaf's innermost parent table (for `KeyInNamedStore`). Absent optional leaves yield nothing; missing required leaves yield an error carrying the dotted label. + +```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) +} +``` + +- [x] **Step 3: Rewrite the two consumers to use the collector** + +Replace the flat lookups in `run_adapter_typed_checks` (`config.rs:1295-1333`) and `typed_secret_checks` (`config.rs:1339-1412`): + +- `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). + +- [x] **Step 4: Run (passes) + full CLI tests** + +Run: `cargo test -p edgezero-cli --lib config 2>&1 | tail -25` +Expected: PASS — new nested tests + all pre-existing config tests (top-level fixtures still length-1 paths). + +- [x] **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. + +- [x] **Step 1: Write the failing E2E test** + +Create `crates/edgezero-macros/tests/nested_secrets_e2e.rs`. Define a real nested config with `#[derive(AppConfig, Deserialize, Validate)]`, including one `KeyInDefault` nested leaf and one `KeyInNamedStore` nested leaf whose `store_ref` sibling lives in the same inner struct. Build a `serde_json::Value` blob holding key NAMES, run `secret_walk` (via the public `AppConfig` extraction path or by calling the crate's extraction entry point), and assert the resolved values. + +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". +``` + +- [x] **Step 2: Run (fails, then passes)** + +Run: `cargo test -p edgezero-macros --test nested_secrets_e2e 2>&1 | tail -25` +Expected: FAIL first (fixture/wiring), then PASS once assertions match resolved values. (If any Task 1–6 gap surfaces here, fix in the owning task's file and re-run.) + +- [x] **Step 3: Docs** + +Append to `docs/guide/configuration.md` a "Nested and array secrets" section documenting: the `#[app_config(nested)]` opt-in (mirrors `#[validate(nested)]`; the nested type must itself derive `AppConfig`), `#[secret]` on `Option` (absent → skipped at runtime), object nesting and `Vec<_>` arrays (`partners[*].api_key`), the `store_ref` sibling scoping rule (resolved within the innermost containing object), and the dotted-path error format (`integrations.datadome.server_side_key`, `partners[3].api_key`). Include a worked `Settings`/`Integrations`/`Partner` example. + +- [x] **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`. + +- [x] **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. + +## Review round 4 — fixes folded in + +- **`field_has_nested_optin` false-positive on `#[app_config()]`:** the CI-guard helper checked only `parse_nested_meta(..).is_ok()`, so a bare `#[app_config()]` (no `nested`) reported opt-in. Now tracks a `found` flag and returns `parsed.is_ok() && found` (Task 5 Step 3). + - **Round-5 correction:** the *derive*'s `nested_optin` had the mirror bug (returned `Ok(false)` for empty `#[app_config()]`, silently NOT recursing and dropping the child's secrets — the opposite failure from the guard's false-positive). Fixed to hard-error when an `app_config` attribute is present without `nested`, per its documented contract; added the `app_config_empty.rs` trybuild fixture. (An earlier note here wrongly claimed the derive already handled this.) +- **`NestedAppConfigVisitor::new` arg order:** the Task 5 test helper had the args reversed; the real signature is `new(source_path, app_config_structs)` (`check_no_nested_app_config.rs:127`). Fixed. +- **Missing-nested-leaf CLI test was deserialize coverage, not collector coverage:** in `run_config_validate_typed`, serde deserialization (`config.rs:207`) runs before `typed_secret_checks`/`run_adapter_typed_checks`, so a *missing required* leaf fails deserialization first and never reaches the path collector. Retitled that test `..._rejects_missing_required_nested_leaf_at_deserialize` and added two **direct** `collect_secret_leaves` unit tests (array-index labels + missing-required-leaf dotted error) that bypass the entry point (Task 6 Step 1). +- **State plan stale snippet:** the State extractor plan's missing-registration test used `expect_err` (needs `State: Debug`); updated to `.err().expect(..)` to match the committed code. +- **Spec §4.3 reconciled:** the B-3 note and the recursion sketch now reference `secret_fields()` (owned `Vec`) instead of child `SECRET_FIELDS` / `Cow` const shapes. diff --git a/docs/superpowers/plans/2026-07-02-edgezero-state-extractor.md b/docs/superpowers/plans/2026-07-02-edgezero-state-extractor.md new file mode 100644 index 00000000..2eb86314 --- /dev/null +++ b/docs/superpowers/plans/2026-07-02-edgezero-state-extractor.md @@ -0,0 +1,812 @@ +# 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` | `state_extensions: Extensions` bag on `RouterBuilder`/`RouterInner`, `RouterBuilder::with_state` (inserts into it), thread through `build()` → `RouterService::new` → `RouterInner`, `extend` in `dispatch` + router tests | Modify | +| `crates/edgezero-macros/tests/action_state.rs` | Integration test proving `#[action]` composes `State` with `Query` end-to-end | Create | +| `crates/edgezero-macros/Cargo.toml` | Add `futures` dev-dependency (for `block_on` in the integration test) | Modify | +| `docs/guide/handlers.md` | "Sharing app state" section | Modify (append) | + +--- + +## 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). + +- [x] **Step 1: Write the failing tests** + +Append to the `#[cfg(test)] mod tests` block at the end of `crates/edgezero-core/src/extractor.rs`. The module already imports `request_builder, Method, StatusCode` (from `crate::http`), `RequestContext`, `PathParams`, `Body`, `block_on`, and `std::sync::Arc`. + +```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()); + + // `.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() + } + ); + } +``` + +- [x] **Step 2: Run tests to verify they fail** + +Run: `cargo test -p edgezero-core --lib state_extractor 2>&1 | tail -20` +Expected: FAIL — compile error `cannot find type/struct State in this scope` (the extractor does not exist yet). + +- [x] **Step 3: Write the extractor** + +Insert into `crates/edgezero-core/src/extractor.rs` immediately after the `Kv` extractor block (after the `impl Kv { ... }` that ends around `extractor.rs:529`), before the next extractor. `anyhow` is already used in this file; `core::any::type_name` needs no import. + +```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 + } +} +``` + +- [x] **Step 4: Run tests to verify they pass** + +Run: `cargo test -p edgezero-core --lib state_extractor 2>&1 | tail -20` +Expected: PASS — 3 tests (`state_extractor_resolves_registered_value`, `state_extractor_missing_registration_is_internal_error`, `state_extractor_deref_and_into_inner`). + +- [x] **Step 5: Lint** + +Run: `cargo clippy -p edgezero-core --all-targets --all-features -- -D warnings 2>&1 | tail -20` +Expected: no warnings. + +- [x] **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 + +> **Superseded design note (post-review refactor):** the shipped implementation +> is simpler than the `StateInserter` closure approach the steps below describe. +> Instead of `state_inserters: Vec>`, the builder and +> `RouterInner` hold a single **`state_extensions: Extensions`** bag; +> `with_state` is `self.state_extensions.insert(value)`, and dispatch does +> `request.extensions_mut().extend(self.state_extensions.clone())` after the +> introspection inserts. This removes the alias, the `Vec`, one closure +> allocation per registered state, and one vtable call per state per request — +> identical behavior (`http::Extensions::insert` bound is `Clone + Send + Sync + +> 'static`; `extend` is last-write-wins by `TypeId`). The step-by-step code below +> reflects the original closure approach; follow router.rs for the final shape. + +**Files:** +- Modify: `crates/edgezero-core/src/router.rs` (add `state_extensions: Extensions` field on `RouterBuilder` and `RouterInner`, `with_state` method inserting into it, 5th arg through `build()`/`RouterService::new`, `extend` in `dispatch`; add router tests in the existing `#[cfg(test)] mod tests`) + +**Interfaces:** +- Consumes: `State` from Task 1 (`crate::extractor::State`), `crate::http::Extensions` (facade alias), `std::sync::Arc` (imported at `router.rs:2`). +- Produces: `RouterBuilder::with_state(self, value: T) -> Self where T: Clone + Send + Sync + 'static`. Consumed by Task 3. + +- [x] **Step 1: Write the failing router tests** + +Append to `crates/edgezero-core/src/router.rs`'s main `#[cfg(test)] mod tests` (the block whose imports are at `router.rs:476`, which already imports `Arc, Mutex`, `block_on`, `noop_waker_ref`, `Context, Poll`, `request_builder, Method, StatusCode`, `Body`, `RequestContext`, `EdgeError`). + +```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"); + } +``` + +- [x] **Step 2: Run tests to verify they fail** + +Run: `cargo test -p edgezero-core --lib with_state 2>&1 | tail -20` +Expected: FAIL — `no method named with_state found for struct RouterBuilder`. + +> ⚠️ **Steps 3–8 below are the ORIGINAL closure approach and were superseded** +> (see the banner at the top of Task 2). The shipped code has **no `StateInserter` +> alias**: `RouterBuilder`/`RouterInner` hold a `state_extensions: Extensions` +> bag, `with_state` is `self.state_extensions.insert(value)`, and dispatch is +> `request.extensions_mut().extend(self.state_extensions.clone())`. Read +> `crates/edgezero-core/src/router.rs` for the final shape; don't follow the +> `state_inserters`/`StateInserter` snippets below literally. + +- [x] **Step 3: Add the `StateInserter` type alias** *(superseded — see note above)* + +In `crates/edgezero-core/src/router.rs`, add just above `pub struct RouterBuilder` (which is at `router.rs:71`, under its `#[derive(Default)]` at `router.rs:70`): + +```rust +/// Type-erased closure that clones a registered state value into a request's +/// extensions at dispatch. See [`RouterBuilder::with_state`]. +type StateInserter = Arc; +``` + +- [x] **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, +} +``` + +- [x] **Step 5: Add the `with_state` method** + +In the `impl RouterBuilder` block, add immediately after `with_manifest_json` (which is at `router.rs:190-195`): + +```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 + } +``` + +- [x] **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, + ) + } +``` + +- [x] **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, + }), + } + } +``` + +- [x] **Step 8: Apply the inserters in `dispatch`** + +In `RouterInner::dispatch` (`router.rs:206-237`), inside the `RouteMatch::Found(entry, params)` arm, add the state-insertion loop after the `needs.routes` block and before `let ctx = RequestContext::new(request, params);` (currently `router.rs:227`). The arm becomes: + +```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 + } +``` + +- [x] **Step 9: Run tests to verify they pass** + +Run: `cargo test -p edgezero-core --lib with_state 2>&1 | tail -20` +Expected: PASS — 4 tests (`with_state_exposes_value_to_handler`, `with_state_supports_multiple_distinct_types`, `with_state_same_type_is_last_write_wins`, `with_state_no_cross_request_bleed`). + +- [x] **Step 10: Full crate test + lint** + +Run: `cargo test -p edgezero-core 2>&1 | tail -20 && cargo clippy -p edgezero-core --all-targets --all-features -- -D warnings 2>&1 | tail -20` +Expected: all existing + new tests PASS; clippy clean (proves the router restructure did not regress introspection tests). + +- [x] **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. + +- [x] **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.) + +- [x] **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"); + } +} +``` + +- [x] **Step 3: Run the integration test** + +Run: `cargo test -p edgezero-macros --test action_state 2>&1 | tail -20` +Expected: PASS — `action_composes_state_and_query`. (This simultaneously proves the macro needs no change: `State` is dispatched by the same generic `FromRequest` line as `Query`.) + +- [x] **Step 4: Add the docs section** + +Append to `docs/guide/handlers.md` a new section (place it after the existing extractor documentation, before any "Next steps"/footer): + +```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()`. +``` + +- [x] **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). + +- [x] **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_extensions: Extensions` bag, `with_state`, dispatch `extend`) → Task 2, mirroring PR #300's `manifest_json` column; simplified from the spec's `StateInserter` closure sketch. +- §3.3 naming → `State` only (no `Extension` alias), per locked decision. +- §3.4 tests: resolves registered / 500 unregistered / Deref (Task 1); handler sees value / two `T`s coexist / last-write-wins (Task 2); `#[action]` composition (Task 3); concurrency/no-bleed (Task 2). +- §3.5 docs: `docs/guide/handlers.md` + rustdoc → Task 3. +- §8 corrections folded in: facade `crate::http::Extensions` (not bare `http::Extensions`); no `lib.rs` re-export; `state_extensions` bag threaded through `RouterInner` + `RouterService::new` + `build()`. diff --git a/docs/superpowers/plans/2026-07-04-edgezero-p0c-fastly-dispatch-fidelity.md b/docs/superpowers/plans/2026-07-04-edgezero-p0c-fastly-dispatch-fidelity.md new file mode 100644 index 00000000..acf2ddac --- /dev/null +++ b/docs/superpowers/plans/2026-07-04-edgezero-p0c-fastly-dispatch-fidelity.md @@ -0,0 +1,899 @@ +# EdgeZero P0-C — Fastly `run_app` Dispatch Fidelity Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Bring Fastly's `run_app` to parity with hand-written custom dispatch: preserve multi-value response headers (`Set-Cookie`), let an app opt out of the adapter's logger init, and add a pre-dispatch hook that reads raw-`fastly::Request` signals (JA4 / H2 / client IP) into the core request's extensions. + +**Architecture:** Three independent Fastly-adapter changes plus one cross-adapter `Hooks` method. C1 swaps `set_header`→`append_header` on the (fresh) response and fixes the proxy-response conversion to append per value. C2 adds a platform-neutral `Hooks::owns_logging()` that every adapter's entrypoint gates its logger init on, with an `app!(owns_logging = …)` macro argument. C3 adds `run_app_with_request_extensions` that runs an app closure against a scratch `Extensions` **before** request conversion, then `extend`s it into the core request. + +**Tech Stack:** Rust 1.95, edition 2021. `edgezero-adapter-fastly` (behind the `fastly` feature), `edgezero-core` (`Hooks` trait, `http::Extensions`), `edgezero-macros` (`app!`). `http` crate `HeaderMap`/`Extensions` via `edgezero_core::http`. + +**Source spec:** `docs/superpowers/specs/2026-07-03-edgezero-p0cd-fastly-dispatch-and-appstate-design.md` (P0-C), verified against `65afbd3`. + +## Global Constraints + +- **Rust 1.95.0**, edition 2021. +- **Strict clippy gate** — `[workspace.lints.clippy] restriction = { level = "deny" }`. Every restriction lint is an ERROR. The ones that bite here: + - `missing_trait_methods` — an `impl Trait` may not inherit a defaulted method. Adding `Hooks::owns_logging()` forces the **macro-emitted** `impl Hooks` to emit it explicitly (the two in-file `Hooks` test stubs already carry `#[expect(clippy::missing_trait_methods)]`, so they need no change). + - `arbitrary_source_item_ordering` — module items / struct fields / enum variants must be alphabetical; place new test fns and struct fields in the correct position, don't append. + - `min_ident_chars` — no single-char identifiers. + - `absolute_paths` — import types; don't inline 3-segment paths. + - `impl_trait_in_params` — use named generics, not `impl Trait` params. + - `assertions_on_result_states` — in tests use `.unwrap()/.unwrap_err()/.expect()` or `assert_eq!`, not `assert!(x.is_ok())`. + - `needless_raw_strings` — plain string unless it needs `"`/`#`. +- **Test targets/features (CRITICAL — the fastly adapter is a wasm-only crate; verified empirically):** + - `--features fastly` pulls in `libfastly`, which references undefined Compute@Edge hostcall symbols on the host. **`cargo test -p edgezero-adapter-fastly --features fastly` from the workspace root FAILS TO LINK** (`ld: symbol(s) not found`). Do not use it. + - The crate ships a per-package `crates/edgezero-adapter-fastly/.cargo/config.toml` that sets `build.target = "wasm32-wasip1"` and a **Viceroy** runner — **but only when cargo is invoked from inside the crate directory.** So run all fastly-adapter tests as: + ``` + (cd crates/edgezero-adapter-fastly && cargo test --features fastly --lib ) + ``` + This builds to `wasm32-wasip1` and runs the test binary under Viceroy (0.17.0, pinned in `.tool-versions`), which provides the hostcalls — so `FastlyResponse::from_status`/`append_header`/`get_header_all`, `FastlyRequest::new`/`get_url_str`, and even `get_client_ip_addr` all work at runtime. **Every `cargo test -p edgezero-adapter-fastly --features fastly …` command in the tasks below MUST be run in this `(cd crates/edgezero-adapter-fastly && cargo test --features fastly …)` form** — the commands are written the short way for brevity. + - `crates/edgezero-adapter-fastly/tests/contract.rs` is `#![cfg(all(feature = "fastly", target_arch = "wasm32"))]` (the same wasm/Viceroy path). CI runs it via `cargo test -p edgezero-adapter-fastly --features fastly --target wasm32-wasip1 --test contract`. + - `cargo clippy`/`cargo check --features fastly` type-check on the **host** (no link), so clippy runs from the workspace root as usual. +- **CI gates (all must pass):** + 1. `cargo fmt --all -- --check` + 2. `cargo clippy --workspace --all-targets --all-features -- -D warnings` + 3. `cargo test --workspace --all-targets` + 4. `cargo check --workspace --all-targets --features "fastly cloudflare spin"` + 5. `cargo check -p edgezero-adapter-spin --target wasm32-wasip2 --features spin` +- **No backward-compat constraint** — prefer the cleanest breaking change; update every in-tree call site in the same PR. +- **Shared with P0-D:** Task 4 reworks the `app!` `AppArgs` grammar into keyword arguments (adding `owns_logging`). The sibling P0-D plan (`2026-07-04-edgezero-p0d-app-macro-state.md`) **extends that same grammar** with a `state` key — execute this P0-C plan first so P0-D builds on the keyword-arg framework. + +--- + +## File Structure + +| File | Responsibility | Task | +| ---- | -------------- | ---- | +| `crates/edgezero-adapter-fastly/src/response.rs` | `from_core_response`: `set_header`→`append_header` + host test | 1 | +| `crates/edgezero-adapter-fastly/src/proxy.rs` | `convert_response`: append per value; `build_fastly_request` request-side note + host tests | 2 | +| `crates/edgezero-core/src/app.rs` | `Hooks::owns_logging()` default + trait test | 3 | +| `crates/edgezero-macros/src/app.rs` | emit `fn owns_logging()`; then (Task 4) rework `AppArgs` to keyword grammar + `owns_logging =` | 3, 4 | +| `crates/edgezero-adapter-{fastly,cloudflare,axum,spin}/src/…` | gate each logger init on `!A::owns_logging()` | 3 | +| `crates/edgezero-adapter-fastly/src/lib.rs` | `run_app_with_request_extensions` + gate logger | 3, 5 | +| `crates/edgezero-adapter-fastly/src/request.rs` | thread the pre-dispatch closure; scratch-`Extensions`-then-`extend` around `into_core_request` + host test | 5 | + +--- + +## Task 1: C1 — multi-value response headers in `from_core_response` + +**Files:** +- Modify: `crates/edgezero-adapter-fastly/src/response.rs` (`from_core_response` at `response.rs:28-30`; add a host test in the existing `#[cfg(test)] mod tests`) + +**Interfaces:** +- Consumes: `from_core_response(response: edgezero_core::http::Response) -> Result` (existing); `response_builder()`, `Body` (test-module imports already present). +- Produces: unchanged signature; behavior now preserves duplicate header values. + +- [x] **Step 1: Write the failing host test** + +Add to the `#[cfg(test)] mod tests` in `crates/edgezero-adapter-fastly/src/response.rs`, placed in alphabetical position among the test fns (before `stream_body_is_written_to_fastly_response`). The module already imports `super::*`, `Body`, `response_builder`. + +```rust + #[test] + fn multi_value_set_cookie_survives_conversion() { + // http::response::Builder::header APPENDS, so this is two Set-Cookie values. + let response = response_builder() + .status(200) + .header("set-cookie", "a=1") + .header("set-cookie", "b=2") + .body(Body::empty()) + .expect("response"); + + let fastly_response = from_core_response(response).expect("fastly response"); + + let cookies: Vec = fastly_response + .get_header_all("set-cookie") + .map(|value| value.to_str().expect("utf8").to_owned()) + .collect(); + assert_eq!(cookies, vec!["a=1".to_owned(), "b=2".to_owned()]); + } +``` + +- [x] **Step 2: Run it — verify it fails** + +Run: `cargo test -p edgezero-adapter-fastly --features fastly --lib multi_value_set_cookie 2>&1 | tail -20` +Expected: FAIL — the collected `cookies` is `["b=2"]` (the loop's `set_header` replaced the first value). + +- [x] **Step 3: Swap `set_header` → `append_header`** + +In `crates/edgezero-adapter-fastly/src/response.rs`, change the header loop (`response.rs:28-30`): + +```rust + for (name, value) in &parts.headers { + fastly_response.set_header(name.as_str(), value.as_bytes()); + } +``` + +to: + +```rust + // `append_header` preserves multi-value headers (e.g. N `Set-Cookie`). The + // response starts empty (`from_status`) and `http::HeaderMap` iteration + // yields one entry per value, so appending is unconditionally correct. + for (name, value) in &parts.headers { + fastly_response.append_header(name.as_str(), value.as_bytes()); + } +``` + +- [x] **Step 4: Run it — verify it passes** + +Run: `cargo test -p edgezero-adapter-fastly --features fastly --lib multi_value_set_cookie 2>&1 | tail -8` +Expected: PASS. Also run the whole module: `cargo test -p edgezero-adapter-fastly --features fastly --lib response 2>&1 | tail -8` → all pass. + +- [x] **Step 5: Lint** + +Run: `cargo clippy -p edgezero-adapter-fastly --all-targets --features fastly -- -D warnings 2>&1 | tail -5` +Expected: clean. + +- [x] **Step 6: Commit** + +```bash +git add crates/edgezero-adapter-fastly/src/response.rs +git commit -m "fix(fastly): preserve multi-value response headers (Set-Cookie) in from_core_response" +``` + +--- + +## Task 2: C1 — multi-value headers in the proxy response conversion + +**Files:** +- Modify: `crates/edgezero-adapter-fastly/src/proxy.rs` (`convert_response` at `proxy.rs:67-71`; `build_fastly_request` at `proxy.rs:53`; add host tests) + +**Interfaces:** +- Consumes: `convert_response(fastly_response: &mut fastly::Response) -> edgezero_core::proxy::ProxyResponse` (existing, private); `HeaderName` from `edgezero_core::http`. +- Produces: `convert_response` preserving duplicate origin response headers. + +- [x] **Step 1: Write the failing host test** + +Add to the `#[cfg(test)] mod tests` in `crates/edgezero-adapter-fastly/src/proxy.rs` (module already imports `super::*`, `block_on`). Place alphabetically among the existing `stream_handles_*` tests (before `stream_handles_brotli`). + +```rust + #[test] + fn convert_response_preserves_multi_value_set_cookie() { + let mut fastly_response = FastlyResponse::from_status(200); + fastly_response.append_header("set-cookie", "a=1"); + fastly_response.append_header("set-cookie", "b=2"); + + let proxy_response = convert_response(&mut fastly_response); + + let cookies: Vec = proxy_response + .headers() + .get_all("set-cookie") + .into_iter() + .map(|value| value.to_str().expect("utf8").to_owned()) + .collect(); + assert_eq!(cookies, vec!["a=1".to_owned(), "b=2".to_owned()]); + } +``` + +- [x] **Step 2: Run it — verify it fails** + +Run: `cargo test -p edgezero-adapter-fastly --features fastly --lib convert_response_preserves 2>&1 | tail -20` +Expected: FAIL — `cookies` is `["a=1"]` (or one value): `get_header` returns the first value and `HeaderMap::insert` replaces. + +- [x] **Step 3: Fix `convert_response` to append every value** + +In `crates/edgezero-adapter-fastly/src/proxy.rs`, change the header loop (`proxy.rs:67-71`): + +```rust + for header in fastly_response.get_header_names() { + if let Some(value) = fastly_response.get_header(header) { + proxy_response.headers_mut().insert(header, value.clone()); + } + } +``` + +to: + +```rust + // Preserve multi-value ORIGIN response headers (e.g. Set-Cookie): read ALL + // values per name and append, instead of first-value + insert (which + // replaced). `get_header_names()` yields `&HeaderName`, usable for both + // `get_header_all` and `append`. + for name in fastly_response.get_header_names() { + for value in fastly_response.get_header_all(name) { + proxy_response.headers_mut().append(name, value.clone()); + } + } +``` + +(If the installed `fastly` SDK's `get_header_names()` yields owned `HeaderName` rather than `&HeaderName`, bind `for name in …` then call `get_header_all(&name)` / `append(&name, …)`. Confirm by the compiler; behavior is identical.) + +- [x] **Step 4: Run it — verify it passes** + +Run: `cargo test -p edgezero-adapter-fastly --features fastly --lib convert_response_preserves 2>&1 | tail -8` +Expected: PASS. Run the module: `cargo test -p edgezero-adapter-fastly --features fastly --lib proxy 2>&1 | tail -8` → all pass. + +- [x] **Step 5: Request-side audit — switch to `append_header` for consistency** + +Origin-bound request duplicate headers are rare, but for consistency and to remove the same class of latent bug, change the request-build loop in `build_fastly_request` (`proxy.rs:53`): + +```rust + fastly_request.set_header(name.as_str(), value.clone()); +``` + +to: + +```rust + fastly_request.append_header(name.as_str(), value.clone()); +``` + +Leave the explicit `Host` line (`proxy.rs:57`) as `set_header` — it is a single computed value that must replace, not append. Add a one-line comment above the loop: + +```rust + // Append (not set) so a multi-value client header survives; `Host` below is + // set explicitly as a single value. +``` + +- [x] **Step 6: Run + lint** + +Run: `cargo test -p edgezero-adapter-fastly --features fastly --lib proxy 2>&1 | tail -8` +Expected: PASS. +Run: `cargo clippy -p edgezero-adapter-fastly --all-targets --features fastly -- -D warnings 2>&1 | tail -5` +Expected: clean. + +- [x] **Step 7: Commit** + +```bash +git add crates/edgezero-adapter-fastly/src/proxy.rs +git commit -m "fix(fastly): preserve multi-value headers in proxy response/request conversion" +``` + +--- + +## Task 3: C2 — `Hooks::owns_logging()` + gate every adapter's logger init + +This task adds the trait method and wires it everywhere **atomically** — because adding a defaulted `Hooks` method breaks the macro-emitted impl under `missing_trait_methods` until the macro emits it. The `app!(owns_logging = …)` *argument* (grammar rework) is Task 4; here the macro emits a hardcoded `false`. + +**Files:** +- Modify: `crates/edgezero-core/src/app.rs` (add `owns_logging()` to `Hooks`, `app.rs:104-143`; add a trait test) +- Modify: `crates/edgezero-macros/src/app.rs` (emit `fn owns_logging() -> bool { false }` in the `Hooks` impl, `app.rs:165-183`) +- Modify: `crates/edgezero-adapter-fastly/src/lib.rs` (`run_app:117`, `run_app_with_config:205-208`) +- Modify: `crates/edgezero-adapter-cloudflare/src/lib.rs` (`run_app:105`) +- Modify: `crates/edgezero-adapter-axum/src/dev_server.rs` (`run_app:343`) +- Modify: `crates/edgezero-adapter-spin/src/lib.rs` (`run_app:115`) + +**Interfaces:** +- Produces: `Hooks::owns_logging() -> bool` (default `false`). Consumed by all four `run_app` entrypoints and by Task 4 (macro argument). + +- [x] **Step 1: Write the failing trait test** + +Add to the `#[cfg(test)] mod tests` in `crates/edgezero-core/src/app.rs`, placed alphabetically among the existing test fns. `DefaultHooks` (defined in that module, `app.rs:163`) overrides only `routes`/`stores`, so it should report the default. + +```rust + #[test] + fn default_hooks_do_not_own_logging() { + assert!(!DefaultHooks::owns_logging()); + } +``` + +- [x] **Step 2: Run it — verify it fails** + +Run: `cargo test -p edgezero-core --lib default_hooks_do_not_own_logging 2>&1 | tail -15` +Expected: FAIL — `no method named owns_logging found`. + +- [x] **Step 3: Add `owns_logging()` to the `Hooks` trait** + +In `crates/edgezero-core/src/app.rs`, add to the `Hooks` trait. **`arbitrary_source_item_ordering` (restriction = deny) enforces alphabetical trait methods**, so place `owns_logging` between `name` and `routes` (order: `build_app`, `configure`, `name`, `owns_logging`, `routes`, `stores`) — not adjacent to `configure`: + +```rust + /// When `true`, an adapter's `run_app` skips its own logger initialization; + /// the app is responsible for installing a `log` backend. Default `false`. + #[must_use] + #[inline] + fn owns_logging() -> bool { + false + } +``` + +- [x] **Step 4: Emit `owns_logging` from the `app!` macro** + +In `crates/edgezero-macros/src/app.rs`, add to the emitted `impl edgezero_core::app::Hooks` block (`app.rs:165-183`) — after `configure`, mirroring the explicit-defaults pattern the file already documents at `app.rs:154-158`: + +```rust + fn configure(_app: &mut edgezero_core::app::App) {} + + fn owns_logging() -> bool { + false + } +``` + +Update the `missing_trait_methods` comment at `app.rs:154-158` to include `owns_logging` in the list of explicitly-emitted defaults: + +```rust + // The emitted `Hooks` impl below explicitly defines `configure`, + // `owns_logging`, and `build_app` even though their bodies mirror the trait + // defaults. This is required because `missing_trait_methods` (restriction = + // deny) forbids relying on trait defaults in the impl. If those Hooks + // defaults change, update these emitted bodies to match. +``` + +- [x] **Step 5: Gate the four adapter logger-init sites** + +Each adapter's `run_app` (and Fastly's `run_app_with_config`) must skip its logger init when `A::owns_logging()`: + +**Fastly** — `crates/edgezero-adapter-fastly/src/lib.rs`, `run_app` (`lib.rs:117`): +```rust + if logging.use_fastly_logger && !A::owns_logging() { + let endpoint = logging.endpoint.as_deref().unwrap_or("stdout"); + init_logger(endpoint, logging.level, logging.echo_stdout)?; + } +``` +and the identical block in `run_app_with_config` (`lib.rs:205-208`) — same `&& !A::owns_logging()`. + +**Cloudflare** — `crates/edgezero-adapter-cloudflare/src/lib.rs`, `run_app` (`lib.rs:105`): +```rust + if !A::owns_logging() { + drop(init_logger()); + } +``` + +**Axum** — `crates/edgezero-adapter-axum/src/dev_server.rs`, `run_app` (`dev_server.rs:343`): +```rust + if !A::owns_logging() { + let _logger_init = SimpleLogger::new().with_level(level).init(); + } +``` + +**Spin** — `crates/edgezero-adapter-spin/src/lib.rs`, `run_app` (`lib.rs:115`) — Spin's `init_logger` is a no-op, but gate it so the neutral flag is honest everywhere: +```rust + if !A::owns_logging() { + drop(init_logger()); + } +``` + +- [x] **Step 6: Run the trait test + workspace build** + +Run: `cargo test -p edgezero-core --lib default_hooks_do_not_own_logging 2>&1 | tail -8` +Expected: PASS. +Run: `cargo check --workspace --all-targets --features "fastly cloudflare spin" 2>&1 | tail -5` +Expected: succeeds (macro emission satisfies `missing_trait_methods`; all four adapters compile). + +- [x] **Step 7: Lint + WASM checks** + +Run: `cargo clippy --workspace --all-targets --all-features -- -D warnings 2>&1 | tail -5` +Expected: clean (the two `Hooks` test stubs already carry `#[expect(clippy::missing_trait_methods)]`, so the new defaulted method needs no change there). +Run: `cargo check -p edgezero-adapter-spin --target wasm32-wasip2 --features spin 2>&1 | tail -3` +Expected: succeeds. + +- [x] **Step 8: Commit** + +```bash +git add crates/edgezero-core/src/app.rs crates/edgezero-macros/src/app.rs \ + crates/edgezero-adapter-fastly/src/lib.rs \ + crates/edgezero-adapter-cloudflare/src/lib.rs \ + crates/edgezero-adapter-axum/src/dev_server.rs \ + crates/edgezero-adapter-spin/src/lib.rs +git commit -m "feat: Hooks::owns_logging() opt-out gated in all four adapter run_app entrypoints" +``` + +--- + +## Task 4: C2 — `app!(owns_logging = )` argument (keyword-arg grammar) + +Rework `AppArgs` from "path + optional ident" into the keyword-argument grammar the spec defines (§`app! argument grammar`), implementing the `owns_logging` key. This is the **shared grammar framework** the P0-D `state` key extends. + +**Files:** +- Modify: `crates/edgezero-macros/src/app.rs` (`AppArgs` struct + `impl Parse`, `app.rs:12-31`; emit `fn owns_logging() -> bool { #bool }`, `app.rs:170`; add `AppArgs` unit tests to the `#[cfg(test)] mod tests`) +- Create: `crates/edgezero-macros/tests/app_macro.rs` (real-`app!` integration test asserting the emitted `owns_logging()`) +- Create: `crates/edgezero-macros/tests/fixtures/owns_logging.toml` (minimal fixture manifest) + +**Interfaces:** +- Consumes: `syn::{LitStr, Ident, LitBool, Token}`, `syn::parse::{Parse, ParseStream}`. +- Produces: `struct AppArgs { path: LitStr, app_ident: Option, owns_logging: Option }` with a keyword-arg parser. **P0-D adds `state: Option` to this same struct and parser.** + +- [x] **Step 1: Write the failing `AppArgs` unit tests** + +Add a focused unit-test module for the parser. Put it in the existing `#[cfg(test)] mod tests` in `crates/edgezero-macros/src/app.rs` (which currently imports only `parse_handler_path`). Add `use super::AppArgs;` and `use syn::parse_str;`. Tests (place alphabetically among the existing `parse_handler_path_*` fns): + +```rust + #[test] + fn app_args_parses_path_only() { + let args: AppArgs = parse_str(r#""edgezero.toml""#).expect("parse"); + assert_eq!(args.path.value(), "edgezero.toml"); + assert!(args.app_ident.is_none()); + assert_eq!(args.owns_logging, None); + } + + #[test] + fn app_args_parses_path_and_app_ident() { + let args: AppArgs = parse_str(r#""edgezero.toml", MyApp"#).expect("parse"); + assert_eq!(args.app_ident.map(|ident| ident.to_string()), Some("MyApp".to_owned())); + assert_eq!(args.owns_logging, None); + } + + #[test] + fn app_args_parses_owns_logging_true() { + let args: AppArgs = parse_str(r#""edgezero.toml", owns_logging = true"#).expect("parse"); + assert_eq!(args.owns_logging, Some(true)); + assert!(args.app_ident.is_none()); + } + + #[test] + fn app_args_parses_app_ident_then_keyword() { + let args: AppArgs = + parse_str(r#""edgezero.toml", MyApp, owns_logging = false"#).expect("parse"); + assert_eq!(args.app_ident.map(|ident| ident.to_string()), Some("MyApp".to_owned())); + assert_eq!(args.owns_logging, Some(false)); + } + + #[test] + fn app_args_rejects_unknown_key() { + let err = parse_str::(r#""edgezero.toml", bogus = true"#).expect_err("unknown key"); + assert!(err.to_string().contains("unknown `app!` argument `bogus`"), "got: {err}"); + } + + #[test] + fn app_args_rejects_duplicate_key() { + let err = parse_str::(r#""edgezero.toml", owns_logging = true, owns_logging = false"#) + .expect_err("duplicate"); + assert!(err.to_string().contains("duplicate `owns_logging`"), "got: {err}"); + } + + #[test] + fn app_args_rejects_ident_after_keyword() { + let err = parse_str::(r#""edgezero.toml", owns_logging = true, MyApp"#) + .expect_err("ident after keyword"); + assert!( + err.to_string().contains("must come immediately after the manifest path"), + "got: {err}" + ); + } +``` + +- [x] **Step 2: Run — verify they fail** + +Run: `cargo test -p edgezero-macros --lib app_args_ 2>&1 | tail -20` +Expected: FAIL — the current `AppArgs` has no `owns_logging` field and rejects `owns_logging = true` with "unexpected tokens". + +- [x] **Step 3: Rework `AppArgs` + `impl Parse`** + +Replace `crates/edgezero-macros/src/app.rs:12-31` with: + +```rust +// `#[derive(Debug)]` is required: the `app_args_rejects_*` unit tests use +// `.expect_err(..)`, whose `Ok` arm (`AppArgs`) must be `Debug`. +#[derive(Debug)] +struct AppArgs { + app_ident: Option, + owns_logging: Option, + path: LitStr, +} + +impl Parse for AppArgs { + fn parse(input: ParseStream) -> syn::Result { + let path: LitStr = input.parse()?; + let mut app_ident: Option = None; + let mut owns_logging: Option = None; + let mut seen_keyword = false; + + while input.peek(Token![,]) { + input.parse::()?; + + // Keyword argument: `Ident = Value`. + if input.peek(Ident) && input.peek2(Token![=]) { + let key: Ident = input.parse()?; + input.parse::()?; + seen_keyword = true; + match key.to_string().as_str() { + "owns_logging" => { + if owns_logging.is_some() { + return Err(syn::Error::new(key.span(), "duplicate `owns_logging` argument")); + } + let value: syn::LitBool = input.parse()?; + owns_logging = Some(value.value); + } + other => { + return Err(syn::Error::new( + key.span(), + format!("unknown `app!` argument `{other}`; expected `owns_logging`"), + )); + } + } + continue; + } + + // Bare identifier: the optional custom App type name, only before keywords. + if input.peek(Ident) { + if seen_keyword || app_ident.is_some() { + return Err(input.error( + "the custom App identifier must come immediately after the manifest path, before keyword arguments", + )); + } + app_ident = Some(input.parse::()?); + continue; + } + + return Err(input.error("expected a custom App identifier or `key = value` argument")); + } + + if !input.is_empty() { + return Err(input.error("unexpected tokens after app! macro arguments")); + } + Ok(Self { app_ident, owns_logging, path }) + } +} +``` + +Add `LitBool` isn't needed as an import (used as `syn::LitBool`); ensure `Ident`, `LitStr`, `Token`, `Parse`, `ParseStream` are already imported at the top of the file (they are — `use syn::{... Ident, LitStr, Token};` and `use syn::parse::{Parse, ParseStream};`). + +> **Note for P0-D:** the `match key.to_string()` arm is the extension point — P0-D adds a `"state" => { … }` arm and a `state: Option` field. The "expected `owns_logging`" message becomes "expected `state` or `owns_logging`" then. + +- [x] **Step 4: Emit the parsed `owns_logging` value** + +In `expand_app`, before the `quote!` block, compute the bool literal (default `false`). Near the other `let …_lit` bindings (around `app.rs:130-152`): + +```rust + let owns_logging_lit = args.owns_logging.unwrap_or(false); +``` + +Change the emitted `fn owns_logging()` (added in Task 3, currently `{ false }`) to use it: + +```rust + fn owns_logging() -> bool { + #owns_logging_lit + } +``` + +(`bool` implements `quote::ToTokens`, so `#owns_logging_lit` emits `true`/`false`.) + +- [x] **Step 5: Run the unit tests — verify they pass** + +Run: `cargo test -p edgezero-macros --lib app_args_ 2>&1 | tail -12` +Expected: PASS — all seven `app_args_*` tests. + +- [x] **Step 6: Add a real-`app!` macro-emission integration test (spec requirement)** + +The spec's C2 acceptance requires proving the macro *emits* `owns_logging() == true` for `app!(…, owns_logging = true)` — not just that the grammar parses. `edgezero-macros` dev-depends on `edgezero-core` (`crates/edgezero-macros/Cargo.toml:31`) which re-exports `app!` (`crates/edgezero-core/src/lib.rs:42`), and the macro resolves the manifest path against the invoking crate's `CARGO_MANIFEST_DIR` (`app.rs:243`), so an integration test can invoke `app!` with a checked-in fixture manifest. + +First create a minimal fixture manifest, `crates/edgezero-macros/tests/fixtures/owns_logging.toml`: + +```toml +[app] +name = "owns-logging-fixture" +``` + +Then create the integration test `crates/edgezero-macros/tests/app_macro.rs`: + +```rust +//! Integration coverage: `app!(..., owns_logging = true)` emits a `Hooks` impl +//! whose `owns_logging()` returns `true`. The manifest path resolves against +//! this crate's `CARGO_MANIFEST_DIR` (backticks required — `doc_markdown`), so +//! the fixture is `tests/fixtures/...`. + +// The macro emits `pub struct OwnedLoggingApp;`, a `Hooks` impl, and a free +// `build_router()` at this module scope. +edgezero_core::app!("tests/fixtures/owns_logging.toml", OwnedLoggingApp, owns_logging = true); + +// `#[test]` must live in a `#[cfg(test)] mod tests` (the `tests_outside_test_module` +// restriction lint), and `Hooks` must be imported (not a 3-segment path — `absolute_paths`). +#[cfg(test)] +mod tests { + use edgezero_core::app::Hooks as _; + + #[test] + fn app_macro_emits_owns_logging_true() { + assert!(super::OwnedLoggingApp::owns_logging()); + } +} +``` + +Run: `cargo test -p edgezero-macros --test app_macro 2>&1 | tail -12` +Expected: PASS — proves the macro emitted `fn owns_logging() -> bool { true }`. (Only one `app!` per test file — it emits a free `build_router()` that would collide across invocations; the default `owns_logging() == false` path is covered by app-demo below.) + +- [x] **Step 7: Verify app-demo (real `app!`, no keyword args) still emits owns_logging=false** + +app-demo's `app!("../../edgezero.toml")` (`examples/app-demo/crates/app-demo-core/src/lib.rs:11`) uses no keyword args, so its generated `owns_logging()` returns `false`. + +Run: `(cd examples/app-demo && cargo test -p app-demo-core 2>&1 | tail -5)` +Expected: PASS. (Do NOT add a process-global logger "call run_app twice" test — the macro-emission test above + the gate code review are the deterministic coverage.) + +- [x] **Step 8: Lint + commit** + +Run: `cargo clippy -p edgezero-macros --all-targets --all-features -- -D warnings 2>&1 | tail -5` +Expected: clean. + +```bash +git add crates/edgezero-macros/src/app.rs \ + crates/edgezero-macros/tests/app_macro.rs \ + crates/edgezero-macros/tests/fixtures/owns_logging.toml +git commit -m "feat(macros): app!(owns_logging = ) keyword argument + AppArgs keyword grammar" +``` + +--- + +## Task 5: C3 — pre-dispatch raw-request hook (`run_app_with_request_extensions`) + +Add a Fastly `run_app` variant taking an app closure that reads the raw `fastly::Request` (JA4 / H2 / etc.) into a scratch `Extensions` **before** `into_core_request` consumes the request; the scratch bag is `extend`ed into the core request after conversion. `run_app` becomes the no-hook wrapper. + +**Files:** +- Modify: `crates/edgezero-adapter-fastly/src/request.rs` (thread the closure through `dispatch_with_registries`/`dispatch_with_handles`; scratch-`extend` around `into_core_request` at `request.rs:284`; update the OTHER `dispatch_with_handles` caller `FastlyService::dispatch` at `request.rs:145` to pass a no-op; add a host unit test) +- Modify: `crates/edgezero-adapter-fastly/src/lib.rs` (`run_app_with_request_extensions`; `run_app` delegates to it with a no-op). `run_app_with_config` is unaffected by the closure threading — it dispatches via `FastlyService::dispatch` (updated in Step 3), not `dispatch_with_registries`. + +**Interfaces:** +- Consumes: `into_core_request(req: FastlyRequest) -> Result` (`request.rs:445`, consumes `req` by value); `edgezero_core::http::Extensions`. +- Produces: + - `request.rs`: `dispatch_with_registries(app, req, config_meta, kv_meta, secret_meta, env, extend: F) where F: FnOnce(&FastlyRequest, &mut Extensions)` — an added final `extend` parameter; `dispatch_with_handles` likewise. + - `lib.rs`: `pub fn run_app_with_request_extensions(req: fastly::Request, extend: F) -> Result where A: Hooks, F: FnOnce(&fastly::Request, &mut Extensions)`. + +- [x] **Step 1: Write the failing host unit test (the scratch-bag mechanism)** + +Unit-test the closure/scratch-bag seam directly via a small extracted helper. (This runs under Viceroy from the crate dir, per Global Constraints; the *full* `run_app_with_request_extensions` → `into_core_request` → handler integration is best covered by a `contract.rs` test — see the note after the handler-visible test.) + +Add to the `#[cfg(test)] mod synthesis_tests` in `crates/edgezero-adapter-fastly/src/request.rs`. It builds a `FastlyRequest` and asserts the closure populated the scratch bag: + +```rust + #[test] + fn apply_request_extend_populates_scratch_from_raw_request() { + use edgezero_core::http::Extensions; + + #[derive(Clone, Debug, PartialEq)] + struct Ja4(String); + + let raw = FastlyRequest::new(fastly::http::Method::GET, "http://example.test/"); + let scratch = apply_request_extend(&raw, |req, extensions| { + // A real closure would call req.get_tls_ja4(); here we derive from a + // host-safe signal (the URL) to avoid a hostcall in the unit test. + let marker = req.get_url_str().to_owned(); + extensions.insert(Ja4(marker)); + }); + + assert_eq!( + scratch.get::(), + Some(&Ja4("http://example.test/".to_owned())) + ); + } +``` + +Also add a **handler-visible** host test — this proves the spec's requirement (§128) that a value stashed by the pre-dispatch hook is readable by a handler. It exercises the second half of the C3 chain host-side (the scratch bag `extend`ed into a core request → dispatched → handler reads it), which `dispatch_with_handles` can't be host-tested through because `into_core_request` calls the `get_client_ip_addr()` hostcall. Add alongside the test above: + +```rust + #[test] + fn extended_request_extensions_are_visible_to_handler() { + use edgezero_core::body::Body; + use edgezero_core::context::RequestContext; + use edgezero_core::error::EdgeError; + use edgezero_core::http::{request_builder, Extensions, Method, StatusCode}; + use edgezero_core::router::RouterService; + use futures::executor::block_on; + + #[derive(Clone)] + struct Ja4(String); + + async fn handler(ctx: RequestContext) -> Result { + let ja4 = ctx + .request() + .extensions() + .get::() + .map_or_else(|| "missing".to_owned(), |value| value.0.clone()); + Ok(ja4) + } + + // Mirror what `dispatch_with_handles` does: a scratch bag built from the + // raw request is `extend`ed into the core request before dispatch. + let mut scratch = Extensions::default(); + scratch.insert(Ja4("t13d1516h2".to_owned())); + + let mut request = request_builder() + .method(Method::GET) + .uri("/ja4") + .body(Body::empty()) + .expect("request"); + request.extensions_mut().extend(scratch); + + let service = RouterService::builder().get("/ja4", handler).build(); + let response = block_on(service.oneshot(request)).expect("response"); + assert_eq!(response.status(), StatusCode::OK); + assert_eq!(response.body().as_bytes().expect("buffered"), b"t13d1516h2"); + } +``` + +> **Complete-path coverage (wasm/Viceroy, optional in this plan):** the *full* raw-`fastly::Request` → `run_app_with_request_extensions` → `into_core_request` → handler path can only run under Viceroy (`into_core_request`'s `get_client_ip_addr()` hostcall). If a Viceroy toolchain is available, add a test to `crates/edgezero-adapter-fastly/tests/contract.rs` (already `#![cfg(all(feature = "fastly", target_arch = "wasm32"))]`) that dispatches a request through `run_app_with_request_extensions::(req, |raw, ext| ext.insert(Ja4(raw.get_url_str().into())))` and asserts a handler read the value. Mark it clearly as wasm/Viceroy-only; it is not run by the host `cargo test` gate. + +- [x] **Step 2: Run — verify they fail** + +Run: `cargo test -p edgezero-adapter-fastly --features fastly --lib apply_request_extend 2>&1 | tail -15` +Expected: FAIL — `apply_request_extend` does not exist. (The `extended_request_extensions_are_visible_to_handler` test also compiles against the same feature set; it exercises only public core APIs and will pass once the crate builds — its value is documenting/locking the handler-visible contract.) + +- [x] **Step 3: Add the `apply_request_extend` helper + thread the closure through dispatch** + +In `crates/edgezero-adapter-fastly/src/request.rs`, add the helper near `dispatch_with_handles` (import `Extensions`: add `Extensions` to the existing `use edgezero_core::http::{request_builder, Request};` line at `request.rs:11`): + +```rust +/// Run an app-provided closure against a scratch `Extensions` populated from the +/// RAW `fastly::Request` (JA4 / H2 / etc.), BEFORE `into_core_request` consumes +/// the request. Returns the scratch bag to be `extend`ed into the core request. +fn apply_request_extend(req: &FastlyRequest, extend: F) -> Extensions +where + F: FnOnce(&FastlyRequest, &mut Extensions), +{ + let mut scratch = Extensions::default(); + extend(req, &mut scratch); + scratch +} +``` + +Change `dispatch_with_handles` (`request.rs:279-286`) to take the closure and merge the scratch bag after conversion: + +```rust +fn dispatch_with_handles( + app: &App, + req: FastlyRequest, + stores: Stores, + extend: F, +) -> Result +where + F: FnOnce(&FastlyRequest, &mut Extensions), +{ + // Read raw-request signals into a scratch bag BEFORE conversion consumes `req`. + let scratch = apply_request_extend(&req, extend); + let mut core_request = into_core_request(req).map_err(|err| map_edge_error(&err))?; + core_request.extensions_mut().extend(scratch); + dispatch_core_request(app, core_request, stores) +} +``` + +(`dispatch_core_request` is unchanged; it already takes `mut core_request` and inserts the registries + runs the router.) + +**`dispatch_with_handles` has a SECOND caller** — `FastlyService::dispatch` (`request.rs:145`, the service-builder API used by the wasm/Viceroy contract tests). It calls `dispatch_with_handles` directly and will not compile after the signature change. Update that call site (`request.rs:145-153`) to pass a no-op closure: + +```rust + dispatch_with_handles( + self.app, + req, + Stores { + config_store, + kv, + secrets, + ..Default::default() + }, + |_req, _extensions| {}, + ) +``` + +(`FastlyService` is the no-hook service API; it does not expose a raw-request hook, so a no-op is correct. If a service-level hook is ever wanted, add it as a separate change.) + +Change `dispatch_with_registries` (`request.rs:288-316`) to take and forward the closure: + +```rust +pub(crate) fn dispatch_with_registries( + app: &App, + req: FastlyRequest, + config_meta: Option, + kv_meta: Option, + secret_meta: Option, + env: &EnvConfig, + extend: F, +) -> Result +where + F: FnOnce(&FastlyRequest, &mut Extensions), +{ + let kv_registry = build_kv_registry(kv_meta, env)?; + let config_registry = build_config_registry(config_meta, env); + let secret_registry = build_secret_registry(secret_meta, env); + dispatch_with_handles( + app, + req, + Stores { + config_registry, + kv_registry, + secret_registry, + ..Default::default() + }, + extend, + ) +} +``` + +- [x] **Step 4: Add `run_app_with_request_extensions` + make `run_app` delegate** + +In `crates/edgezero-adapter-fastly/src/lib.rs`, replace the `run_app` body's `dispatch_with_registries(...)` call (`lib.rs:122`) so `run_app` delegates to the new variant with a no-op closure, and add the new public fn. Import `Extensions`: add `use edgezero_core::http::Extensions;` near the other imports (guarded under the same `#[cfg(feature = "fastly")]` scope as `run_app`). + +```rust +#[cfg(feature = "fastly")] +#[inline] +pub fn run_app(req: fastly::Request) -> Result { + run_app_with_request_extensions::(req, |_req, _extensions| {}) +} + +/// Like [`run_app`], but runs `extend` against a scratch [`Extensions`] populated +/// from the raw `fastly::Request` (TLS JA4, H2 fingerprint, client IP, …) before +/// the request is converted; the scratch values are merged into the core +/// request's extensions and are visible to middleware and the `State`/extractor +/// layer. +#[cfg(feature = "fastly")] +#[inline] +pub fn run_app_with_request_extensions( + req: fastly::Request, + extend: F, +) -> Result +where + A: Hooks, + F: FnOnce(&fastly::Request, &mut Extensions), +{ + let stores = A::stores(); + let env = env_config_from_runtime_dictionary(stores); + let logging = logging_from_env(&env); + if logging.use_fastly_logger && !A::owns_logging() { + let endpoint = logging.endpoint.as_deref().unwrap_or("stdout"); + init_logger(endpoint, logging.level, logging.echo_stdout)?; + } + let app = A::build_app(); + request::dispatch_with_registries( + &app, + req, + stores.config, + stores.kv, + stores.secrets, + &env, + extend, + ) +} +``` + +(The logger gate here is the Task-3 `&& !A::owns_logging()`. Note: `run_app_with_config` (`lib.rs:200`) does **not** call `dispatch_with_registries` — it builds a `FastlyService` and calls `service.dispatch(req)` (`lib.rs:210-214`), which routes through `FastlyService::dispatch` → `dispatch_with_handles`, already updated with a no-op in Step 3. So `run_app_with_config` needs no change here beyond its own Task-3 logger gate. The **only** direct caller of `dispatch_with_registries` is `run_app` — now delegating through `run_app_with_request_extensions`.) + +- [x] **Step 5: Confirm every caller was updated, then run the host test + build** + +`dispatch_with_handles` and `dispatch_with_registries` are internal to the fastly crate but each has multiple callers. Grep to confirm none was missed before building: + +Run: `grep -rn "dispatch_with_handles\|dispatch_with_registries" crates/edgezero-adapter-fastly/src/` +Expected callers, all now passing an `extend` closure: `dispatch_with_handles` ← `FastlyService::dispatch` (`request.rs:145`, no-op) and `dispatch_with_registries` (`request.rs`); `dispatch_with_registries` ← only `run_app_with_request_extensions` (`lib.rs`; `run_app` delegates to it). `run_app_with_config` reaches dispatch via `FastlyService::dispatch`, so it needs no closure change. If grep shows any `dispatch_with_handles`/`dispatch_with_registries` call without a trailing closure arg, fix it. + +Run: `cargo test -p edgezero-adapter-fastly --features fastly --lib apply_request_extend 2>&1 | tail -8` +Expected: PASS. +Run: `cargo test -p edgezero-adapter-fastly --features fastly --lib 2>&1 | tail -8` +Expected: all host unit tests PASS (existing `synthesis_tests`, response, proxy, lib). +Run: `cargo check --workspace --all-targets --features "fastly cloudflare spin" 2>&1 | tail -5` +Expected: succeeds (the signature changes are internal to the fastly crate; the two `dispatch_with_handles` callers — `FastlyService::dispatch` and `dispatch_with_registries` — and the one `dispatch_with_registries` caller, `run_app_with_request_extensions`, are all updated. `run_app_with_config` compiles unchanged: it dispatches via `FastlyService::dispatch`). + +- [x] **Step 6: Lint + commit** + +Run: `cargo clippy -p edgezero-adapter-fastly --all-targets --features fastly -- -D warnings 2>&1 | tail -5` +Expected: clean. + +```bash +git add crates/edgezero-adapter-fastly/src/request.rs crates/edgezero-adapter-fastly/src/lib.rs +git commit -m "feat(fastly): run_app_with_request_extensions pre-dispatch hook for raw-request signals" +``` + +--- + +## Final verification (all P0-C tasks) + +- [x] **Run every CI gate:** + +```bash +cargo fmt --all -- --check +cargo clippy --workspace --all-targets --all-features -- -D warnings +cargo test --workspace --all-targets +# fastly adapter is wasm-only: run its tests via Viceroy FROM THE CRATE DIR +# (root `cargo test -p edgezero-adapter-fastly --features fastly` fails to link). +# Use TARGETED module filters — a blanket `--lib` run aborts (exit 134) on +# pre-existing store/hostcall tests that need specific Viceroy backend config: +(cd crates/edgezero-adapter-fastly && cargo test --features fastly --lib -- response:: proxy::) +(cd crates/edgezero-adapter-fastly && cargo test --features fastly --lib -- synthesis apply_request_extend extended_request_extensions) +(cd crates/edgezero-adapter-fastly && cargo test --features fastly --target wasm32-wasip1 --test contract) # if Viceroy present +cargo check --workspace --all-targets --features "fastly cloudflare spin" +cargo check -p edgezero-adapter-spin --target wasm32-wasip2 --features spin +(cd examples/app-demo && cargo test) +# app-demo is its OWN workspace (root `exclude`s it at Cargo.toml:12); the macro +# change regenerates its Hooks impl (owns_logging), so lint it separately: +(cd examples/app-demo && cargo clippy --workspace --all-targets --all-features -- -D warnings) +``` + +Expected: all green. Note: `crates/edgezero-adapter-fastly/tests/contract.rs` (the Viceroy/wasm end-to-end incl. the C3 closure-reaches-handler path and full header round-trips) is `#![cfg(all(feature = "fastly", target_arch = "wasm32"))]` and is **not** run by the above; if a Viceroy toolchain is available, run it separately with `--features fastly --target wasm32-wasip1`. + +## Acceptance criteria (spec §P0-C acceptance) + +1. Multi-value `Set-Cookie` round-trips through `from_core_response` (Task 1) and `convert_response` (Task 2). +2. `Hooks::owns_logging()` gates logger init in all four adapters (Task 3); `app!(owns_logging = true)` emits `owns_logging() -> bool { true }` (Task 4). +3. A pre-dispatch closure populates a scratch `Extensions` from the raw `fastly::Request` (Task 5), the scratch is merged into the core request, and the merged value is **handler-visible** (`extended_request_extensions_are_visible_to_handler`); `run_app` (no-hook) behavior is unchanged. The full raw-request→handler path is additionally covered by an optional wasm/Viceroy `contract.rs` test. +4. `cargo fmt`/clippy clean; workspace + app-demo tests green; WASM checks pass. + +## Self-review notes (spec coverage) + +- C1 §26-51 (response append; proxy request + response) → Tasks 1, 2. +- C2 §53-91 (`Hooks::owns_logging()` neutral across 4 adapters + `missing_trait_methods` emission + macro arg) → Tasks 3, 4. +- C3 §93-125 (scratch-`Extensions`-before-conversion, `edgezero_core::http::Extensions`, thread through dispatch) → Task 5. +- Test target/feature clarity (spec review): every test step names `--features fastly` for host coverage and flags `contract.rs` as wasm/Viceroy-only; C2 uses deterministic `AppArgs` grammar tests + the macro-emission check, not a process-global logger test. diff --git a/docs/superpowers/plans/2026-07-04-edgezero-p0d-app-macro-state.md b/docs/superpowers/plans/2026-07-04-edgezero-p0d-app-macro-state.md new file mode 100644 index 00000000..a12f8f12 --- /dev/null +++ b/docs/superpowers/plans/2026-07-04-edgezero-p0d-app-macro-state.md @@ -0,0 +1,332 @@ +# EdgeZero P0-D — `app!` App-State Injection Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Let a fully-macro app provide app-owned shared state (`Arc`) to `#[action]` handlers via `app!("edgezero.toml", state = )`, so `State` works without a hand-written `Hooks::routes()`. + +**Architecture:** The router already owns per-request state injection (PR #306: `RouterBuilder::with_state` + a `state_extensions: Extensions` bag that dispatch `extend`s into every request). So P0-D is macro-only: the `app!`-generated `build_router()` calls `.with_state()` — one builder call next to its existing `.with_manifest_json(...)`. No adapter change, no new `Hooks` method, no state carrier. + +**Tech Stack:** Rust 1.95, edition 2021. `edgezero-macros` (`app!`), `examples/app-demo` (worked example). Reuses `edgezero-core` `RouterBuilder::with_state` / `State` unchanged. + +**Source spec:** `docs/superpowers/specs/2026-07-03-edgezero-p0cd-fastly-dispatch-and-appstate-design.md` (P0-D), verified against `65afbd3`. + +## Global Constraints + +- **Rust 1.95.0**, edition 2021. Strict clippy gate (`restriction = deny`): watch `arbitrary_source_item_ordering` (order struct fields / test fns), `min_ident_chars`, `absolute_paths`, `impl_trait_in_params`, `assertions_on_result_states`, `needless_raw_strings`, `missing_trait_methods`. +- **DEPENDS ON P0-C.** This plan **extends the `app!` `AppArgs` keyword-argument grammar** introduced by the P0-C plan (`2026-07-04-edgezero-p0c-fastly-dispatch-fidelity.md`, Task 4). Execute P0-C first. After P0-C, `AppArgs` is `struct AppArgs { app_ident: Option, owns_logging: Option, path: LitStr }` with a keyword-arg parser whose `match key.to_string()` has an `owns_logging` arm and an `_ => unknown key` arm; Task 1 below adds a `state` field + arm. If P0-C has not landed, do it first — do not reintroduce the grammar here. +- **Reused, unchanged API** (from PR #306, `crates/edgezero-core/src/router.rs`): `RouterBuilder::with_state(self, value: T) -> Self where T: Clone + Send + Sync + 'static`; the router injects `state_extensions` into every request at dispatch. `State` (`crates/edgezero-core/src/extractor.rs`) extracts it; an unregistered `T` → `500`. +- **CI gates (all must pass):** `cargo fmt --all -- --check`; `cargo clippy --workspace --all-targets --all-features -- -D warnings`; `cargo test --workspace --all-targets`; `cargo check --workspace --all-targets --features "fastly cloudflare spin"`; `cargo check -p edgezero-adapter-spin --target wasm32-wasip2 --features spin`; and `(cd examples/app-demo && cargo test)`. +- **No backward-compat constraint.** `state` is optional; omitting it leaves `build_router()` byte-identical to today. + +--- + +## File Structure + +| File | Responsibility | Task | +| ---- | -------------- | ---- | +| `crates/edgezero-macros/src/app.rs` | `AppArgs` gains `state: Option`; parser adds the `state` arm; `build_router()` emits `.with_state(#state)`; `AppArgs` unit tests | 1 | +| `examples/app-demo/crates/app-demo-core/src/lib.rs` | crate-root `DemoState` + `app_state()`; `app!(…, state = crate::app_state())` | 2 | +| `examples/app-demo/crates/app-demo-core/src/handlers.rs` | a `State>` `#[action]` handler + host test through `build_router()` | 2 | +| `examples/app-demo/edgezero.toml` | route for the state-demo handler | 2 | + +--- + +## Task 1: `app!(state = )` — parse + emit `.with_state(...)` + +**Files:** +- Modify: `crates/edgezero-macros/src/app.rs` (`AppArgs` struct + parser `state` arm; `expand_app` emits `.with_state(#state_expr)` in `build_router()`, `app.rs:185-191`; add `AppArgs` state unit tests) + +**Interfaces:** +- Consumes (from P0-C): the `AppArgs` keyword-arg parser with its `match key.to_string()` dispatch. +- Produces: `AppArgs.state: Option`; `build_router()` emits `builder = builder.with_state(#state_expr);` when `state` is present. + +- [x] **Step 1: Write the failing `AppArgs` state unit tests** + +Add to the `#[cfg(test)] mod tests` in `crates/edgezero-macros/src/app.rs` (which after P0-C already has `use super::AppArgs;`, `use syn::parse_str;`, and the `app_args_*` tests). Place alphabetically among the `app_args_*` fns. To assert the parsed expression, render it with `quote`: + +```rust + #[test] + fn app_args_parses_state_expr() { + let args: AppArgs = parse_str(r#""edgezero.toml", state = crate::app_state()"#).expect("parse"); + let rendered = args.state.map(|expr| quote::quote!(#expr).to_string()); + assert_eq!(rendered, Some("crate :: app_state ()".to_owned())); + assert!(args.app_ident.is_none()); + assert_eq!(args.owns_logging, None); + } + + #[test] + fn app_args_parses_state_with_app_ident_and_owns_logging() { + let args: AppArgs = parse_str( + r#""edgezero.toml", MyApp, state = crate::app_state(), owns_logging = true"#, + ) + .expect("parse"); + assert_eq!(args.app_ident.map(|ident| ident.to_string()), Some("MyApp".to_owned())); + assert_eq!(args.owns_logging, Some(true)); + assert!(args.state.is_some()); + } + + #[test] + fn app_args_rejects_duplicate_state() { + let err = parse_str::(r#""edgezero.toml", state = a(), state = b()"#) + .expect_err("duplicate state"); + assert!(err.to_string().contains("duplicate `state`"), "got: {err}"); + } +``` + +- [x] **Step 2: Run — verify they fail** + +Run: `cargo test -p edgezero-macros --lib app_args_parses_state 2>&1 | tail -20` +Expected: FAIL — `AppArgs` has no `state` field, and `state = …` hits the unknown-key arm ("unknown `app!` argument `state`"). + +- [x] **Step 3: Add the `state` field + parser arm** + +In `crates/edgezero-macros/src/app.rs`, add `state` to the `AppArgs` struct (alphabetical field order: `app_ident`, `owns_logging`, `path`, `state`): + +```rust +struct AppArgs { + app_ident: Option, + owns_logging: Option, + path: LitStr, + state: Option, +} +``` + +In `impl Parse`, add a `state` local (`let mut state: Option = None;` next to the `owns_logging` local), add the `state` arm to the `match key.to_string().as_str()`, and include `state` in the returned `Self`: + +```rust + "state" => { + if state.is_some() { + return Err(syn::Error::new(key.span(), "duplicate `state` argument")); + } + state = Some(input.parse::()?); + } + "owns_logging" => { + if owns_logging.is_some() { + return Err(syn::Error::new(key.span(), "duplicate `owns_logging` argument")); + } + let value: syn::LitBool = input.parse()?; + owns_logging = Some(value.value); + } + other => { + return Err(syn::Error::new( + key.span(), + format!("unknown `app!` argument `{other}`; expected `state` or `owns_logging`"), + )); + } +``` + +and the constructor: + +```rust + Ok(Self { app_ident, owns_logging, path, state }) +``` + +- [x] **Step 4: Emit `.with_state(...)` in `build_router()`** + +In `expand_app`, before the `quote!` block, turn the optional state expression into optional emitted tokens (place near the other `let …` bindings). Use `Option<&Expr>` mapped to a `TokenStream2` so the call is emitted only when present: + +```rust + let state_call = args.state.as_ref().map(|state_expr| { + quote! { builder = builder.with_state(#state_expr); } + }); +``` + +Then insert `#state_call` into the emitted `build_router()` (`app.rs:185-191`), right after the `with_manifest_json` line: + +```rust + pub fn build_router() -> edgezero_core::router::RouterService { + let mut builder = edgezero_core::router::RouterService::builder(); + builder = builder.with_manifest_json(#manifest_json_lit); + #state_call + #(#middleware_tokens)* + #(#route_tokens)* + builder.build() + } +``` + +(`Option` implements `ToTokens` — `None` emits nothing, so omitting `state` leaves `build_router()` unchanged.) + +- [x] **Step 5: Run the unit tests — verify they pass** + +Run: `cargo test -p edgezero-macros --lib app_args_ 2>&1 | tail -12` +Expected: PASS — the new `app_args_parses_state*` / `app_args_rejects_duplicate_state` plus the P0-C `app_args_*` tests. + +- [x] **Step 6: Confirm app-demo (no `state`) is unchanged + lint** + +Run: `(cd examples/app-demo && cargo test -p app-demo-core 2>&1 | tail -5)` +Expected: PASS (app-demo's `app!` has no `state` yet, so `build_router()` is byte-identical). +Run: `cargo clippy -p edgezero-macros --all-targets --all-features -- -D warnings 2>&1 | tail -5` +Expected: clean. + +- [x] **Step 7: Commit** + +```bash +git add crates/edgezero-macros/src/app.rs +git commit -m "feat(macros): app!(state = ) emits RouterBuilder::with_state for macro apps" +``` + +--- + +## Task 2: app-demo worked example + end-to-end `State` test + +Prove the whole chain: `app!(state = crate::app_state())` → generated `build_router()` calls `.with_state(...)` → dispatch injects → a `State>` handler reads it. This runs **host-side** through `build_router()` (no adapter/hostcalls), mirroring the existing `crate::build_router()` test at `handlers.rs:436`. + +**Files:** +- Modify: `examples/app-demo/crates/app-demo-core/src/lib.rs` (define `DemoState` + `app_state()` at the **crate root**; add `state = crate::app_state()` to `app!`) +- Modify: `examples/app-demo/crates/app-demo-core/src/handlers.rs` (add the `State` handler + host test) +- Modify: `examples/app-demo/edgezero.toml` (route for the handler) + +**Interfaces:** +- Consumes: `edgezero_core::extractor::State`, `edgezero_core::action`, `RouterService::oneshot` (from #306), `crate::build_router()` (macro-generated). +- Produces: crate-root `crate::{DemoState, app_state}`; `crate::handlers::state_demo` handler. + +> **Design note (why crate root, not a `state` module):** app-demo is its own workspace with a stricter lint set — `pub_use` and `module_name_repetitions` are **denied** (unlike the root workspace which allows them). A `pub mod state;` + `pub use crate::state::app_state;` trips `pub_use`, and `DemoState`/`app_state` inside a module named `state` trip `module_name_repetitions`. Defining both at the crate root (in `lib.rs`) avoids all three with no `#[allow]`, and `crate::app_state()` / `crate::DemoState` resolve directly where the macro emits them. + +- [x] **Step 1: Define `DemoState` + `app_state()` at the crate root and wire `app!`** + +In `examples/app-demo/crates/app-demo-core/src/lib.rs`, add the state types at the crate root (after the `pub mod` declarations) and add the `state` argument to the `app!` invocation (`lib.rs:11`): + +```rust +pub mod handlers; + +use std::sync::Arc; + +/// App-owned shared state for the `app!(..., state = ...)` demonstration, +/// handed to handlers via `State>`. +#[derive(Debug)] +pub struct DemoState { + /// A greeting the handler echoes, proving the value reached the handler. + pub greeting: String, +} + +/// Constructs the shared app state. Referenced by `app!(..., state = crate::app_state())`. +#[must_use] +#[inline] +pub fn app_state() -> Arc { + Arc::new(DemoState { + greeting: "hello from app state".to_owned(), + }) +} + +edgezero_core::app!("../../edgezero.toml", state = crate::app_state()); +``` + +(`#[inline]` is required by `missing_inline_in_public_items`; `#[derive(Debug)]` keeps the public struct debuggable.) + +- [x] **Step 2: Add the `State` handler** + +In `examples/app-demo/crates/app-demo-core/src/handlers.rs`, add `State` to the existing `edgezero_core::extractor::{…}` import and `use std::sync::Arc;`, then add the handler (`crate::DemoState` is a 2-segment path — fine under `absolute_paths`). The return type is `Result, EdgeError>` to match the file's other text handlers (e.g. `secrets_echo`); `#[action]` wraps it via `Responder`: + +```rust +#[action] +pub async fn state_demo( + State(state): State>, +) -> Result, EdgeError> { + Ok(Text::new(state.greeting.clone())) +} +``` + +(If `handlers.rs` already imports `Arc` / an `action` alias, reuse those rather than re-importing — keep the file's existing style.) + +- [x] **Step 3: Register the route in the manifest** + +In `examples/app-demo/edgezero.toml`, add an HTTP trigger for the handler, matching the existing `[[triggers.http]]` entries' exact keys (`id`, `path`, `methods`, `handler`, `adapters`): + +```toml +[[triggers.http]] +id = "state-demo" +path = "/state-demo" +methods = ["GET"] +handler = "app_demo_core::handlers::state_demo" +adapters = ["axum", "cloudflare", "fastly", "spin"] +description = "Reads app-owned state via State> (app!(state = ...))" +``` + +- [x] **Step 4: Write the end-to-end host test** + +Add to the `#[cfg(test)] mod tests` in `examples/app-demo/crates/app-demo-core/src/handlers.rs` (the module that already contains the `crate::build_router()` test at `handlers.rs:436`; reuse its imports for `request_builder`/`Body`/`block_on` — mirror that test). Place the fn alphabetically. + +```rust + #[test] + fn state_demo_handler_reads_app_state_through_macro_router() { + use edgezero_core::body::Body; + use edgezero_core::http::{request_builder, Method, StatusCode}; + use futures::executor::block_on; + + // build_router() is macro-generated and now calls `.with_state(crate::app_state())`. + let service = crate::build_router(); + + let request = request_builder() + .method(Method::GET) + .uri("/state-demo") + .body(Body::empty()) + .expect("request"); + + let response = block_on(service.oneshot(request)).expect("response"); + assert_eq!(response.status(), StatusCode::OK); + assert_eq!( + response.body().as_bytes().expect("buffered"), + b"hello from app state" + ); + } +``` + +- [x] **Step 5: Run the e2e test** + +Run: `(cd examples/app-demo && cargo test -p app-demo-core state_demo_handler_reads_app_state 2>&1 | tail -12)` +Expected: PASS — the macro-generated router injected `Arc`, the `State` extractor resolved it, and the handler echoed `greeting`. (First run may FAIL if the route/handler/state wiring is incomplete; fix until green.) + +- [x] **Step 6: Full app-demo + workspace verification** + +Run: `(cd examples/app-demo && cargo test 2>&1 | tail -8)` +Expected: PASS (all four adapter example crates still build; app-demo-core tests green). +Run: `cargo test -p edgezero-macros 2>&1 | tail -5` +Expected: macros tests green. +Run root clippy AND app-demo clippy separately — `examples/app-demo` is its **own workspace**, `exclude`d from the root workspace (`Cargo.toml:12`), so root `--workspace` does NOT lint it: +``` +cargo clippy --workspace --all-targets --all-features -- -D warnings +(cd examples/app-demo && cargo clippy --workspace --all-targets --all-features -- -D warnings) +``` +Expected: both clean (the new handler's fields are read by the assertion, so no `dead_code` suppression is needed). + +- [x] **Step 7: Commit** + +```bash +git add examples/app-demo/crates/app-demo-core/src/lib.rs \ + examples/app-demo/crates/app-demo-core/src/handlers.rs \ + examples/app-demo/edgezero.toml +git commit -m "docs(app-demo): app!(state = ...) + State handler example with end-to-end test" +``` + +--- + +## Final verification (all P0-D tasks) + +- [x] **Run every CI gate:** + +```bash +cargo fmt --all -- --check +cargo clippy --workspace --all-targets --all-features -- -D warnings +cargo test --workspace --all-targets +cargo check --workspace --all-targets --features "fastly cloudflare spin" +cargo check -p edgezero-adapter-spin --target wasm32-wasip2 --features spin +(cd examples/app-demo && cargo test) +# app-demo is its OWN workspace (root `exclude`s it), so lint it separately: +(cd examples/app-demo && cargo clippy --workspace --all-targets --all-features -- -D warnings) +``` + +Expected: all green. + +## Acceptance criteria (spec §P0-D acceptance) + +1. A macro app declaring `app!("...", state = )` can extract `State` (where `T` is the expression's value type) in an `#[action]` handler — proven host-side via app-demo's macro-generated `build_router()` (Task 2). Because the router's dispatch injection is platform-neutral, this holds on all four adapters with no adapter change. +2. An app that provides no `state` is unaffected — `build_router()` is byte-identical without the argument (Task 1 Step 6); `State` for an unregistered `T` still returns the existing 500. +3. `app-demo` gains a small `app!(..., state = ...)` + `State` handler example (Task 2). +4. **No `edgezero-core` `Hooks`/adapter change** — the diff is confined to `edgezero-macros` + the `app-demo` example (the acceptance signal that the simpler, router-reusing design was taken). + +## Self-review notes (spec coverage + type consistency) + +- §P0-D "The gap" / "Design (revised)" → Task 1 (macro-only `.with_state` emission, reusing #306's `state_extensions`). +- §`app!` argument grammar (`state = ` as `syn::Expr`, emitted verbatim; duplicate/unknown-key errors; coexistence with app ident + `owns_logging`) → Task 1, extending P0-C's parser. Field/arm names match P0-C's `AppArgs` (`app_ident`, `owns_logging`, `path`, `state`). +- §P0-D acceptance (macro app extracts `State`; no-state unaffected; app-demo example; no adapter/Hooks change) → Task 2 + the "no adapter change" acceptance line. +- `state = ` is an expression emitted verbatim (`state = crate::app_state()`, not a bare fn path) — consistent with Task 1's parser (`syn::Expr`) and the emitted `.with_state(#state_expr)`. 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..7c5ac820 --- /dev/null +++ b/docs/superpowers/specs/2026-07-02-edgezero-state-and-nested-secrets-design.md @@ -0,0 +1,442 @@ +# 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 + +> **Note:** an earlier draft used a `Vec` of type-erased closures +> (`StateInserter = Arc`). The shipped design below is +> simpler — a single `Extensions` bag — with the same `Clone + Send + Sync + +> 'static` bound and last-write-wins-by-`TypeId` semantics, and no closure/vtable +> machinery. + +Store the registered state in an `Extensions` bag on `RouterBuilder` and thread it into `RouterInner`: + +```rust +#[derive(Default)] +pub struct RouterBuilder { + // ... existing fields ... + state_extensions: crate::http::Extensions, +} + +pub fn with_state(mut self, value: T) -> Self +where T: Clone + Send + Sync + 'static { + self.state_extensions.insert(value); + self +} +``` + +In `RouterInner::dispatch`, extend the owned request's extensions with the state bag **before** building the context: + +```rust +RouteMatch::Found(entry, params) => { + let mut request = request; + // ... introspection inserts (manifest/routes) run first ... + request.extensions_mut().extend(self.state_extensions.clone()); + let ctx = RequestContext::new(request, params); + let next = Next::new(&self.middlewares, entry.handler.as_ref()); + next.run(ctx).await +} +``` + +Notes: +- `RouterInner` gains a `state_extensions: Extensions` field; `RouterService::new` takes it from the builder. +- Insertion happens **after** the introspection inserts into the same extensions map (different `TypeId`s, no collision). `Extensions::extend` is last-write-wins by `TypeId`, and the router runs last — document this; a `T` collision with an adapter/introspection value is not expected in practice. +- Cost is one `Extensions::clone` (which clones each registered `T`) per request — negligible for `Arc` (a refcount bump per entry). +- The route-listing internal handler and middleware are unaffected. + +### 3.3 Naming decision (needs maintainer sign-off) + +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 calls the sub-struct's own `secret_fields()` and prefixes the path — so recursion composes without the parent macro needing the child's fields: + +```rust +// emitted metadata for `integrations: IntegrationSettings` (#[app_config(nested)]): +// for each SecretField the child returns, prepend Field("integrations") to its path. +// for mut f in ::secret_fields() { +// f.path.insert(0, SecretPathSegment::Field(Cow::Borrowed("integrations"))); +// out.push(f); +// } +// (a Vec field prepends Field("field"), then ArrayEach.) +``` + +> **B-3 — resolved (§8).** Cross-crate `const` concatenation of `&'static [SecretPathSegment]` with a parent-prepended prefix is not expressible in stable `const` (the macro cannot see the child's segments). So `AppConfigMeta` becomes an associated **`fn secret_fields() -> Vec`** (owned segments), and recursion builds owned vectors at runtime — allocation cost is negligible vs. the per-request network fetch. Every in-tree `impl AppConfigMeta` / `SECRET_FIELDS` site flips to the `fn`, including the ~10 hand-rolled test impls. See the implementation plan for the concrete emission. + +Also relax `enforce_scalar_string_type` to additionally accept `Option` on `#[secret]` fields (optional secrets are common in real config); an absent/`None` optional secret is skipped by the walk rather than erroring. Keep rejecting non-string scalar types. + +### 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_extensions` (an `Extensions` bag), dispatch `extend` (before `RequestContext::new`, alongside the introspection injects from PR #300). +- `crates/edgezero-core/src/extractor.rs` — `State` extractor (+ `Deref`/`DerefMut`/`into_inner`). Making `State` `pub` here is sufficient; **no `lib.rs` crate-root re-export** (§8 [A, minor] — no extractor is re-exported at the crate root today; consumers use `edgezero_core::extractor::State`). +- `docs/guide/handlers.md`. + +**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/docs/superpowers/specs/2026-07-03-edgezero-p0cd-fastly-dispatch-and-appstate-design.md b/docs/superpowers/specs/2026-07-03-edgezero-p0cd-fastly-dispatch-and-appstate-design.md new file mode 100644 index 00000000..00fbabdb --- /dev/null +++ b/docs/superpowers/specs/2026-07-03-edgezero-p0cd-fastly-dispatch-and-appstate-design.md @@ -0,0 +1,221 @@ +# EdgeZero P0-C + P0-D — Fastly `run_app` dispatch fidelity + app-state injection + +- **Status:** Draft for edgezero maintainer +- **Date:** 2026-07-03 +- **Target repo:** `github.com/stackpop/edgezero` (`edgezero-adapter-fastly`, `edgezero-core`, `edgezero-macros`) +- **Consumed by:** trusted-server "full convergence" migration — the decision that every adapter binary becomes the one-line `run_app::` with `#[action]` handlers. These two capabilities are the remaining gaps that block Fastly (P0-C) and macro-based app state (P0-D). Independent of the earlier Phase 0 spec (State + nested `#[secret]`, PR #306). +- **Verified against:** originally `6ebc29a5`; **re-verified and revised against `47a112c`** (branch `worktree-state-nested-secrets-spec-review`, PR #306 tip). The revision matters: between those commits the router's app-state layer was simplified from a `Vec` of type-erased closures to a single `state_extensions: Extensions` bag, which materially simplifies P0-D (see §P0-D). + +> **Maintainer-review revisions (2026-07-03..04):** folded in after verifying every claim against `47a112c`. +> - **Round 1 — P0-D re-specced** to reuse the router's existing `Extensions`-bag state injection (macro-only; no new `Hooks` method, no `AppState` carrier, no per-adapter edits — the original `StateInserter` design no longer exists). **C3 wiring corrected** — the raw-request closure runs *before* conversion against a scratch `Extensions` (conversion consumes the `fastly::Request`). **C2** gained an `app!` opt-in for fully-macro apps. +> - **Round 2 — C3 `Extensions` path** fixed to `edgezero_core::http::Extensions` (the snippet lives in the fastly adapter, not core). **C2 scope** made a decision point: `owns_logging()` is a platform-neutral `Hooks` method, so **all four** adapter entrypoints must gate their logger init on it (or C2 is scoped Fastly-only) — the files list previously only touched Fastly. **C1** extended to the proxy **response** path (`convert_response` collapses multi-value origin `Set-Cookie`). **`app!` argument grammar** fully specified (coexistence of the custom app ident with `state =` / `owns_logging =`, order, duplicate/unknown-key errors) — previously undefined. +> - **Round 3 — `owns_logging` macro emission** corrected: the macro must **always emit** `fn owns_logging() -> bool { #bool }` (not rely on the trait default), because `clippy::missing_trait_methods` (`restriction = deny`) forbids inheriting defaulted trait methods; the same lint means adding the trait method touches **every** `impl Hooks` in the workspace (noted in the C2 scope decision). Stale `state = f` acceptance wording corrected to `state = `. + +--- + +## Why + +trusted-server is converging on the canonical `app-demo` wiring: `run_app::` on every adapter, `#[action]` handlers, `State>`. Two things stop that today: + +1. **Fastly `run_app` loses fidelity** that trusted-server's hand-written custom dispatch preserves: multi-value `Set-Cookie` headers, an opt-out from the per-call logger reinit, and a pre-dispatch hook to capture Fastly-only request signals (TLS JA4 / H2 fingerprint, client IP) from the raw `fastly::Request` before it is converted to the neutral core request. → **P0-C.** +2. **Macro/`run_app` apps can't inject app-owned state.** `State` + `RouterBuilder::with_state` exist (PR #306) and the router injects registered state at dispatch — but the `app!` macro generates the router and never calls `with_state`, and `run_app` doesn't inject app state. So `State>` can't reach handlers in a macro app. → **P0-D.** + +**P0-D is optional** (see §4): if a downstream keeps a hand-written `Hooks::routes()` that calls `RouterBuilder::with_state`, the existing dispatch-time injection already delivers `State` under `run_app` — no edgezero change needed. P0-D is required only to support app-owned state **through the `app!` macro**. It is specified here so the maintainer can choose to support the fully-macro path. + +--- + +## P0-C — Fastly `run_app` dispatch fidelity + +Three independent sub-changes in `edgezero-adapter-fastly`. Each is small and separately testable. + +### C1 — Preserve multi-value response headers (`Set-Cookie`) + +**Current (bug):** `crates/edgezero-adapter-fastly/src/response.rs` builds the `fastly::Response` by looping over the core response's `HeaderMap` and calling `set_header`, which **replaces** — so N `Set-Cookie` values collapse to the last one: + +```rust +// response.rs (~line 28) +for (name, value) in &parts.headers { + fastly_response.set_header(name.as_str(), value.as_bytes()); +} +``` + +`http::HeaderMap`'s iterator yields **one entry per value** (duplicates included), and the `fastly::Response` starts empty (`FastlyResponse::from_status(...)`). So the fix is to **append** instead of set: + +```rust +for (name, value) in &parts.headers { + fastly_response.append_header(name.as_str(), value.as_bytes()); +} +``` + +`append_header` adds without clobbering, so all `Set-Cookie` (and any other multi-value header) survive. This is unconditionally correct given a fresh response; no per-header special-casing needed. + +**Two more proxy header surfaces have the same class of defect:** + +1. **Proxy *request* construction** — `proxy.rs:53` uses `set_header` when building the upstream `fastly::Request`. Request-side multi-value headers are rare (`Cookie` folds to one), so this is audit-only: either switch to `append_header` for consistency or document why `set_header` is acceptable here. +2. **Proxy *response* conversion (must fix, or explicitly exclude)** — `convert_response` in `proxy.rs` collapses **origin** multi-value response headers. It iterates `fastly_response.get_header_names()` and does `proxy_response.headers_mut().insert(header, fastly_response.get_header(header)...)` — `get_header` returns only the first value and `HeaderMap::insert` replaces, so multiple upstream `Set-Cookie` collapse to one. Origin `Set-Cookie` is common (auth/session), so this matters more than the request side. Fix by reading **all** values and appending: for each `name` in `get_header_names()`, iterate `fastly_response.get_header_all(name)` and `proxy_response.headers_mut().append(name, value.clone())`. **If P0-C intends to preserve multi-value headers, this is in scope**; otherwise state explicitly that proxy-response fidelity is out of P0-C. + +**Tests:** (a) a handler returns a `Response` with two `Set-Cookie` values → the converted `fastly::Response` (`get_header_all("set-cookie")`) contains both; (b) an upstream `fastly::Response` with two `Set-Cookie` → `convert_response` yields a `ProxyResponse` whose `HeaderMap` retains both. + +### C2 — Let the app opt out of the `run_app` logger init + +**Current:** `run_app` (`lib.rs:113`) initializes the Fastly logger unconditionally when `use_fastly_logger`: + +```rust +let logging = logging_from_env(&env); +if logging.use_fastly_logger { + init_logger(endpoint, logging.level, logging.echo_stdout)?; +} +``` + +An app that already owns `log`/`log-fastly` initialization (trusted-server does) cannot use `run_app` without a double-init conflict. Provide an opt-out. **Preferred:** a `Hooks` flag consulted by every adapter's `run_app`, so it is platform-neutral: + +```rust +// edgezero-core/src/app.rs — Hooks +/// When `true`, the adapter's `run_app` skips its own logger +/// initialization; the app is responsible for installing a `log` backend. +/// Default `false` (adapter initializes logging as today). +fn owns_logging() -> bool { false } +``` + +**Scope decision (this is a platform-neutral `Hooks` method, so ALL adapters must honor it — otherwise the flag is a lie on 3 of 4 platforms).** Every adapter entrypoint that initializes a logger must gate it on `!A::owns_logging()`: +- **Fastly** — `lib.rs:117` `init_logger(...)` (the primary target). +- **Cloudflare** — `lib.rs:105` `drop(init_logger())`. +- **Axum** — `dev_server.rs:343` `SimpleLogger::new()...init()`. +- **Spin** — `lib.rs:115` `drop(init_logger())` (already a no-op, but gate it for uniformity and to keep the contract honest). + +**Hidden cost of the `Hooks` method (feeds this decision):** `clippy::missing_trait_methods` (`restriction = deny`) forbids any `impl Hooks` from inheriting a defaulted method. So adding `owns_logging` to the trait — even with a default — forces **every existing `impl Hooks` in the workspace to add an explicit `fn owns_logging()`**: the `app!` macro's emitted impl (add it alongside `configure`/`build_app`), `app-demo`'s app, and every hand-written test/fixture `Hooks` impl. This is mechanical but wider than "core + 4 adapters." The Fastly-only alternative (a `run_app_without_logger::` variant, no trait method) avoids this ripple entirely. + +If cross-adapter wiring + the trait-impl ripple are undesirable for the first landing, the alternative is to **scope C2 explicitly to Fastly** and NOT add a core `Hooks` method — e.g. a Fastly-only `run_app_without_logger::` variant. Do not ship a platform-neutral `Hooks::owns_logging()` that only Fastly consults. **Recommendation:** the neutral `Hooks` method, wired through all four entrypoints and all `Hooks` impls (mechanical), since trusted-server converges on all adapters — but the plan must enumerate every `impl Hooks` site it touches. + +`run_app` becomes `if logging.use_fastly_logger && !A::owns_logging() { init_logger(...)?; }`. (Alternative: a `run_app_without_logger::` variant — but the `Hooks` flag composes with the `app!` macro and applies uniformly across adapters, so prefer it.) + +**Macro opt-in (required for the fully-macro path).** The `Hooks` default `owns_logging() -> false` is only overridable by a **hand-written** `Hooks` impl. A fully-macro app (the stated trusted-server target, which *does* own its `log`/`log-fastly` init) has no way to set it — the `app!` macro generates the `Hooks` impl and would emit the default. So C2 must also give `app!` an `owns_logging = true` argument (parallel to P0-D's `state = …`), e.g. `app!("edgezero.toml", owns_logging = true)`, which emits `fn owns_logging() -> bool { true }` in the generated impl. Without it, C2 only helps hand-written `Hooks` impls, not the macro path C2 exists to unblock. + +**Test:** an app with `owns_logging() == true` runs `run_app` twice / after the app initialized its own logger without the init error. Add a macro-path test: `app!("…", owns_logging = true)` emits `owns_logging() == true`. + +### C3 — Pre-dispatch hook for raw-request signals (JA4 / H2 / client IP) + +**Current:** `run_app` → `dispatch_with_registries` → `dispatch_with_handles` (`request.rs:279`) calls `into_core_request(req)` (`request.rs:284`), which **consumes the `fastly::Request` by value**, then inserts the store registries into the core request's extensions (`request.rs:266-272`) and runs `app.router().oneshot(core_request)` (`request.rs:274`). There is **no hook** to read the *original* `fastly::Request` (whose `get_tls_ja4()`, `get_client_h2_fingerprint()`, client-IP getter are only available pre-conversion) and stash derived values into the core request's extensions. trusted-server's custom path does this before dispatch. (Note: `context.rs:19` already inserts a `FastlyContext` into the core request; C3 supplements that with app-specific signals — the plan should state whether client-IP is already captured there to avoid duplication.) + +**Ordering constraint (this corrects the original sketch).** The raw signals must be **read before** `into_core_request` consumes `req`, but they must be **written into** the core request's `Extensions`, which only exist **after** conversion. So the closure cannot receive `core_req.extensions_mut()` "after conversion" — by then `req` is gone. Resolve by running the closure against a **scratch `Extensions`** before conversion, then merging it in after: + +```rust +// edgezero-adapter-fastly/src/lib.rs +use edgezero_core::http::Extensions; // NOT `crate::http` — that facade is edgezero-core's; + // the fastly adapter reaches it via `edgezero_core::http`. + +pub fn run_app_with_request_extensions( + req: fastly::Request, + extend: F, +) -> Result +where + A: Hooks, + F: FnOnce(&fastly::Request, &mut Extensions), +{ /* same as run_app, but thread `extend` into dispatch; there: + let mut scratch = Extensions::default(); // Extensions: Default + extend(&req, &mut scratch); // BEFORE into_core_request(req) + let mut core_req = into_core_request(req)?; + core_req.extensions_mut().extend(scratch); // reuse the Extensions::extend pattern + // ... registry inserts, then router.oneshot ... */ } +``` + +(The neutral core request produced by `into_core_request` is `edgezero_core::http::Request`, so `edgezero_core::http::Extensions` is the matching type — the closure's bag and the core request's extensions map are the same `http::Extensions`.) + +The closure runs once per request, reads the raw `fastly::Request`, and populates a scratch bag that is `extend`ed into the core request (the same `Extensions::extend` mechanism the router uses for app state). `run_app` stays as the no-hook convenience wrapper (`run_app_with_request_extensions::(req, |_, _| {})`). + +This requires threading the closure from `run_app_with_request_extensions` → `dispatch_with_registries` → `dispatch_with_handles` (add a generic `extend: F` parameter, or `Option<&mut dyn FnMut(&FastlyRequest, &mut Extensions)>`), with the scratch-then-extend step landing in `dispatch_with_handles` around `into_core_request`. Keep the existing `dispatch_with_registries` entry working (the no-op closure). + +**Test:** a handler reads a value from extensions that only the pre-dispatch closure could have set (e.g. a synthetic `Ja4` newtype); assert it is present. + +### P0-C acceptance + +- Multi-value `Set-Cookie` round-trips through `run_app` (C1) — both the handler-response path (`response.rs`) and, unless explicitly excluded, the proxy-response path (`proxy.rs::convert_response`). +- An app with `owns_logging() == true` runs under `run_app` without a logger-init error (C2) — verified on Fastly and, since `owns_logging` is a platform-neutral `Hooks` method, wired through the Cloudflare / Axum / Spin entrypoints too (or C2 is explicitly scoped Fastly-only — see the C2 scope decision). +- A pre-dispatch closure can populate core-request extensions from the raw `fastly::Request` (C3). +- `app-demo` still builds/serves; existing Fastly tests green; `run_app` (no-hook) behavior unchanged for apps that don't opt in. + +--- + +## P0-D — App-state injection for macro / `run_app` apps + +### The gap + +`State` (`extractor.rs:550`) reads from request extensions; `RouterBuilder::with_state` (`router.rs`) registers a value in the router's `state_extensions: Extensions` bag, which dispatch clones into each request via `request.extensions_mut().extend(self.state_extensions.clone())`. That works when the app **hand-builds** its router. But the `app!` macro's generated `build_router()` (`edgezero-macros/src/app.rs:185`) only calls `.with_manifest_json(...)` — never `.with_state(...)` — so a macro app has no way to provide `State>`. + +### Design — bake app state into the router via the macro (revised) + +> **Revised after the `Extensions`-bag reshape.** The original design added a `Hooks::app_state()` method returning a type-erased `AppState` carrier and applied it in **all four adapters'** `run_app`, "mirroring registry injection." That is unnecessary and was written against the removed `StateInserter` layer. Registries are injected adapter-side **because they are platform-specific handles that cannot live in the neutral router**; **app state is platform-neutral and already lives in the router** (`state_extensions`), whose dispatch injection (shipped in PR #306) delivers it to every request. So P0-D is just: **have the macro-generated router call `with_state`** — reusing the exact path the hand-written case already uses. No new `Hooks` method, no `AppState` carrier, **no adapter changes**. + +**`edgezero-macros` — `app!` gains an optional `state` argument.** `build_router()` emits one extra builder call, right next to the existing `with_manifest_json`: + +```rust +edgezero_core::app!("edgezero.toml", state = crate::app_state()); + +// in the generated build_router(): +let mut builder = RouterService::builder(); +builder = builder.with_manifest_json(#manifest_json_lit); +builder = builder.with_state(crate::app_state()); // #state_expr, only when `state = …` is given +// ... routes ... +``` + +**`state = ` is a full Rust expression** evaluating to the app-owned value, emitted **verbatim** into `.with_state()`. It must be the call/expression, **not** a bare function path — `with_state` takes the state *value*, so `state = crate::app_state` (a fn item) would pass the function, not its result. Write `state = crate::app_state()` or `state = std::sync::Arc::new(AppState::new())`. (This mirrors nothing magical: the macro does not append `()`.) `run_app` → `A::build_app()` → `routes()` → `build_router()` already runs per request (Fastly) / once at startup (Axum), and #306's dispatch injection clones the value into each request — so `State` reaches `#[action]` handlers on **all four adapters** with no adapter edits. Without the `state` argument, `build_router()` is unchanged (no state), preserving current behavior. + +**Single vs. multiple state types.** `with_state` registers one `T` — a single `state = crate::app_state()` covers the `Arc` case (what trusted-server / `app-demo` need). If multiple state types are ever required, allow repeated `state = a(), state = b()` (emit one `.with_state(...)` per occurrence) or add a `RouterBuilder::with_state_extensions(Extensions)` fed by an app-supplied `Extensions` bag. Default to the single-value form unless a concrete multi-type need appears. **The grammar below permits repeated `state`; whether repeats are accepted or rejected is a decision the plan must state** (see §`app!` argument grammar). + +### `app!` argument grammar (governs P0-D `state` and C2 `owns_logging`) + +This must be nailed down before a step-by-step plan. Today `AppArgs::parse` (`edgezero-macros/src/app.rs:12`) accepts only `app!("path")` or `app!("path", AppIdent)` and errors on any further tokens. Extend it to: + +``` +app!( PATH [, APP_IDENT] [, KEY = VALUE]* ) +``` + +- **PATH** — string literal (manifest path). Required, first. Unchanged. +- **APP_IDENT** — optional bare identifier (the custom `App` type name), exactly as today. If present it must be the **first** comma item after PATH, before any `KEY = VALUE`. At most one. +- **KEY = VALUE** — zero or more keyword arguments, **order-independent** among themselves, following `APP_IDENT` if that is present. Recognized keys: + - `state = ` — a `syn::Expr`; emits `.with_state()` in `build_router()`. + - `owns_logging = ` — `true` or `false` (a `syn::LitBool`). The macro **always emits** `fn owns_logging() -> bool { #bool }` in the generated `Hooks` impl, defaulting `#bool` to `false` when the argument is omitted. It must **not** rely on the trait default: `clippy::missing_trait_methods` (`restriction = deny`, `Cargo.toml`) forbids an impl from inheriting a defaulted trait method, which is exactly why the macro already emits explicit `configure`/`build_app` bodies (`edgezero-macros/src/app.rs:154`). Emit `owns_logging` the same way. +- **Disambiguation** — after PATH, iterate comma-separated items. For each item, `peek2(Token![=])`: if the next-next token is `=`, parse `Ident = Value` as a keyword; otherwise parse a bare `Ident` as `APP_IDENT`. +- **Errors (define exact messages in the plan):** + - Unknown key → `` unknown `app!` argument ``; expected `state` or `owns_logging` ``. + - Duplicate key → `` duplicate `` argument ``. (Decide state-repeat policy: reject as duplicate, or allow N `state` args — pick one; recommend **reject duplicates** initially, single `state` only, to keep it simple.) + - Bare ident after a keyword arg, or a second bare ident → `` the custom App identifier must come immediately after the manifest path, before keyword arguments ``. + - Wrong value type (`state` given a non-expression, `owns_logging` given a non-bool) → a clear per-key message. +- **`AppArgs` becomes** `{ path: LitStr, app_ident: Option, state: Option, owns_logging: Option }`; the two new fields feed `build_router()` (state) and the generated `Hooks` impl (owns_logging). Add UI/trybuild-style or unit coverage for: happy path each key, both keys, key + app ident, unknown key, duplicate key, ident-after-key. + +### Alternative that needs NO macro change (document in the guide) + +A downstream that keeps a **hand-written `Hooks::routes()`** can call `RouterBuilder::with_state(app_state)` there directly; the dispatch-time `state_extensions` injection then delivers it under `run_app` with zero further change. The trade-off is routes are built in Rust rather than declared in `edgezero.toml`. trusted-server may take this path — but the `state = …` macro argument is what makes app state work for the **fully macro-driven** shape `app-demo` models. + +### P0-D acceptance + +- A macro app declaring `app!("...", state = )` (e.g. `state = crate::app_state()`) can extract `State` (where `T` is the expression's value type) in an `#[action]` handler on all four adapters. +- An app that provides no state is unaffected (`State` for an unregistered `T` returns the existing "no state registered" 500). +- `app-demo` gains a small example using `app!(..., state = ...)` + a `State` handler. +- **No adapter (`run_app`) or `Hooks`-trait change is required for P0-D** — the diff is confined to `edgezero-macros` (and the `app-demo` example). This is the acceptance signal that the revised, simpler design was taken. + +--- + +## Sequencing & interaction with trusted-server Phase 1 + +- **P0-C is required** for trusted-server Phase 4 (Fastly `run_app`). Until it lands, trusted-server's Phase 1 keeps interim Fastly local registry builders + custom `oneshot`; those are deleted in Phase 4 once P0-C exists. Landing P0-C early lets Phase 1 skip that throwaway scaffolding. +- **P0-D is required only for the `app!`-macro path.** If trusted-server keeps hand-built `routes()` + `with_state`, P0-C alone suffices for full `run_app` convergence. Decide this before Phase 4. +- Both are independent of the nested-`#[secret]` work already in #306. + +## Files to touch (edgezero) + +**P0-C** +- `crates/edgezero-adapter-fastly/src/response.rs` — `set_header` → `append_header` in the handler-response loop (C1) +- `crates/edgezero-adapter-fastly/src/proxy.rs` — (a) audit/switch request-side `set_header` (`:53`); (b) fix `convert_response` to read `get_header_all` + `headers_mut().append` so multi-value **origin** response headers survive — unless proxy-response fidelity is explicitly excluded from P0-C (C1) +- `crates/edgezero-core/src/app.rs` — `Hooks::owns_logging()` default-`false` method (C2) +- `crates/edgezero-adapter-fastly/src/lib.rs` — gate `init_logger` (`:117`) on `!A::owns_logging()`; add `run_app_with_request_extensions` (C2, C3) +- `crates/edgezero-adapter-{cloudflare,axum,spin}/src/…` — gate each entrypoint's logger init on `!A::owns_logging()` (Cloudflare `lib.rs:105`, Axum `dev_server.rs:343`, Spin `lib.rs:115`) — required to keep the neutral `Hooks` flag honest (C2; omit only if C2 is scoped Fastly-only) +- `crates/edgezero-adapter-fastly/src/request.rs` — thread the pre-dispatch closure through `dispatch_with_registries`/`dispatch_with_handles`; scratch-`Extensions`-then-`extend` around `into_core_request` (C3) +- `crates/edgezero-macros/src/app.rs` — `owns_logging = true` argument so fully-macro apps can opt out of adapter logger init; per the `app!` argument grammar (C2) + +**P0-D** *(revised — macro-only; the router already owns state injection)* +- `crates/edgezero-macros/src/app.rs` — optional `state = ` argument (per the `app!` argument grammar); `build_router()` emits `.with_state(#state_expr)` +- `examples/app-demo/…` — small `app!(..., state = …)` + `State` handler example +- *(No `edgezero-core` `Hooks`/`AppState` change and no adapter change — superseding the original list's `Hooks::app_state()`, the router `StateInserter`-exposure line, and the four-adapter edits, all of which the `Extensions`-bag reshape made unnecessary.)* + +> **Note:** `crates/edgezero-macros/src/app.rs` is touched by **both** C2 (`owns_logging =`) and P0-D (`state =`), and both extend the same `AppArgs` grammar. If P0-C and P0-D are planned/shipped as separate plans (recommended — they are independent), the `app!` grammar extension is a shared prerequisite; land the `AppArgs` grammar rework once (with both keys) or sequence the plans so the second rebases on the first. diff --git a/examples/app-demo/crates/app-demo-core/src/config.rs b/examples/app-demo/crates/app-demo-core/src/config.rs index 145525cf..0eeae7a6 100644 --- a/examples/app-demo/crates/app-demo-core/src/config.rs +++ b/examples/app-demo/crates/app-demo-core/src/config.rs @@ -123,16 +123,16 @@ mod tests { #[test] fn secret_fields_metadata_matches_declarations() { - let mut by_name: Vec<(&str, SecretKind)> = AppDemoConfig::SECRET_FIELDS - .iter() - .map(|f| (f.name, f.kind)) + let mut by_path: Vec<(String, SecretKind)> = AppDemoConfig::secret_fields() + .into_iter() + .map(|f| (f.dotted_path(), f.kind)) .collect(); - by_name.sort_by_key(|(name, _)| *name); + by_path.sort_by_key(|(path, _)| path.clone()); assert_eq!( - by_name, + by_path, vec![ - ("api_token", SecretKind::KeyInDefault), - ("vault", SecretKind::StoreRef), + ("api_token".to_owned(), SecretKind::KeyInDefault), + ("vault".to_owned(), SecretKind::StoreRef), ], ); } diff --git a/examples/app-demo/crates/app-demo-core/src/handlers.rs b/examples/app-demo/crates/app-demo-core/src/handlers.rs index 8358ac5c..ae112669 100644 --- a/examples/app-demo/crates/app-demo-core/src/handlers.rs +++ b/examples/app-demo/crates/app-demo-core/src/handlers.rs @@ -1,11 +1,14 @@ use std::env; +use std::sync::Arc; use bytes::Bytes; use edgezero_core::action; use edgezero_core::body::Body; use edgezero_core::context::RequestContext; use edgezero_core::error::EdgeError; -use edgezero_core::extractor::{AppConfig, Headers, Json, Kv, Path, Query, Secrets, ValidatedPath}; +use edgezero_core::extractor::{ + AppConfig, Headers, Json, Kv, Path, Query, Secrets, State, ValidatedPath, +}; use edgezero_core::http::{self, Response, StatusCode, Uri}; use edgezero_core::proxy::ProxyRequest; use edgezero_core::response::Text; @@ -304,6 +307,16 @@ pub async fn secrets_echo( Ok(Text::new(value)) } +/// Demonstrates app-owned shared state injected via `app!(..., state = ...)`: +/// the `State>` extractor resolves the value the macro-generated +/// router registered with `RouterBuilder::with_state`. +#[action] +pub async fn state_demo( + State(state): State>, +) -> Result, EdgeError> { + Ok(Text::new(state.greeting.clone())) +} + #[cfg(test)] mod tests { use super::*; @@ -1009,4 +1022,23 @@ mod tests { "chunk 0\nchunk 1\nchunk 2\n" ); } + + #[test] + fn state_demo_handler_reads_app_state_through_macro_router() { + // build_router() is macro-generated and now calls `.with_state(crate::app_state())`. + let service = crate::build_router(); + + let request = request_builder() + .method(Method::GET) + .uri("/state-demo") + .body(Body::empty()) + .expect("request"); + + let response = block_on(service.oneshot(request)).expect("response"); + assert_eq!(response.status(), StatusCode::OK); + assert_eq!( + response.body().as_bytes().expect("buffered"), + b"hello from app state" + ); + } } diff --git a/examples/app-demo/crates/app-demo-core/src/lib.rs b/examples/app-demo/crates/app-demo-core/src/lib.rs index d3c87003..59892387 100644 --- a/examples/app-demo/crates/app-demo-core/src/lib.rs +++ b/examples/app-demo/crates/app-demo-core/src/lib.rs @@ -8,4 +8,23 @@ pub mod config; // internally; pub visibility is purely additive. pub mod handlers; -edgezero_core::app!("../../edgezero.toml"); +use std::sync::Arc; + +/// App-owned shared state for the `app!(..., state = ...)` demonstration, +/// handed to handlers via `State>`. +#[derive(Debug)] +pub struct DemoState { + /// A greeting the handler echoes, proving the value reached the handler. + pub greeting: String, +} + +/// Constructs the shared app state. Referenced by `app!(..., state = crate::app_state())`. +#[must_use] +#[inline] +pub fn app_state() -> Arc { + Arc::new(DemoState { + greeting: "hello from app state".to_owned(), + }) +} + +edgezero_core::app!("../../edgezero.toml", state = crate::app_state()); diff --git a/examples/app-demo/edgezero.toml b/examples/app-demo/edgezero.toml index f5462639..123e5903 100644 --- a/examples/app-demo/edgezero.toml +++ b/examples/app-demo/edgezero.toml @@ -22,6 +22,14 @@ methods = ["GET"] handler = "app_demo_core::handlers::echo" adapters = ["axum", "cloudflare", "fastly", "spin"] +[[triggers.http]] +id = "state-demo" +path = "/state-demo" +methods = ["GET"] +handler = "app_demo_core::handlers::state_demo" +adapters = ["axum", "cloudflare", "fastly", "spin"] +description = "Reads app-owned state via State> (app!(state = ...))" + [[triggers.http]] id = "stream" path = "/stream"