From 2e9a5475e863095fc470da9472b417eda3183984 Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Tue, 24 Mar 2026 14:20:12 +0530 Subject: [PATCH 01/18] chore: add .worktrees/ to .gitignore --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index 515cbf4..a4c0cda 100644 --- a/.gitignore +++ b/.gitignore @@ -14,6 +14,9 @@ target/ # OS .DS_Store +# Worktrees +.worktrees/ + # Editors .claude/* !.claude/settings.json From e3250bb34dacea3e1a8d5f03e1d74648ad6f29f7 Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Tue, 24 Mar 2026 14:27:41 +0530 Subject: [PATCH 02/18] feat(core): add SecretStore trait, SecretHandle, and contract test macro --- crates/edgezero-core/src/lib.rs | 2 + crates/edgezero-core/src/secret_store.rs | 395 +++++++++++++++++++++++ 2 files changed, 397 insertions(+) create mode 100644 crates/edgezero-core/src/secret_store.rs diff --git a/crates/edgezero-core/src/lib.rs b/crates/edgezero-core/src/lib.rs index bc6fe81..1ddc460 100644 --- a/crates/edgezero-core/src/lib.rs +++ b/crates/edgezero-core/src/lib.rs @@ -10,6 +10,7 @@ pub mod handler; pub mod http; pub mod key_value_store; pub mod manifest; +pub mod secret_store; pub mod middleware; pub mod params; pub mod proxy; @@ -19,3 +20,4 @@ pub mod router; pub use edgezero_macros::{action, app}; pub use key_value_store::{KvError, KvHandle, KvPage, KvStore}; +pub use secret_store::{SecretError, SecretHandle, SecretStore}; diff --git a/crates/edgezero-core/src/secret_store.rs b/crates/edgezero-core/src/secret_store.rs new file mode 100644 index 0000000..ed699bf --- /dev/null +++ b/crates/edgezero-core/src/secret_store.rs @@ -0,0 +1,395 @@ +//! Provider-neutral secret store abstraction. +//! +//! # Architecture +//! +//! ```text +//! Handler code SecretHandle (get_str / require_str) +//! │ │ +//! └── Secrets extractor ─►│ UTF-8 / bytes layer +//! │ +//! Arc (object-safe, Bytes) +//! │ +//! ┌──────────────┼──────────────┐ +//! ▼ ▼ ▼ +//! EnvSecretStore FastlySecretStore CloudflareSecretStore +//! ``` +//! +//! Secrets are read-only — this API only retrieves values, +//! it never writes or deletes them. Provisioning secrets is the +//! responsibility of each platform's deployment toolchain. + +use std::fmt; +use std::sync::Arc; + +use async_trait::async_trait; +use bytes::Bytes; + +use crate::error::EdgeError; + +// --------------------------------------------------------------------------- +// Error +// --------------------------------------------------------------------------- + +/// Errors returned by secret store operations. +#[derive(Debug, thiserror::Error)] +pub enum SecretError { + /// The requested secret was not found. + #[error("secret not found: {name}")] + NotFound { name: String }, + + /// The secret store backend is temporarily unavailable. + #[error("secret store unavailable")] + Unavailable, + + /// A validation error (e.g., invalid secret name). + #[error("validation error: {0}")] + Validation(String), + + /// A general internal error. + #[error("secret store error: {0}")] + Internal(#[from] anyhow::Error), +} + +impl From for EdgeError { + fn from(err: SecretError) -> Self { + match err { + // NotFound = server misconfiguration, never a client 404. + // A missing API key means the platform isn't set up correctly, + // not that the request was invalid. + SecretError::NotFound { name } => EdgeError::internal(anyhow::anyhow!( + "required secret '{}' is not configured -- check platform secret store bindings", + name + )), + SecretError::Unavailable => { + EdgeError::service_unavailable("secret store unavailable") + } + // Validation errors are programming errors (bad secret name in code), + // not client errors. + SecretError::Validation(e) => { + EdgeError::internal(anyhow::anyhow!("secret name validation error: {e}")) + } + SecretError::Internal(e) => EdgeError::internal(e), + } + } +} + +// --------------------------------------------------------------------------- +// Trait +// --------------------------------------------------------------------------- + +/// Object-safe interface for secret store backends. +/// +/// All methods take `&self` — backends handle their own access model. +/// +/// This trait is always called through [`SecretHandle`], which validates +/// inputs before delegating here. Implementations may therefore assume: +/// - Names are non-empty and within [`SecretHandle::MAX_NAME_LEN`] +/// - Names contain no control characters +#[async_trait(?Send)] +pub trait SecretStore: Send + Sync { + /// Retrieve a secret as raw bytes. Returns `Ok(None)` if not found. + async fn get_bytes(&self, name: &str) -> Result, SecretError>; +} + +// --------------------------------------------------------------------------- +// Test-only no-op store +// --------------------------------------------------------------------------- + +/// A no-op [`SecretStore`] for tests that only need a [`SecretHandle`] to exist. +/// +/// All reads return `None`. +/// +/// Available in `#[cfg(test)]` builds and via the `test-utils` feature: +/// ```toml +/// [dev-dependencies] +/// edgezero-core = { path = "...", features = ["test-utils"] } +/// ``` +#[cfg(any(test, feature = "test-utils"))] +pub struct NoopSecretStore; + +#[cfg(any(test, feature = "test-utils"))] +#[async_trait(?Send)] +impl SecretStore for NoopSecretStore { + async fn get_bytes(&self, _name: &str) -> Result, SecretError> { + Ok(None) + } +} + +// --------------------------------------------------------------------------- +// In-memory store (test-utils) +// --------------------------------------------------------------------------- + +/// An in-memory [`SecretStore`] pre-populated with known secrets. +/// +/// Useful for contract tests and unit tests that need deterministic secret values. +/// +/// Available in `#[cfg(test)]` builds and via the `test-utils` feature. +#[cfg(any(test, feature = "test-utils"))] +pub struct InMemorySecretStore { + secrets: std::collections::HashMap, +} + +#[cfg(any(test, feature = "test-utils"))] +impl InMemorySecretStore { + pub fn new( + entries: impl IntoIterator, impl Into)>, + ) -> Self { + Self { + secrets: entries + .into_iter() + .map(|(k, v)| (k.into(), v.into())) + .collect(), + } + } +} + +#[cfg(any(test, feature = "test-utils"))] +#[async_trait(?Send)] +impl SecretStore for InMemorySecretStore { + async fn get_bytes(&self, name: &str) -> Result, SecretError> { + Ok(self.secrets.get(name).cloned()) + } +} + +// --------------------------------------------------------------------------- +// Handle +// --------------------------------------------------------------------------- + +/// A cloneable, ergonomic handle to a secret store. +/// +/// Provides typed helpers (`get_str`, `require_bytes`, `require_str`) +/// while delegating to the object-safe `SecretStore` trait underneath. +#[derive(Clone)] +pub struct SecretHandle { + store: Arc, +} + +impl fmt::Debug for SecretHandle { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("SecretHandle").finish_non_exhaustive() + } +} + +impl SecretHandle { + /// Maximum secret name length in bytes. + pub const MAX_NAME_LEN: usize = 512; + + /// Create a new handle wrapping a secret store implementation. + pub fn new(store: Arc) -> Self { + Self { store } + } + + fn validate_name(name: &str) -> Result<(), SecretError> { + if name.is_empty() { + return Err(SecretError::Validation("secret name cannot be empty".to_string())); + } + if name.len() > Self::MAX_NAME_LEN { + return Err(SecretError::Validation(format!( + "secret name length {} exceeds limit of {} bytes", + name.len(), + Self::MAX_NAME_LEN + ))); + } + if name.chars().any(|c| c.is_control()) { + return Err(SecretError::Validation( + "secret name contains invalid control characters".to_string(), + )); + } + Ok(()) + } + + /// Retrieve a secret as raw bytes. Returns `Ok(None)` if not found. + pub async fn get_bytes(&self, name: &str) -> Result, SecretError> { + Self::validate_name(name)?; + self.store.get_bytes(name).await + } + + /// Retrieve a secret as a UTF-8 string. Returns `Ok(None)` if not found. + pub async fn get_str(&self, name: &str) -> Result, SecretError> { + let bytes = self.get_bytes(name).await?; + bytes + .map(|b| { + String::from_utf8(b.to_vec()).map_err(|e| { + SecretError::Internal(anyhow::anyhow!( + "secret '{}' is not valid UTF-8: {e}", + name + )) + }) + }) + .transpose() + } + + /// Retrieve a secret as raw bytes. Returns `SecretError::NotFound` if absent. + pub async fn require_bytes(&self, name: &str) -> Result { + self.get_bytes(name) + .await? + .ok_or_else(|| SecretError::NotFound { name: name.to_string() }) + } + + /// Retrieve a secret as a UTF-8 string. Returns `SecretError::NotFound` if absent. + pub async fn require_str(&self, name: &str) -> Result { + let bytes = self.require_bytes(name).await?; + String::from_utf8(bytes.to_vec()).map_err(|e| { + SecretError::Internal(anyhow::anyhow!( + "secret '{}' is not valid UTF-8: {e}", + name + )) + }) + } +} + +// --------------------------------------------------------------------------- +// Contract test macro +// --------------------------------------------------------------------------- + +/// Generate a suite of contract tests for any [`SecretStore`] implementation. +/// +/// The factory expression must produce a store pre-populated with: +/// - `"contract_key"` → `Bytes::from("contract_value")` +/// - `"contract_key_2"` → `Bytes::from("another_value")` +/// - `"missing_key"` must NOT be present. +#[macro_export] +macro_rules! secret_store_contract_tests { + ($mod_name:ident, $factory:expr) => { + mod $mod_name { + use super::*; + use bytes::Bytes; + use $crate::secret_store::SecretStore; + + fn run(f: F) -> F::Output { + futures::executor::block_on(f) + } + + #[test] + fn contract_get_existing_returns_bytes() { + let store = $factory; + run(async { + let result = store.get_bytes("contract_key").await.unwrap(); + assert_eq!(result, Some(Bytes::from("contract_value"))); + }); + } + + #[test] + fn contract_get_second_key_returns_bytes() { + let store = $factory; + run(async { + let result = store.get_bytes("contract_key_2").await.unwrap(); + assert_eq!(result, Some(Bytes::from("another_value"))); + }); + } + + #[test] + fn contract_get_missing_returns_none() { + let store = $factory; + run(async { + let result = store.get_bytes("missing_key").await.unwrap(); + assert!(result.is_none()); + }); + } + } + }; +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + use bytes::Bytes; + use futures::executor::block_on; + + // Test-only in-memory store + use std::collections::HashMap; + struct SimpleStore(HashMap); + #[async_trait(?Send)] + impl SecretStore for SimpleStore { + async fn get_bytes(&self, name: &str) -> Result, SecretError> { + Ok(self.0.get(name).cloned()) + } + } + + fn store_with(entries: &[(&str, &str)]) -> SecretHandle { + let map: HashMap = entries + .iter() + .map(|(k, v)| (k.to_string(), Bytes::from(v.to_string()))) + .collect(); + SecretHandle::new(std::sync::Arc::new(SimpleStore(map))) + } + + #[test] + fn validate_name_rejects_empty() { + block_on(async { + let h = store_with(&[]); + let err = h.get_bytes("").await.unwrap_err(); + assert!(matches!(err, SecretError::Validation(_))); + }); + } + + #[test] + fn validate_name_rejects_control_chars() { + block_on(async { + let h = store_with(&[]); + let err = h.get_bytes("bad\x00name").await.unwrap_err(); + assert!(matches!(err, SecretError::Validation(_))); + }); + } + + #[test] + fn validate_name_rejects_oversized() { + block_on(async { + let h = store_with(&[]); + let name = "x".repeat(SecretHandle::MAX_NAME_LEN + 1); + let err = h.get_bytes(&name).await.unwrap_err(); + assert!(matches!(err, SecretError::Validation(_))); + }); + } + + #[test] + fn get_bytes_returns_none_for_missing() { + block_on(async { + let h = store_with(&[]); + assert_eq!(h.get_bytes("missing").await.unwrap(), None); + }); + } + + #[test] + fn get_bytes_returns_value_for_existing() { + block_on(async { + let h = store_with(&[("api_key", "secret123")]); + assert_eq!( + h.get_bytes("api_key").await.unwrap(), + Some(Bytes::from("secret123")) + ); + }); + } + + #[test] + fn get_str_decodes_utf8() { + block_on(async { + let h = store_with(&[("token", "bearer xyz")]); + assert_eq!( + h.get_str("token").await.unwrap(), + Some("bearer xyz".to_string()) + ); + }); + } + + #[test] + fn require_bytes_fails_for_missing() { + block_on(async { + let h = store_with(&[]); + let err = h.require_bytes("missing").await.unwrap_err(); + assert!(matches!(err, SecretError::NotFound { .. })); + }); + } + + #[test] + fn require_str_returns_value() { + block_on(async { + let h = store_with(&[("key", "value")]); + assert_eq!(h.require_str("key").await.unwrap(), "value"); + }); + } +} From 766ed91852171a2fe980d1816113e7c1c0184e5f Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Tue, 24 Mar 2026 14:31:15 +0530 Subject: [PATCH 03/18] refactor(core): use Bytes::into() for zero-copy UTF-8 conversion in SecretHandle --- crates/edgezero-core/src/secret_store.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/edgezero-core/src/secret_store.rs b/crates/edgezero-core/src/secret_store.rs index ed699bf..9b1575f 100644 --- a/crates/edgezero-core/src/secret_store.rs +++ b/crates/edgezero-core/src/secret_store.rs @@ -209,7 +209,7 @@ impl SecretHandle { let bytes = self.get_bytes(name).await?; bytes .map(|b| { - String::from_utf8(b.to_vec()).map_err(|e| { + String::from_utf8(b.into()).map_err(|e| { SecretError::Internal(anyhow::anyhow!( "secret '{}' is not valid UTF-8: {e}", name @@ -229,7 +229,7 @@ impl SecretHandle { /// Retrieve a secret as a UTF-8 string. Returns `SecretError::NotFound` if absent. pub async fn require_str(&self, name: &str) -> Result { let bytes = self.require_bytes(name).await?; - String::from_utf8(bytes.to_vec()).map_err(|e| { + String::from_utf8(bytes.into()).map_err(|e| { SecretError::Internal(anyhow::anyhow!( "secret '{}' is not valid UTF-8: {e}", name From 6c8cc08f49832a8c0a4d05ca321418a247bd67df Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Tue, 24 Mar 2026 14:33:19 +0530 Subject: [PATCH 04/18] feat(core): add secret_handle() to RequestContext and Secrets extractor --- crates/edgezero-core/src/context.rs | 29 ++++++++++ crates/edgezero-core/src/extractor.rs | 78 +++++++++++++++++++++++++++ 2 files changed, 107 insertions(+) diff --git a/crates/edgezero-core/src/context.rs b/crates/edgezero-core/src/context.rs index 67efdef..0b6e054 100644 --- a/crates/edgezero-core/src/context.rs +++ b/crates/edgezero-core/src/context.rs @@ -3,6 +3,7 @@ use crate::error::EdgeError; use crate::http::Request; use crate::key_value_store::KvHandle; use crate::params::PathParams; +use crate::secret_store::SecretHandle; use crate::proxy::ProxyHandle; use serde::de::DeserializeOwned; @@ -88,6 +89,10 @@ impl RequestContext { pub fn kv_handle(&self) -> Option { self.request.extensions().get::().cloned() } + + pub fn secret_handle(&self) -> Option { + self.request.extensions().get::().cloned() + } } #[cfg(test)] @@ -350,4 +355,28 @@ mod tests { let ctx = ctx("/test", Body::empty(), PathParams::default()); assert!(ctx.kv_handle().is_none()); } + + #[test] + fn secret_handle_is_retrieved_when_present() { + use crate::secret_store::{NoopSecretStore, SecretHandle}; + use std::sync::Arc; + + let mut request = request_builder() + .method(Method::GET) + .uri("/secrets") + .body(Body::empty()) + .expect("request"); + request + .extensions_mut() + .insert(SecretHandle::new(Arc::new(NoopSecretStore))); + + let ctx = RequestContext::new(request, PathParams::default()); + assert!(ctx.secret_handle().is_some()); + } + + #[test] + fn secret_handle_returns_none_when_absent() { + let ctx = ctx("/test", Body::empty(), PathParams::default()); + assert!(ctx.secret_handle().is_none()); + } } diff --git a/crates/edgezero-core/src/extractor.rs b/crates/edgezero-core/src/extractor.rs index 2c58d9b..38a715c 100644 --- a/crates/edgezero-core/src/extractor.rs +++ b/crates/edgezero-core/src/extractor.rs @@ -448,6 +448,52 @@ impl Kv { } } +/// Extracts the [`SecretHandle`] from the request context. +/// +/// Returns `EdgeError::Internal` if no secret store was configured for this request. +/// +/// # Example +/// ```ignore +/// #[action] +/// pub async fn handler(Secrets(secrets): Secrets) -> Result { +/// let key = secrets.require_str("API_KEY").await.map_err(EdgeError::from)?; +/// // use key ... +/// } +/// ``` +#[derive(Debug)] +pub struct Secrets(pub crate::secret_store::SecretHandle); + +#[async_trait(?Send)] +impl FromRequest for Secrets { + async fn from_request(ctx: &RequestContext) -> Result { + ctx.secret_handle().map(Secrets).ok_or_else(|| { + EdgeError::internal(anyhow::anyhow!( + "no secret store configured -- check [stores.secrets] in edgezero.toml and platform bindings" + )) + }) + } +} + +impl std::ops::Deref for Secrets { + type Target = crate::secret_store::SecretHandle; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl std::ops::DerefMut for Secrets { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.0 + } +} + +impl Secrets { + pub fn into_inner(self) -> crate::secret_store::SecretHandle { + self.0 + } +} + #[cfg(test)] mod tests { use super::*; @@ -1009,4 +1055,36 @@ mod tests { // into_inner works let _inner: KvHandle = kv.into_inner(); } + + // -- Secrets extractor -------------------------------------------------- + + #[test] + fn secrets_extractor_returns_handle_when_present() { + use crate::secret_store::{NoopSecretStore, SecretHandle}; + use std::sync::Arc; + + let mut request = request_builder() + .method(Method::GET) + .uri("/secrets") + .body(Body::empty()) + .expect("request"); + request + .extensions_mut() + .insert(SecretHandle::new(Arc::new(NoopSecretStore))); + let ctx = RequestContext::new(request, PathParams::default()); + let result = block_on(Secrets::from_request(&ctx)); + assert!(result.is_ok()); + } + + #[test] + fn secrets_extractor_errors_when_absent() { + let request = request_builder() + .method(Method::GET) + .uri("/secrets") + .body(Body::empty()) + .expect("request"); + let ctx = RequestContext::new(request, PathParams::default()); + let err = block_on(Secrets::from_request(&ctx)).unwrap_err(); + assert_eq!(err.status(), StatusCode::INTERNAL_SERVER_ERROR); + } } From 6aadb54c872fdeecfb470fa38d15893305716d1b Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Tue, 24 Mar 2026 14:37:04 +0530 Subject: [PATCH 05/18] feat(core): add [stores.secrets] manifest schema and secret_store_name() --- crates/edgezero-core/src/manifest.rs | 110 +++++++++++++++++++++++++++ 1 file changed, 110 insertions(+) diff --git a/crates/edgezero-core/src/manifest.rs b/crates/edgezero-core/src/manifest.rs index 0efb690..ea130a3 100644 --- a/crates/edgezero-core/src/manifest.rs +++ b/crates/edgezero-core/src/manifest.rs @@ -142,6 +142,29 @@ impl Manifest { } } + /// Returns the secret store name for a given adapter. + /// + /// Resolution order: + /// 1. Per-adapter override (`[stores.secrets.adapters.]`) + /// 2. Global name (`[stores.secrets] name = "..."`) + /// 3. Default: `"EDGEZERO_SECRETS"` + pub fn secret_store_name(&self, adapter: &str) -> &str { + match &self.stores.secrets { + Some(secrets) => { + let adapter_lower = adapter.to_ascii_lowercase(); + if let Some(adapter_cfg) = secrets + .adapters + .iter() + .find(|(k, _)| k.eq_ignore_ascii_case(&adapter_lower)) + { + return &adapter_cfg.1.name; + } + &secrets.name + } + None => DEFAULT_SECRET_STORE_NAME, + } + } + fn finalize(&mut self) { let mut resolved = BTreeMap::new(); @@ -397,6 +420,13 @@ fn default_kv_name() -> String { DEFAULT_KV_STORE_NAME.to_string() } +/// Default secret store / binding name used when `[stores.secrets]` is omitted. +pub const DEFAULT_SECRET_STORE_NAME: &str = "EDGEZERO_SECRETS"; + +fn default_secret_name() -> String { + DEFAULT_SECRET_STORE_NAME.to_string() +} + /// Configuration for external stores (e.g., KV, object storage). /// /// ```toml @@ -413,6 +443,12 @@ pub struct ManifestStores { #[serde(default)] #[validate(nested)] pub kv: Option, + + /// Secret store configuration. When absent, the default + /// name `EDGEZERO_SECRETS` is used. + #[serde(default)] + #[validate(nested)] + pub secrets: Option, } /// Global KV store configuration. @@ -436,6 +472,27 @@ pub struct ManifestKvAdapterConfig { pub name: String, } +/// Global secret store configuration. +#[derive(Debug, Deserialize, Validate)] +pub struct ManifestSecretsConfig { + /// Store / binding name (default: `"EDGEZERO_SECRETS"`). + #[serde(default = "default_secret_name")] + #[validate(length(min = 1))] + pub name: String, + + /// Per-adapter name overrides. + #[serde(default)] + #[validate(nested)] + pub adapters: BTreeMap, +} + +/// Per-adapter secret store name override. +#[derive(Debug, Deserialize, Validate)] +pub struct ManifestSecretsAdapterConfig { + #[validate(length(min = 1))] + pub name: String, +} + #[derive(Clone, Debug, Eq, PartialEq)] pub enum HttpMethod { Get, @@ -1239,4 +1296,57 @@ name = "FASTLY_STORE" assert_eq!(manifest.kv_store_name("fastly"), "FASTLY_STORE"); assert_eq!(manifest.kv_store_name("FASTLY"), "FASTLY_STORE"); } + + // -- Secret store config ----------------------------------------------- + + #[test] + fn secret_store_name_defaults_to_constant_when_absent() { + let manifest = ManifestLoader::load_from_str("[app]\nname = \"x\"\n"); + assert_eq!( + manifest.manifest().secret_store_name("fastly"), + DEFAULT_SECRET_STORE_NAME + ); + } + + #[test] + fn secret_store_name_uses_global_name_when_declared() { + let manifest = ManifestLoader::load_from_str( + "[stores.secrets]\nname = \"MY_SECRETS\"\n", + ); + assert_eq!(manifest.manifest().secret_store_name("fastly"), "MY_SECRETS"); + assert_eq!( + manifest.manifest().secret_store_name("cloudflare"), + "MY_SECRETS" + ); + } + + #[test] + fn secret_store_name_uses_per_adapter_override() { + let manifest = ManifestLoader::load_from_str( + "[stores.secrets]\nname = \"MY_SECRETS\"\n\ + [stores.secrets.adapters.fastly]\nname = \"FASTLY_STORE\"\n", + ); + assert_eq!( + manifest.manifest().secret_store_name("fastly"), + "FASTLY_STORE" + ); + assert_eq!( + manifest.manifest().secret_store_name("cloudflare"), + "MY_SECRETS" + ); + } + + #[test] + fn secrets_required_is_false_when_absent() { + let manifest = ManifestLoader::load_from_str("[app]\nname = \"x\"\n"); + assert!(manifest.manifest().stores.secrets.is_none()); + } + + #[test] + fn secrets_required_is_true_when_declared() { + let manifest = ManifestLoader::load_from_str( + "[stores.secrets]\nname = \"MY_SECRETS\"\n", + ); + assert!(manifest.manifest().stores.secrets.is_some()); + } } From 514f16490ef3dbbdcaf397483a4be7941147c433 Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Tue, 24 Mar 2026 14:41:30 +0530 Subject: [PATCH 06/18] feat(fastly): add FastlySecretStore adapter and dispatch_with_secrets --- crates/edgezero-adapter-fastly/src/lib.rs | 24 ++++- crates/edgezero-adapter-fastly/src/request.rs | 93 +++++++++++++++++++ .../src/secret_store.rs | 47 ++++++++++ 3 files changed, 161 insertions(+), 3 deletions(-) create mode 100644 crates/edgezero-adapter-fastly/src/secret_store.rs diff --git a/crates/edgezero-adapter-fastly/src/lib.rs b/crates/edgezero-adapter-fastly/src/lib.rs index 20ba5cd..c82c3b2 100644 --- a/crates/edgezero-adapter-fastly/src/lib.rs +++ b/crates/edgezero-adapter-fastly/src/lib.rs @@ -9,6 +9,8 @@ pub mod key_value_store; #[cfg(feature = "fastly")] mod logger; #[cfg(feature = "fastly")] +pub mod secret_store; +#[cfg(feature = "fastly")] mod proxy; #[cfg(feature = "fastly")] mod request; @@ -19,7 +21,12 @@ pub use context::FastlyRequestContext; #[cfg(feature = "fastly")] pub use proxy::FastlyProxyClient; #[cfg(feature = "fastly")] -pub use request::{dispatch, dispatch_with_kv, into_core_request, DEFAULT_KV_STORE_NAME}; +pub use request::{ + dispatch, dispatch_with_kv, dispatch_with_kv_and_secrets, dispatch_with_secrets, + into_core_request, DEFAULT_KV_STORE_NAME, +}; +#[cfg(feature = "fastly")] +pub use secret_store::FastlySecretStore; #[cfg(feature = "fastly")] pub use response::from_core_response; @@ -84,7 +91,16 @@ pub fn run_app( let logging = manifest.logging_or_default("fastly"); let kv_name = manifest.kv_store_name("fastly").to_string(); let kv_required = manifest.stores.kv.is_some(); - run_app_with_logging::(logging.into(), req, &kv_name, kv_required) + let secret_name = manifest.secret_store_name("fastly").to_string(); + let secrets_required = manifest.stores.secrets.is_some(); + run_app_with_logging::( + logging.into(), + req, + &kv_name, + kv_required, + &secret_name, + secrets_required, + ) } #[cfg(feature = "fastly")] @@ -93,6 +109,8 @@ pub(crate) fn run_app_with_logging( req: fastly::Request, kv_store_name: &str, kv_required: bool, + secret_store_name: &str, + secrets_required: bool, ) -> Result { if logging.use_fastly_logger { let endpoint = logging.endpoint.as_deref().unwrap_or("stdout"); @@ -100,7 +118,7 @@ pub(crate) fn run_app_with_logging( } let app = A::build_app(); - dispatch_with_kv(&app, req, kv_store_name, kv_required) + dispatch_with_kv_and_secrets(&app, req, kv_store_name, kv_required, secret_store_name, secrets_required) } #[cfg(all(test, feature = "fastly"))] diff --git a/crates/edgezero-adapter-fastly/src/request.rs b/crates/edgezero-adapter-fastly/src/request.rs index 670b698..6fd8c10 100644 --- a/crates/edgezero-adapter-fastly/src/request.rs +++ b/crates/edgezero-adapter-fastly/src/request.rs @@ -8,6 +8,7 @@ use edgezero_core::error::EdgeError; use edgezero_core::http::{request_builder, Request}; use edgezero_core::key_value_store::KvHandle; use edgezero_core::proxy::ProxyHandle; +use edgezero_core::secret_store::SecretHandle; use fastly::{Error as FastlyError, Request as FastlyRequest, Response as FastlyResponse}; use futures::executor; @@ -107,3 +108,95 @@ fn warn_missing_kv_store_once(kv_store_name: &str, error: &impl std::fmt::Displa } } } + +/// Dispatch a Fastly request with a secret store attached. +pub fn dispatch_with_secrets( + app: &App, + req: FastlyRequest, + secret_store_name: &str, + secrets_required: bool, +) -> Result { + let mut core_request = into_core_request(req).map_err(map_edge_error)?; + + match crate::secret_store::FastlySecretStore::open(secret_store_name) { + Ok(store) => { + let handle = SecretHandle::new(std::sync::Arc::new(store)); + core_request.extensions_mut().insert(handle); + } + Err(e) => { + if secrets_required { + return Err(FastlyError::msg(format!( + "secret store '{}' is explicitly configured but could not be opened: {}", + secret_store_name, e + ))); + } + warn_missing_secret_store_once(secret_store_name, &e); + } + } + + let response = executor::block_on(app.router().oneshot(core_request)); + from_core_response(response).map_err(map_edge_error) +} + +/// Dispatch a Fastly request with both KV and secret stores attached. +pub fn dispatch_with_kv_and_secrets( + app: &App, + req: FastlyRequest, + kv_store_name: &str, + kv_required: bool, + secret_store_name: &str, + secrets_required: bool, +) -> Result { + let mut core_request = into_core_request(req).map_err(map_edge_error)?; + + match FastlyKvStore::open(kv_store_name) { + Ok(store) => { + let handle = KvHandle::new(std::sync::Arc::new(store)); + core_request.extensions_mut().insert(handle); + } + Err(e) => { + if kv_required { + return Err(FastlyError::msg(format!( + "KV store '{}' is explicitly configured but could not be opened: {}", + kv_store_name, e + ))); + } + warn_missing_kv_store_once(kv_store_name, &e); + } + } + + match crate::secret_store::FastlySecretStore::open(secret_store_name) { + Ok(store) => { + let handle = SecretHandle::new(std::sync::Arc::new(store)); + core_request.extensions_mut().insert(handle); + } + Err(e) => { + if secrets_required { + return Err(FastlyError::msg(format!( + "secret store '{}' is explicitly configured but could not be opened: {}", + secret_store_name, e + ))); + } + warn_missing_secret_store_once(secret_store_name, &e); + } + } + + let response = executor::block_on(app.router().oneshot(core_request)); + from_core_response(response).map_err(map_edge_error) +} + +fn warn_missing_secret_store_once(name: &str, error: &impl std::fmt::Display) { + static WARNED: OnceLock>> = OnceLock::new(); + let warned = WARNED.get_or_init(|| Mutex::new(BTreeSet::new())); + match warned.lock() { + Ok(mut warned) => { + if !warned.insert(name.to_string()) { + return; + } + log::warn!("secret store '{}' not available: {}", name, error); + } + Err(_) => { + log::warn!("secret store '{}' not available: {}", name, error); + } + } +} diff --git a/crates/edgezero-adapter-fastly/src/secret_store.rs b/crates/edgezero-adapter-fastly/src/secret_store.rs new file mode 100644 index 0000000..3baec1a --- /dev/null +++ b/crates/edgezero-adapter-fastly/src/secret_store.rs @@ -0,0 +1,47 @@ +//! Fastly SecretStore adapter. +//! +//! Wraps `fastly::secret_store::SecretStore` to implement +//! `edgezero_core::secret_store::SecretStore`. + +#[cfg(feature = "fastly")] +use async_trait::async_trait; +#[cfg(feature = "fastly")] +use bytes::Bytes; +#[cfg(feature = "fastly")] +use edgezero_core::secret_store::{SecretError, SecretStore}; + +/// Secret store backed by Fastly's SecretStore API. +#[cfg(feature = "fastly")] +pub struct FastlySecretStore { + store: fastly::secret_store::SecretStore, +} + +#[cfg(feature = "fastly")] +impl FastlySecretStore { + /// Open a Fastly SecretStore by name. + /// + /// Returns `SecretError::Internal` if the store does not exist or cannot + /// be opened. Unlike `KVStore::open`, the Fastly SecretStore API returns + /// `Result` (not `Result, _>`), so there + /// is no `ok_or` unwrap here. + pub fn open(name: &str) -> Result { + let store = fastly::secret_store::SecretStore::open(name).map_err(|e| { + SecretError::Internal(anyhow::anyhow!("failed to open secret store '{}': {e}", name)) + })?; + Ok(Self { store }) + } +} + +#[cfg(feature = "fastly")] +#[async_trait(?Send)] +impl SecretStore for FastlySecretStore { + async fn get_bytes(&self, name: &str) -> Result, SecretError> { + match self.store.get(name) { + Some(secret) => Ok(Some(secret.plaintext())), + None => Ok(None), + } + } +} + +// TODO: integration tests require the Fastly compute environment. +// Test `FastlySecretStore` as part of the Fastly adapter E2E test suite. From d79d67e6cc1bc20e4b8c719bf2525371883572d9 Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Tue, 24 Mar 2026 14:43:42 +0530 Subject: [PATCH 07/18] style: apply rustfmt to Task 4 changes --- crates/edgezero-adapter-fastly/src/lib.rs | 17 +- .../src/secret_store.rs | 5 +- crates/edgezero-core/src/context.rs | 2 +- crates/edgezero-core/src/lib.rs | 2 +- crates/edgezero-core/src/manifest.rs | 11 +- crates/edgezero-core/src/secret_store.rs | 21 +- .../2026-03-24-secret-store-abstraction.md | 1964 +++++++++++++++++ 7 files changed, 1996 insertions(+), 26 deletions(-) create mode 100644 docs/superpowers/plans/2026-03-24-secret-store-abstraction.md diff --git a/crates/edgezero-adapter-fastly/src/lib.rs b/crates/edgezero-adapter-fastly/src/lib.rs index c82c3b2..864c20e 100644 --- a/crates/edgezero-adapter-fastly/src/lib.rs +++ b/crates/edgezero-adapter-fastly/src/lib.rs @@ -9,13 +9,13 @@ pub mod key_value_store; #[cfg(feature = "fastly")] mod logger; #[cfg(feature = "fastly")] -pub mod secret_store; -#[cfg(feature = "fastly")] mod proxy; #[cfg(feature = "fastly")] mod request; #[cfg(feature = "fastly")] mod response; +#[cfg(feature = "fastly")] +pub mod secret_store; pub use context::FastlyRequestContext; #[cfg(feature = "fastly")] @@ -26,9 +26,9 @@ pub use request::{ into_core_request, DEFAULT_KV_STORE_NAME, }; #[cfg(feature = "fastly")] -pub use secret_store::FastlySecretStore; -#[cfg(feature = "fastly")] pub use response::from_core_response; +#[cfg(feature = "fastly")] +pub use secret_store::FastlySecretStore; #[cfg(feature = "fastly")] #[derive(Debug, Clone)] @@ -118,7 +118,14 @@ pub(crate) fn run_app_with_logging( } let app = A::build_app(); - dispatch_with_kv_and_secrets(&app, req, kv_store_name, kv_required, secret_store_name, secrets_required) + dispatch_with_kv_and_secrets( + &app, + req, + kv_store_name, + kv_required, + secret_store_name, + secrets_required, + ) } #[cfg(all(test, feature = "fastly"))] diff --git a/crates/edgezero-adapter-fastly/src/secret_store.rs b/crates/edgezero-adapter-fastly/src/secret_store.rs index 3baec1a..960751a 100644 --- a/crates/edgezero-adapter-fastly/src/secret_store.rs +++ b/crates/edgezero-adapter-fastly/src/secret_store.rs @@ -26,7 +26,10 @@ impl FastlySecretStore { /// is no `ok_or` unwrap here. pub fn open(name: &str) -> Result { let store = fastly::secret_store::SecretStore::open(name).map_err(|e| { - SecretError::Internal(anyhow::anyhow!("failed to open secret store '{}': {e}", name)) + SecretError::Internal(anyhow::anyhow!( + "failed to open secret store '{}': {e}", + name + )) })?; Ok(Self { store }) } diff --git a/crates/edgezero-core/src/context.rs b/crates/edgezero-core/src/context.rs index 0b6e054..48494c2 100644 --- a/crates/edgezero-core/src/context.rs +++ b/crates/edgezero-core/src/context.rs @@ -3,8 +3,8 @@ use crate::error::EdgeError; use crate::http::Request; use crate::key_value_store::KvHandle; use crate::params::PathParams; -use crate::secret_store::SecretHandle; use crate::proxy::ProxyHandle; +use crate::secret_store::SecretHandle; use serde::de::DeserializeOwned; /// Request context exposed to handlers and middleware. diff --git a/crates/edgezero-core/src/lib.rs b/crates/edgezero-core/src/lib.rs index 1ddc460..803100e 100644 --- a/crates/edgezero-core/src/lib.rs +++ b/crates/edgezero-core/src/lib.rs @@ -10,13 +10,13 @@ pub mod handler; pub mod http; pub mod key_value_store; pub mod manifest; -pub mod secret_store; pub mod middleware; pub mod params; pub mod proxy; pub mod responder; pub mod response; pub mod router; +pub mod secret_store; pub use edgezero_macros::{action, app}; pub use key_value_store::{KvError, KvHandle, KvPage, KvStore}; diff --git a/crates/edgezero-core/src/manifest.rs b/crates/edgezero-core/src/manifest.rs index ea130a3..aa6f3fe 100644 --- a/crates/edgezero-core/src/manifest.rs +++ b/crates/edgezero-core/src/manifest.rs @@ -1310,10 +1310,11 @@ name = "FASTLY_STORE" #[test] fn secret_store_name_uses_global_name_when_declared() { - let manifest = ManifestLoader::load_from_str( - "[stores.secrets]\nname = \"MY_SECRETS\"\n", + let manifest = ManifestLoader::load_from_str("[stores.secrets]\nname = \"MY_SECRETS\"\n"); + assert_eq!( + manifest.manifest().secret_store_name("fastly"), + "MY_SECRETS" ); - assert_eq!(manifest.manifest().secret_store_name("fastly"), "MY_SECRETS"); assert_eq!( manifest.manifest().secret_store_name("cloudflare"), "MY_SECRETS" @@ -1344,9 +1345,7 @@ name = "FASTLY_STORE" #[test] fn secrets_required_is_true_when_declared() { - let manifest = ManifestLoader::load_from_str( - "[stores.secrets]\nname = \"MY_SECRETS\"\n", - ); + let manifest = ManifestLoader::load_from_str("[stores.secrets]\nname = \"MY_SECRETS\"\n"); assert!(manifest.manifest().stores.secrets.is_some()); } } diff --git a/crates/edgezero-core/src/secret_store.rs b/crates/edgezero-core/src/secret_store.rs index 9b1575f..fdc76a7 100644 --- a/crates/edgezero-core/src/secret_store.rs +++ b/crates/edgezero-core/src/secret_store.rs @@ -60,9 +60,7 @@ impl From for EdgeError { "required secret '{}' is not configured -- check platform secret store bindings", name )), - SecretError::Unavailable => { - EdgeError::service_unavailable("secret store unavailable") - } + SecretError::Unavailable => EdgeError::service_unavailable("secret store unavailable"), // Validation errors are programming errors (bad secret name in code), // not client errors. SecretError::Validation(e) => { @@ -131,9 +129,7 @@ pub struct InMemorySecretStore { #[cfg(any(test, feature = "test-utils"))] impl InMemorySecretStore { - pub fn new( - entries: impl IntoIterator, impl Into)>, - ) -> Self { + pub fn new(entries: impl IntoIterator, impl Into)>) -> Self { Self { secrets: entries .into_iter() @@ -181,7 +177,9 @@ impl SecretHandle { fn validate_name(name: &str) -> Result<(), SecretError> { if name.is_empty() { - return Err(SecretError::Validation("secret name cannot be empty".to_string())); + return Err(SecretError::Validation( + "secret name cannot be empty".to_string(), + )); } if name.len() > Self::MAX_NAME_LEN { return Err(SecretError::Validation(format!( @@ -223,17 +221,16 @@ impl SecretHandle { pub async fn require_bytes(&self, name: &str) -> Result { self.get_bytes(name) .await? - .ok_or_else(|| SecretError::NotFound { name: name.to_string() }) + .ok_or_else(|| SecretError::NotFound { + name: name.to_string(), + }) } /// Retrieve a secret as a UTF-8 string. Returns `SecretError::NotFound` if absent. pub async fn require_str(&self, name: &str) -> Result { let bytes = self.require_bytes(name).await?; String::from_utf8(bytes.into()).map_err(|e| { - SecretError::Internal(anyhow::anyhow!( - "secret '{}' is not valid UTF-8: {e}", - name - )) + SecretError::Internal(anyhow::anyhow!("secret '{}' is not valid UTF-8: {e}", name)) }) } } diff --git a/docs/superpowers/plans/2026-03-24-secret-store-abstraction.md b/docs/superpowers/plans/2026-03-24-secret-store-abstraction.md new file mode 100644 index 0000000..52bc4b9 --- /dev/null +++ b/docs/superpowers/plans/2026-03-24-secret-store-abstraction.md @@ -0,0 +1,1964 @@ +# Secret Store Abstraction 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:** Provide a provider-neutral `SecretStore` trait so applications can access sensitive values (API keys, signing keys, tokens) without coupling to platform-specific secret APIs. + +**Architecture:** A thin `SecretStore` trait with a single `get_bytes` method is wrapped by a `SecretHandle` that validates inputs and adds `get_str`/`require_*` helpers. Each adapter implements the trait using the platform's native secret mechanism: Fastly `SecretStore`, Cloudflare `Env::secret()`, and Axum `std::env::var`. The handle is stored in request extensions and retrieved via the `Secrets` extractor — identical pattern to the existing `KvHandle`/`Kv` extractor. + +**Tech Stack:** Rust 1.91, `async-trait`, `bytes`, `thiserror`, `anyhow`, Fastly 0.11 crate, Cloudflare `worker` 0.7 crate + +--- + +## File Map + +### New files +| File | Responsibility | +|------|----------------| +| `crates/edgezero-core/src/secret_store.rs` | `SecretStore` trait, `SecretHandle`, `SecretError`, `NoopSecretStore` (test-utils), `InMemorySecretStore` (test-utils), `secret_store_contract_tests!` macro | +| `crates/edgezero-adapter-fastly/src/secret_store.rs` | `FastlySecretStore` — wraps `fastly::secret_store::SecretStore` | +| `crates/edgezero-adapter-cloudflare/src/secret_store.rs` | `CloudflareSecretStore` — wraps `worker::Env` for on-demand lookup | +| `crates/edgezero-adapter-axum/src/secret_store.rs` | `EnvSecretStore` — reads from `std::env::var` | + +### Modified files +| File | Change | +|------|--------| +| `crates/edgezero-core/src/lib.rs` | Add `pub mod secret_store` + re-exports | +| `crates/edgezero-core/src/context.rs` | Add `secret_handle()` method | +| `crates/edgezero-core/src/extractor.rs` | Add `Secrets` extractor | +| `crates/edgezero-core/src/manifest.rs` | Add `ManifestSecretsConfig`, `ManifestSecretsAdapterConfig`, update `ManifestStores`, add `secret_store_name()`, add `DEFAULT_SECRET_STORE_NAME` constant | +| `crates/edgezero-adapter-fastly/src/lib.rs` | Add `pub mod secret_store`, exports, update `run_app` | +| `crates/edgezero-adapter-fastly/src/request.rs` | Add `dispatch_with_secrets`, `dispatch_with_kv_and_secrets` | +| `crates/edgezero-adapter-cloudflare/src/lib.rs` | Add `pub mod secret_store`, exports, update `run_app` | +| `crates/edgezero-adapter-cloudflare/src/request.rs` | Add `dispatch_with_secrets`, `dispatch_with_kv_and_secrets` | +| `crates/edgezero-adapter-axum/src/lib.rs` | Add `pub mod secret_store`, exports | +| `crates/edgezero-adapter-axum/src/service.rs` | Add `secret_handle` field, `with_secret_handle()` method | +| `crates/edgezero-adapter-axum/src/dev_server.rs` | Add `secret_handle_from_manifest()`, wire into `run_app` and `serve_with_listener_and_kv_handle` → renamed `serve_with_listener_and_stores` | +| `crates/edgezero-cli/src/main.rs` | Add secret store binding info logging in `handle_build` | + +--- + +## Design Context + +### Relationship to `[[environment.secrets]]` + +The manifest already has `[[environment.secrets]]` — **these are completely different concepts**: + +| Concept | Section | When it runs | Purpose | +|---------|---------|-------------|---------| +| Build-time secret declaration | `[[environment.secrets]]` | `edgezero build` / `edgezero deploy` | Declares which env vars must be present for CLI commands. Aborts with an error if any are missing. Not related to request handling. | +| Runtime secret store | `[stores.secrets]` | Every HTTP request | Registers a platform-specific vault (Fastly SecretStore, Cloudflare Worker Secrets, env vars) for handlers to read during request processing. | + +Both coexist. An app may use `[[environment.secrets]]` to ensure that `API_KEY` is set for the build pipeline, and `[stores.secrets]` so that a handler can call `secrets.require_str("API_KEY").await` at request time. + +### Error model + +A missing secret (`SecretError::NotFound`) is a **server-side misconfiguration**, not a client error. The `From for EdgeError` impl maps `NotFound` to `EdgeError::internal` (HTTP 500), not `EdgeError::not_found` (404). Client-visible 404s would leak information about server configuration and would be incorrect HTTP semantics. + +### Manifest `name` semantics per adapter + +The `[stores.secrets] name` field has different meaning per adapter — this asymmetry is intentional: + +| Adapter | `name` usage | +|---------|-------------| +| **Fastly** | Name of the `fastly::secret_store::SecretStore` resource declared in `fastly.toml`. Required for `SecretStore::open(name)` to succeed. | +| **Cloudflare** | Informational only. Cloudflare Worker Secrets are individually bound as separate `[vars]` entries in `wrangler.toml`; there is no namespace concept. The `_secrets_required` flag has no runtime effect since `CloudflareSecretStore::from_env(env)` always succeeds. | +| **Axum (dev)** | Informational only. Secrets are read from env vars by name. `EnvSecretStore` is always successfully constructed. | + +The `secrets_required` flag is only meaningful for Fastly (where `SecretStore::open()` can fail). On Cloudflare and Axum, constructing the store always succeeds and individual secret misses are surfaced at access time via `SecretError::NotFound`. + +--- + +## Task 1: Core `SecretStore` trait and `SecretHandle` + +Closes #60. + +**Files:** +- Create: `crates/edgezero-core/src/secret_store.rs` +- Modify: `crates/edgezero-core/src/lib.rs` + +- [ ] **Step 1.1: Declare the module in `crates/edgezero-core/src/lib.rs` first** + +Add `pub mod secret_store;` after the `key_value_store` line (before adding the implementation file): + +```rust +pub mod secret_store; +``` + +Also add to the `pub use` block: +```rust +pub use secret_store::{SecretError, SecretHandle, SecretStore}; +``` + +This makes the compiler look for `secret_store.rs` — which doesn't exist yet — so the next step's tests will produce the expected error. + +- [ ] **Step 1.2: Run to confirm compile error (module file missing)** + +```bash +cargo build -p edgezero-core 2>&1 | head -5 +``` + +Expected: `error[E0583]: file not found for module 'secret_store'` + +- [ ] **Step 1.3: Write failing tests for `SecretError` and `SecretHandle` validation** + +Create `crates/edgezero-core/src/secret_store.rs` containing **only** the test module (no implementation yet): + +```rust +// In crates/edgezero-core/src/secret_store.rs + +#[cfg(test)] +mod tests { + use super::*; + use bytes::Bytes; + use futures::executor::block_on; + + // Test-only in-memory store + use std::collections::HashMap; + struct SimpleStore(HashMap); + #[async_trait(?Send)] + impl SecretStore for SimpleStore { + async fn get_bytes(&self, name: &str) -> Result, SecretError> { + Ok(self.0.get(name).cloned()) + } + } + + fn store_with(entries: &[(&str, &str)]) -> SecretHandle { + let map: HashMap = entries + .iter() + .map(|(k, v)| (k.to_string(), Bytes::from(v.to_string()))) + .collect(); + SecretHandle::new(std::sync::Arc::new(SimpleStore(map))) + } + + #[test] + fn validate_name_rejects_empty() { + block_on(async { + let h = store_with(&[]); + let err = h.get_bytes("").await.unwrap_err(); + assert!(matches!(err, SecretError::Validation(_))); + }); + } + + #[test] + fn validate_name_rejects_control_chars() { + block_on(async { + let h = store_with(&[]); + let err = h.get_bytes("bad\x00name").await.unwrap_err(); + assert!(matches!(err, SecretError::Validation(_))); + }); + } + + #[test] + fn validate_name_rejects_oversized() { + block_on(async { + let h = store_with(&[]); + let name = "x".repeat(SecretHandle::MAX_NAME_LEN + 1); + let err = h.get_bytes(&name).await.unwrap_err(); + assert!(matches!(err, SecretError::Validation(_))); + }); + } + + #[test] + fn get_bytes_returns_none_for_missing() { + block_on(async { + let h = store_with(&[]); + assert_eq!(h.get_bytes("missing").await.unwrap(), None); + }); + } + + #[test] + fn get_bytes_returns_value_for_existing() { + block_on(async { + let h = store_with(&[("api_key", "secret123")]); + assert_eq!( + h.get_bytes("api_key").await.unwrap(), + Some(Bytes::from("secret123")) + ); + }); + } + + #[test] + fn get_str_decodes_utf8() { + block_on(async { + let h = store_with(&[("token", "bearer xyz")]); + assert_eq!( + h.get_str("token").await.unwrap(), + Some("bearer xyz".to_string()) + ); + }); + } + + #[test] + fn require_bytes_fails_for_missing() { + block_on(async { + let h = store_with(&[]); + let err = h.require_bytes("missing").await.unwrap_err(); + assert!(matches!(err, SecretError::NotFound { .. })); + }); + } + + #[test] + fn require_str_returns_value() { + block_on(async { + let h = store_with(&[("key", "value")]); + assert_eq!(h.require_str("key").await.unwrap(), "value"); + }); + } +} +``` + +- [ ] **Step 1.4: Run tests — verify they fail (symbols not defined yet)** + +```bash +cargo test -p edgezero-core 2>&1 | head -20 +``` + +Expected: compile error `use of undeclared type 'SecretStore'` or similar — the test body references symbols not yet defined. + +- [ ] **Step 1.5: Prepend the full implementation to `secret_store.rs`** + +The `#[cfg(test)]` module from Step 1.3 must stay at the bottom of the file — do not delete it. **Insert everything below before the existing `#[cfg(test)] mod tests {` line.** The final file will be: module doc + use declarations + impl code + (unchanged) test module from Step 1.3. + +```rust +//! Provider-neutral secret store abstraction. +//! +//! # Architecture +//! +//! ```text +//! Handler code SecretHandle (get_str / require_str) +//! │ │ +//! └── Secrets extractor ─►│ UTF-8 / bytes layer +//! │ +//! Arc (object-safe, Bytes) +//! │ +//! ┌──────────────┼──────────────┐ +//! ▼ ▼ ▼ +//! EnvSecretStore FastlySecretStore CloudflareSecretStore +//! ``` +//! +//! Secrets are read-only — this API only retrieves values, +//! it never writes or deletes them. Provisioning secrets is the +//! responsibility of each platform's deployment toolchain. + +use std::fmt; +use std::sync::Arc; + +use async_trait::async_trait; +use bytes::Bytes; + +use crate::error::EdgeError; + +// --------------------------------------------------------------------------- +// Error +// --------------------------------------------------------------------------- + +/// Errors returned by secret store operations. +#[derive(Debug, thiserror::Error)] +pub enum SecretError { + /// The requested secret was not found. + #[error("secret not found: {name}")] + NotFound { name: String }, + + /// The secret store backend is temporarily unavailable. + #[error("secret store unavailable")] + Unavailable, + + /// A validation error (e.g., invalid secret name). + #[error("validation error: {0}")] + Validation(String), + + /// A general internal error. + #[error("secret store error: {0}")] + Internal(#[from] anyhow::Error), +} + +impl From for EdgeError { + fn from(err: SecretError) -> Self { + match err { + // NotFound = server misconfiguration, never a client 404. + // A missing API key means the platform isn't set up correctly, + // not that the request was invalid. + SecretError::NotFound { name } => EdgeError::internal(anyhow::anyhow!( + "required secret '{}' is not configured -- check platform secret store bindings", + name + )), + SecretError::Unavailable => { + EdgeError::service_unavailable("secret store unavailable") + } + // Validation errors are programming errors (bad secret name in code), + // not client errors. + SecretError::Validation(e) => { + EdgeError::internal(anyhow::anyhow!("secret name validation error: {e}")) + } + SecretError::Internal(e) => EdgeError::internal(e), + } + } +} + +// --------------------------------------------------------------------------- +// Trait +// --------------------------------------------------------------------------- + +/// Object-safe interface for secret store backends. +/// +/// All methods take `&self` — backends handle their own access model. +/// +/// This trait is always called through [`SecretHandle`], which validates +/// inputs before delegating here. Implementations may therefore assume: +/// - Names are non-empty and within [`SecretHandle::MAX_NAME_LEN`] +/// - Names contain no control characters +#[async_trait(?Send)] +pub trait SecretStore: Send + Sync { + /// Retrieve a secret as raw bytes. Returns `Ok(None)` if not found. + async fn get_bytes(&self, name: &str) -> Result, SecretError>; +} + +// --------------------------------------------------------------------------- +// Test-only no-op store +// --------------------------------------------------------------------------- + +/// A no-op [`SecretStore`] for tests that only need a [`SecretHandle`] to exist. +/// +/// All reads return `None`. +/// +/// Available in `#[cfg(test)]` builds and via the `test-utils` feature: +/// ```toml +/// [dev-dependencies] +/// edgezero-core = { path = "...", features = ["test-utils"] } +/// ``` +#[cfg(any(test, feature = "test-utils"))] +pub struct NoopSecretStore; + +#[cfg(any(test, feature = "test-utils"))] +#[async_trait(?Send)] +impl SecretStore for NoopSecretStore { + async fn get_bytes(&self, _name: &str) -> Result, SecretError> { + Ok(None) + } +} + +// --------------------------------------------------------------------------- +// In-memory store (test-utils) +// --------------------------------------------------------------------------- + +/// An in-memory [`SecretStore`] pre-populated with known secrets. +/// +/// Useful for contract tests and unit tests that need deterministic secret values. +/// +/// Available in `#[cfg(test)]` builds and via the `test-utils` feature. +#[cfg(any(test, feature = "test-utils"))] +pub struct InMemorySecretStore { + secrets: std::collections::HashMap, +} + +#[cfg(any(test, feature = "test-utils"))] +impl InMemorySecretStore { + /// Create a new in-memory store with the given entries. + /// + /// # Example + /// ```rust,ignore + /// let store = InMemorySecretStore::new([ + /// ("api_key", Bytes::from("secret123")), + /// ("signing_key", Bytes::from("keyvalue")), + /// ]); + /// ``` + pub fn new( + entries: impl IntoIterator, impl Into)>, + ) -> Self { + Self { + secrets: entries + .into_iter() + .map(|(k, v)| (k.into(), v.into())) + .collect(), + } + } +} + +#[cfg(any(test, feature = "test-utils"))] +#[async_trait(?Send)] +impl SecretStore for InMemorySecretStore { + async fn get_bytes(&self, name: &str) -> Result, SecretError> { + Ok(self.secrets.get(name).cloned()) + } +} + +// --------------------------------------------------------------------------- +// Handle +// --------------------------------------------------------------------------- + +/// A cloneable, ergonomic handle to a secret store. +/// +/// Provides typed helpers (`get_str`, `require_bytes`, `require_str`) +/// while delegating to the object-safe `SecretStore` trait underneath. +/// +/// # Example +/// +/// ```ignore +/// #[action] +/// async fn handler(Secrets(secrets): Secrets) -> Result { +/// let api_key: String = secrets.require_str("API_KEY").await?; +/// // use api_key ... +/// } +/// ``` +#[derive(Clone)] +pub struct SecretHandle { + store: Arc, +} + +impl fmt::Debug for SecretHandle { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("SecretHandle").finish_non_exhaustive() + } +} + +impl SecretHandle { + /// Maximum secret name length in bytes. + pub const MAX_NAME_LEN: usize = 512; + + /// Create a new handle wrapping a secret store implementation. + pub fn new(store: Arc) -> Self { + Self { store } + } + + fn validate_name(name: &str) -> Result<(), SecretError> { + if name.is_empty() { + return Err(SecretError::Validation("secret name cannot be empty".to_string())); + } + if name.len() > Self::MAX_NAME_LEN { + return Err(SecretError::Validation(format!( + "secret name length {} exceeds limit of {} bytes", + name.len(), + Self::MAX_NAME_LEN + ))); + } + if name.chars().any(|c| c.is_control()) { + return Err(SecretError::Validation( + "secret name contains invalid control characters".to_string(), + )); + } + Ok(()) + } + + /// Retrieve a secret as raw bytes. Returns `Ok(None)` if not found. + pub async fn get_bytes(&self, name: &str) -> Result, SecretError> { + Self::validate_name(name)?; + self.store.get_bytes(name).await + } + + /// Retrieve a secret as a UTF-8 string. Returns `Ok(None)` if not found. + pub async fn get_str(&self, name: &str) -> Result, SecretError> { + let bytes = self.get_bytes(name).await?; + bytes + .map(|b| { + String::from_utf8(b.to_vec()).map_err(|e| { + SecretError::Internal(anyhow::anyhow!( + "secret '{}' is not valid UTF-8: {e}", + name + )) + }) + }) + .transpose() + } + + /// Retrieve a secret as raw bytes. Returns `SecretError::NotFound` if absent. + pub async fn require_bytes(&self, name: &str) -> Result { + self.get_bytes(name) + .await? + .ok_or_else(|| SecretError::NotFound { name: name.to_string() }) + } + + /// Retrieve a secret as a UTF-8 string. Returns `SecretError::NotFound` if absent. + pub async fn require_str(&self, name: &str) -> Result { + let bytes = self.require_bytes(name).await?; + String::from_utf8(bytes.to_vec()).map_err(|e| { + SecretError::Internal(anyhow::anyhow!( + "secret '{}' is not valid UTF-8: {e}", + name + )) + }) + } +} + +// --------------------------------------------------------------------------- +// Contract test macro +// --------------------------------------------------------------------------- + +/// Generate a suite of contract tests for any [`SecretStore`] implementation. +/// +/// The factory expression must produce a store pre-populated with: +/// - `"contract_key"` → `Bytes::from("contract_value")` +/// - `"contract_key_2"` → `Bytes::from("another_value")` +/// - `"missing_key"` must NOT be present. +/// +/// # Example +/// +/// ```rust,ignore +/// edgezero_core::secret_store_contract_tests!(in_memory_contract, { +/// InMemorySecretStore::new([ +/// ("contract_key", Bytes::from("contract_value")), +/// ("contract_key_2", Bytes::from("another_value")), +/// ]) +/// }); +/// ``` +#[macro_export] +macro_rules! secret_store_contract_tests { + ($mod_name:ident, $factory:expr) => { + mod $mod_name { + use super::*; + use bytes::Bytes; + use $crate::secret_store::SecretStore; + + fn run(f: F) -> F::Output { + futures::executor::block_on(f) + } + + #[test] + fn contract_get_existing_returns_bytes() { + let store = $factory; + run(async { + let result = store.get_bytes("contract_key").await.unwrap(); + assert_eq!(result, Some(Bytes::from("contract_value"))); + }); + } + + #[test] + fn contract_get_second_key_returns_bytes() { + let store = $factory; + run(async { + let result = store.get_bytes("contract_key_2").await.unwrap(); + assert_eq!(result, Some(Bytes::from("another_value"))); + }); + } + + #[test] + fn contract_get_missing_returns_none() { + let store = $factory; + run(async { + let result = store.get_bytes("missing_key").await.unwrap(); + assert!(result.is_none()); + }); + } + } + }; +} + +// ← Stop here. The #[cfg(test)] mod tests { ... } block from Step 1.3 already +// sits below this point in the file and must be preserved as-is. +``` + +The `#[cfg(test)]` test module you created in Step 1.3 remains unchanged at the bottom of the file. + +- [ ] **Step 1.6: Run tests — verify they pass** + +```bash +cargo test -p edgezero-core 2>&1 | tail -20 +``` + +Expected: all tests pass including the new `secret_store::tests::*` tests. + +- [ ] **Step 1.7: Run clippy** + +```bash +cargo clippy -p edgezero-core --all-features -- -D warnings +``` + +Expected: no warnings. + +- [ ] **Step 1.8: Commit** + +```bash +git add crates/edgezero-core/src/secret_store.rs crates/edgezero-core/src/lib.rs +git commit -m "feat(core): add SecretStore trait, SecretHandle, and contract test macro" +``` + +--- + +## Task 2: `RequestContext::secret_handle()` + `Secrets` extractor + +Closes #64. + +**Files:** +- Modify: `crates/edgezero-core/src/context.rs` +- Modify: `crates/edgezero-core/src/extractor.rs` + +- [ ] **Step 2.1: Write failing test in `context.rs`** + +Add to `crates/edgezero-core/src/context.rs` `#[cfg(test)]` block: + +```rust +#[test] +fn secret_handle_is_retrieved_when_present() { + use crate::secret_store::{NoopSecretStore, SecretHandle}; + use std::sync::Arc; + + let mut request = request_builder() + .method(Method::GET) + .uri("/secrets") + .body(Body::empty()) + .expect("request"); + request + .extensions_mut() + .insert(SecretHandle::new(Arc::new(NoopSecretStore))); + + let ctx = RequestContext::new(request, PathParams::default()); + assert!(ctx.secret_handle().is_some()); +} + +#[test] +fn secret_handle_returns_none_when_absent() { + let ctx = ctx("/test", Body::empty(), PathParams::default()); + assert!(ctx.secret_handle().is_none()); +} +``` + +- [ ] **Step 2.2: Run tests — verify they fail** + +```bash +cargo test -p edgezero-core context::tests 2>&1 | grep -E "FAILED|error" +``` + +Expected: error `no method named 'secret_handle'` + +- [ ] **Step 2.3: Add `secret_handle()` method to `RequestContext`** + +In `crates/edgezero-core/src/context.rs`, add import at top: +```rust +use crate::secret_store::SecretHandle; +``` + +Add method after `kv_handle()`: +```rust +pub fn secret_handle(&self) -> Option { + self.request.extensions().get::().cloned() +} +``` + +- [ ] **Step 2.4: Write failing test for `Secrets` extractor in `extractor.rs`** + +Add to `crates/edgezero-core/src/extractor.rs` `#[cfg(test)]` block: + +```rust +#[test] +fn secrets_extractor_returns_handle_when_present() { + use crate::secret_store::{NoopSecretStore, SecretHandle}; + use std::sync::Arc; + + let mut request = request_builder() + .method(Method::GET) + .uri("/secrets") + .body(Body::empty()) + .expect("request"); + request + .extensions_mut() + .insert(SecretHandle::new(Arc::new(NoopSecretStore))); + let ctx = RequestContext::new(request, PathParams::default()); + let result = block_on(Secrets::from_request(&ctx)); + assert!(result.is_ok()); +} + +#[test] +fn secrets_extractor_errors_when_absent() { + let request = request_builder() + .method(Method::GET) + .uri("/secrets") + .body(Body::empty()) + .expect("request"); + let ctx = RequestContext::new(request, PathParams::default()); + let err = block_on(Secrets::from_request(&ctx)).unwrap_err(); + assert_eq!(err.status(), StatusCode::INTERNAL_SERVER_ERROR); +} +``` + +- [ ] **Step 2.5: Add `Secrets` extractor to `extractor.rs`** + +After the `Kv` extractor block (around line 449), add: + +```rust +/// Extracts the [`SecretHandle`] from the request context. +/// +/// Returns `EdgeError::Internal` if no secret store was configured for this request. +/// +/// # Example +/// ```ignore +/// #[action] +/// pub async fn handler(Secrets(secrets): Secrets) -> Result { +/// let key = secrets.require_str("API_KEY").await.map_err(EdgeError::from)?; +/// // use key ... +/// } +/// ``` +#[derive(Debug)] +pub struct Secrets(pub crate::secret_store::SecretHandle); + +#[async_trait(?Send)] +impl FromRequest for Secrets { + async fn from_request(ctx: &RequestContext) -> Result { + ctx.secret_handle().map(Secrets).ok_or_else(|| { + EdgeError::internal(anyhow::anyhow!( + "no secret store configured -- check [stores.secrets] in edgezero.toml and platform bindings" + )) + }) + } +} + +impl std::ops::Deref for Secrets { + type Target = crate::secret_store::SecretHandle; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl std::ops::DerefMut for Secrets { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.0 + } +} + +impl Secrets { + pub fn into_inner(self) -> crate::secret_store::SecretHandle { + self.0 + } +} +``` + +- [ ] **Step 2.6: Run tests** + +```bash +cargo test -p edgezero-core 2>&1 | tail -20 +``` + +Expected: all tests pass. + +- [ ] **Step 2.7: Commit** + +```bash +git add crates/edgezero-core/src/context.rs crates/edgezero-core/src/extractor.rs +git commit -m "feat(core): add secret_handle() to RequestContext and Secrets extractor" +``` + +--- + +## Task 3: Manifest schema for `[stores.secrets]` + +Closes #65. + +**Files:** +- Modify: `crates/edgezero-core/src/manifest.rs` + +- [ ] **Step 3.1: Write failing tests for manifest parsing** + +Add to `manifest.rs` `#[cfg(test)]` block (search for the existing test module): + +```rust +#[test] +fn secret_store_name_defaults_to_constant_when_absent() { + let manifest = ManifestLoader::load_from_str("[app]\nname = \"x\"\n"); + assert_eq!( + manifest.manifest().secret_store_name("fastly"), + DEFAULT_SECRET_STORE_NAME + ); +} + +#[test] +fn secret_store_name_uses_global_name_when_declared() { + let manifest = ManifestLoader::load_from_str( + "[stores.secrets]\nname = \"MY_SECRETS\"\n", + ); + assert_eq!(manifest.manifest().secret_store_name("fastly"), "MY_SECRETS"); + assert_eq!( + manifest.manifest().secret_store_name("cloudflare"), + "MY_SECRETS" + ); +} + +#[test] +fn secret_store_name_uses_per_adapter_override() { + let manifest = ManifestLoader::load_from_str( + "[stores.secrets]\nname = \"MY_SECRETS\"\n\ + [stores.secrets.adapters.fastly]\nname = \"FASTLY_STORE\"\n", + ); + assert_eq!( + manifest.manifest().secret_store_name("fastly"), + "FASTLY_STORE" + ); + assert_eq!( + manifest.manifest().secret_store_name("cloudflare"), + "MY_SECRETS" + ); +} + +#[test] +fn secrets_required_is_false_when_absent() { + let manifest = ManifestLoader::load_from_str("[app]\nname = \"x\"\n"); + assert!(manifest.manifest().stores.secrets.is_none()); +} + +#[test] +fn secrets_required_is_true_when_declared() { + let manifest = ManifestLoader::load_from_str( + "[stores.secrets]\nname = \"MY_SECRETS\"\n", + ); + assert!(manifest.manifest().stores.secrets.is_some()); +} +``` + +- [ ] **Step 3.2: Run tests — verify they fail** + +```bash +cargo test -p edgezero-core manifest 2>&1 | grep -E "FAILED|error" +``` + +Expected: errors about `secret_store_name` and `DEFAULT_SECRET_STORE_NAME` not existing. + +- [ ] **Step 3.3: Add manifest structs and `secret_store_name()` method** + +In `crates/edgezero-core/src/manifest.rs`: + +After the `DEFAULT_KV_STORE_NAME` and `default_kv_name` declarations, add: + +```rust +/// Default secret store / binding name used when `[stores.secrets]` is omitted. +pub const DEFAULT_SECRET_STORE_NAME: &str = "EDGEZERO_SECRETS"; + +fn default_secret_name() -> String { + DEFAULT_SECRET_STORE_NAME.to_string() +} +``` + +Update `ManifestStores` struct to add the `secrets` field: + +```rust +#[derive(Debug, Default, Deserialize, Validate)] +pub struct ManifestStores { + /// KV store configuration. + #[serde(default)] + #[validate(nested)] + pub kv: Option, + + /// Secret store configuration. When absent, the default + /// name `EDGEZERO_SECRETS` is used. + #[serde(default)] + #[validate(nested)] + pub secrets: Option, +} +``` + +Add after `ManifestKvAdapterConfig`: + +```rust +/// Global secret store configuration. +#[derive(Debug, Deserialize, Validate)] +pub struct ManifestSecretsConfig { + /// Store / binding name (default: `"EDGEZERO_SECRETS"`). + #[serde(default = "default_secret_name")] + #[validate(length(min = 1))] + pub name: String, + + /// Per-adapter name overrides. + #[serde(default)] + #[validate(nested)] + pub adapters: BTreeMap, +} + +/// Per-adapter secret store name override. +#[derive(Debug, Deserialize, Validate)] +pub struct ManifestSecretsAdapterConfig { + #[validate(length(min = 1))] + pub name: String, +} +``` + +Add `secret_store_name()` method to `impl Manifest`, after `kv_store_name()`: + +```rust +/// Returns the secret store name for a given adapter. +/// +/// Resolution order: +/// 1. Per-adapter override (`[stores.secrets.adapters.]`) +/// 2. Global name (`[stores.secrets] name = "..."`) +/// 3. Default: `"EDGEZERO_SECRETS"` +pub fn secret_store_name(&self, adapter: &str) -> &str { + match &self.stores.secrets { + Some(secrets) => { + let adapter_lower = adapter.to_ascii_lowercase(); + if let Some(adapter_cfg) = secrets + .adapters + .iter() + .find(|(k, _)| k.eq_ignore_ascii_case(&adapter_lower)) + { + return &adapter_cfg.1.name; + } + &secrets.name + } + None => DEFAULT_SECRET_STORE_NAME, + } +} +``` + +- [ ] **Step 3.4: Run tests** + +```bash +cargo test -p edgezero-core 2>&1 | tail -20 +``` + +Expected: all tests pass including the new manifest tests. + +- [ ] **Step 3.5: Commit** + +```bash +git add crates/edgezero-core/src/manifest.rs +git commit -m "feat(core): add [stores.secrets] manifest schema and secret_store_name()" +``` + +--- + +## Task 4: Fastly secret adapter + +Closes #61. + +**Files:** +- Create: `crates/edgezero-adapter-fastly/src/secret_store.rs` +- Modify: `crates/edgezero-adapter-fastly/src/lib.rs` +- Modify: `crates/edgezero-adapter-fastly/src/request.rs` + +- [ ] **Step 4.1: Create `FastlySecretStore`** + +Create `crates/edgezero-adapter-fastly/src/secret_store.rs`: + +```rust +//! Fastly SecretStore adapter. +//! +//! Wraps `fastly::secret_store::SecretStore` to implement +//! `edgezero_core::secret_store::SecretStore`. + +#[cfg(feature = "fastly")] +use async_trait::async_trait; +#[cfg(feature = "fastly")] +use bytes::Bytes; +#[cfg(feature = "fastly")] +use edgezero_core::secret_store::{SecretError, SecretStore}; + +/// Secret store backed by Fastly's SecretStore API. +#[cfg(feature = "fastly")] +pub struct FastlySecretStore { + store: fastly::secret_store::SecretStore, +} + +#[cfg(feature = "fastly")] +impl FastlySecretStore { + /// Open a Fastly SecretStore by name. + /// + /// Returns `SecretError::Internal` if the store does not exist or cannot + /// be opened. Unlike `KVStore::open`, the Fastly SecretStore API returns + /// `Result` (not `Result, _>`), so there + /// is no `ok_or` unwrap here. + pub fn open(name: &str) -> Result { + let store = fastly::secret_store::SecretStore::open(name) + .map_err(|e| { + SecretError::Internal(anyhow::anyhow!("failed to open secret store '{}': {e}", name)) + })?; + Ok(Self { store }) + } +} + +#[cfg(feature = "fastly")] +#[async_trait(?Send)] +impl SecretStore for FastlySecretStore { + async fn get_bytes(&self, name: &str) -> Result, SecretError> { + match self.store.get(name) { + Some(secret) => Ok(Some(secret.plaintext())), + None => Ok(None), + } + } +} +``` + +- [ ] **Step 4.2: Add `dispatch_with_secrets` and `dispatch_with_kv_and_secrets` to `request.rs`** + +In `crates/edgezero-adapter-fastly/src/request.rs`, add imports: +```rust +use edgezero_core::secret_store::SecretHandle; +``` + +Add after `dispatch_with_kv`: + +```rust +/// Dispatch a Fastly request with a secret store attached. +pub fn dispatch_with_secrets( + app: &App, + req: FastlyRequest, + secret_store_name: &str, + secrets_required: bool, +) -> Result { + let mut core_request = into_core_request(req).map_err(map_edge_error)?; + + match crate::secret_store::FastlySecretStore::open(secret_store_name) { + Ok(store) => { + let handle = SecretHandle::new(std::sync::Arc::new(store)); + core_request.extensions_mut().insert(handle); + } + Err(e) => { + if secrets_required { + return Err(FastlyError::msg(format!( + "secret store '{}' is explicitly configured but could not be opened: {}", + secret_store_name, e + ))); + } + warn_missing_secret_store_once(secret_store_name, &e); + } + } + + let response = executor::block_on(app.router().oneshot(core_request)); + from_core_response(response).map_err(map_edge_error) +} + +/// Dispatch a Fastly request with both KV and secret stores attached. +pub fn dispatch_with_kv_and_secrets( + app: &App, + req: FastlyRequest, + kv_store_name: &str, + kv_required: bool, + secret_store_name: &str, + secrets_required: bool, +) -> Result { + let mut core_request = into_core_request(req).map_err(map_edge_error)?; + + match FastlyKvStore::open(kv_store_name) { + Ok(store) => { + let handle = KvHandle::new(std::sync::Arc::new(store)); + core_request.extensions_mut().insert(handle); + } + Err(e) => { + if kv_required { + return Err(FastlyError::msg(format!( + "KV store '{}' is explicitly configured but could not be opened: {}", + kv_store_name, e + ))); + } + warn_missing_kv_store_once(kv_store_name, &e); + } + } + + match crate::secret_store::FastlySecretStore::open(secret_store_name) { + Ok(store) => { + let handle = SecretHandle::new(std::sync::Arc::new(store)); + core_request.extensions_mut().insert(handle); + } + Err(e) => { + if secrets_required { + return Err(FastlyError::msg(format!( + "secret store '{}' is explicitly configured but could not be opened: {}", + secret_store_name, e + ))); + } + warn_missing_secret_store_once(secret_store_name, &e); + } + } + + let response = executor::block_on(app.router().oneshot(core_request)); + from_core_response(response).map_err(map_edge_error) +} + +fn warn_missing_secret_store_once(name: &str, error: &impl std::fmt::Display) { + static WARNED: OnceLock>> = OnceLock::new(); + let warned = WARNED.get_or_init(|| Mutex::new(BTreeSet::new())); + match warned.lock() { + Ok(mut warned) => { + if !warned.insert(name.to_string()) { + return; + } + log::warn!("secret store '{}' not available: {}", name, error); + } + Err(_) => { + log::warn!("secret store '{}' not available: {}", name, error); + } + } +} +``` + +- [ ] **Step 4.3: Update `run_app` and `run_app_with_logging` in `lib.rs` to handle secrets** + +In `crates/edgezero-adapter-fastly/src/lib.rs`, update `run_app`: + +```rust +#[cfg(feature = "fastly")] +pub fn run_app( + manifest_src: &str, + req: fastly::Request, +) -> Result { + let manifest_loader = edgezero_core::manifest::ManifestLoader::load_from_str(manifest_src); + let manifest = manifest_loader.manifest(); + let logging = manifest.logging_or_default("fastly"); + let kv_name = manifest.kv_store_name("fastly").to_string(); + let kv_required = manifest.stores.kv.is_some(); + let secret_name = manifest.secret_store_name("fastly").to_string(); + let secrets_required = manifest.stores.secrets.is_some(); + run_app_with_logging::( + logging.into(), + req, + &kv_name, + kv_required, + &secret_name, + secrets_required, + ) +} +``` + +Update `run_app_with_logging` signature and body: + +```rust +#[cfg(feature = "fastly")] +pub(crate) fn run_app_with_logging( + logging: FastlyLogging, + req: fastly::Request, + kv_store_name: &str, + kv_required: bool, + secret_store_name: &str, + secrets_required: bool, +) -> Result { + if logging.use_fastly_logger { + let endpoint = logging.endpoint.as_deref().unwrap_or("stdout"); + init_logger(endpoint, logging.level, logging.echo_stdout).expect("init fastly logger"); + } + let app = A::build_app(); + dispatch_with_kv_and_secrets( + &app, + req, + kv_store_name, + kv_required, + secret_store_name, + secrets_required, + ) +} +``` + +Add `pub mod secret_store` and export `dispatch_with_secrets`, `dispatch_with_kv_and_secrets` to the `pub use request::` line. + +Update `lib.rs` exports: +```rust +#[cfg(feature = "fastly")] +pub mod secret_store; +// ... +#[cfg(feature = "fastly")] +pub use request::{ + dispatch, dispatch_with_kv, dispatch_with_kv_and_secrets, dispatch_with_secrets, + into_core_request, DEFAULT_KV_STORE_NAME, +}; +``` + +Also add: +```rust +#[cfg(feature = "fastly")] +pub use secret_store::FastlySecretStore; +``` + +- [ ] **Step 4.4: Verify existing tests in `lib.rs` still pass** + +The only test in `lib.rs` is `fastly_logging_from_manifest_converts_defaults`, which tests `FastlyLogging::from(...)` and does **not** call `run_app_with_logging` at all. No test changes are needed here. + +- [ ] **Step 4.5: Build check (WASM — compile only)** + +```bash +cargo check -p edgezero-adapter-fastly --features fastly --target wasm32-wasip1 2>&1 | tail -10 +``` + +Expected: no errors. + +- [ ] **Step 4.6: Commit** + +```bash +git add crates/edgezero-adapter-fastly/src/secret_store.rs \ + crates/edgezero-adapter-fastly/src/request.rs \ + crates/edgezero-adapter-fastly/src/lib.rs +git commit -m "feat(fastly): add FastlySecretStore adapter and dispatch_with_secrets" +``` + +--- + +## Task 5: Cloudflare secret adapter + +Closes #62. + +**Files:** +- Create: `crates/edgezero-adapter-cloudflare/src/secret_store.rs` +- Modify: `crates/edgezero-adapter-cloudflare/src/lib.rs` +- Modify: `crates/edgezero-adapter-cloudflare/src/request.rs` + +- [ ] **Step 5.1: Create `CloudflareSecretStore`** + +Create `crates/edgezero-adapter-cloudflare/src/secret_store.rs`: + +```rust +//! Cloudflare Workers secret adapter. +//! +//! Reads secrets from `worker::Env::secret()`. Each call to `get_bytes(name)` +//! invokes `env.secret(name)` to retrieve the value. The `Env` is cloned at +//! dispatch time to outlive `into_core_request`'s ownership of the original. +//! +//! Note: Cloudflare Workers Secrets have no namespace concept — each secret +//! is an individual `[vars]` / Secrets binding in `wrangler.toml`. The +//! `[stores.secrets] name` in `edgezero.toml` is used only for Fastly; +//! Cloudflare accesses all secrets via this adapter regardless of name. + +#[cfg(all(feature = "cloudflare", target_arch = "wasm32"))] +use async_trait::async_trait; +#[cfg(all(feature = "cloudflare", target_arch = "wasm32"))] +use bytes::Bytes; +#[cfg(all(feature = "cloudflare", target_arch = "wasm32"))] +use edgezero_core::secret_store::{SecretError, SecretStore}; + +/// Secret store backed by Cloudflare Workers `Env`. +/// +/// Reads secrets via `env.secret(name)`. Clones the `Env` handle at dispatch +/// time so secrets remain accessible throughout the request lifetime. +#[cfg(all(feature = "cloudflare", target_arch = "wasm32"))] +pub struct CloudflareSecretStore { + env: worker::Env, +} + +#[cfg(all(feature = "cloudflare", target_arch = "wasm32"))] +impl CloudflareSecretStore { + /// Create a secret store from a cloned `Env`. + pub fn from_env(env: worker::Env) -> Self { + Self { env } + } +} + +#[cfg(all(feature = "cloudflare", target_arch = "wasm32"))] +#[async_trait(?Send)] +impl SecretStore for CloudflareSecretStore { + async fn get_bytes(&self, name: &str) -> Result, SecretError> { + match self.env.secret(name) { + Ok(secret) => { + let value = secret.to_string(); + Ok(Some(Bytes::from(value.into_bytes()))) + } + // Workers returns an error when a secret binding is absent + Err(_) => Ok(None), + } + } +} +``` + +- [ ] **Step 5.2: Add `dispatch_with_secrets` and `dispatch_with_kv_and_secrets` to `request.rs`** + +In `crates/edgezero-adapter-cloudflare/src/request.rs`, add import: +```rust +use edgezero_core::secret_store::SecretHandle; +``` + +Add after `dispatch_with_kv`: + +```rust +/// Dispatch a Cloudflare Worker request with a secret store attached. +/// +/// Note: `_secrets_required` is intentionally unused. Cloudflare Worker Secrets +/// are individually bound in `wrangler.toml`; there is no namespace to "open" +/// that could fail. The store is always successfully constructed from `Env`. +/// Individual missing secrets surface as `SecretError::NotFound` at access time. +pub async fn dispatch_with_secrets( + app: &App, + req: CfRequest, + env: Env, + ctx: Context, + _secrets_required: bool, +) -> Result { + // Clone env before consuming it in into_core_request. + // Env wraps a JsValue reference; cloning increments the JS reference count. + let secret_store = + crate::secret_store::CloudflareSecretStore::from_env(env.clone()); + let secret_handle = SecretHandle::new(std::sync::Arc::new(secret_store)); + + let mut core_request = into_core_request(req, env, ctx) + .await + .map_err(edge_error_to_worker)?; + core_request.extensions_mut().insert(secret_handle); + + let svc = app.router().clone(); + let response = svc.oneshot(core_request).await; + from_core_response(response).map_err(edge_error_to_worker) +} + +/// Dispatch a Cloudflare Worker request with both KV and secret stores attached. +pub async fn dispatch_with_kv_and_secrets( + app: &App, + req: CfRequest, + env: Env, + ctx: Context, + kv_binding: &str, + kv_required: bool, + _secret_binding: &str, // unused: CF secrets have no namespace concept + _secrets_required: bool, // unused: CloudflareSecretStore always constructs OK +) -> Result { + // Open KV by borrowing env + let kv_handle = match crate::key_value_store::CloudflareKvStore::from_env(&env, kv_binding) { + Ok(store) => Some(KvHandle::new(std::sync::Arc::new(store))), + Err(e) => { + if kv_required { + return Err(WorkerError::RustError(format!( + "KV binding '{}' is explicitly configured but could not be opened: {}", + kv_binding, e + ))); + } + warn_missing_kv_binding_once(kv_binding, &e); + None + } + }; + + // Clone env for secrets before consuming it + let secret_store = + crate::secret_store::CloudflareSecretStore::from_env(env.clone()); + let secret_handle = SecretHandle::new(std::sync::Arc::new(secret_store)); + + let mut core_request = into_core_request(req, env, ctx) + .await + .map_err(edge_error_to_worker)?; + + if let Some(handle) = kv_handle { + core_request.extensions_mut().insert(handle); + } + core_request.extensions_mut().insert(secret_handle); + + let svc = app.router().clone(); + let response = svc.oneshot(core_request).await; + from_core_response(response).map_err(edge_error_to_worker) +} +``` + +- [ ] **Step 5.3: Update `run_app` in `lib.rs` to handle secrets** + +In `crates/edgezero-adapter-cloudflare/src/lib.rs`, update `run_app`: + +```rust +#[cfg(all(feature = "cloudflare", target_arch = "wasm32"))] +pub async fn run_app( + manifest_src: &str, + req: worker::Request, + env: worker::Env, + ctx: worker::Context, +) -> Result { + init_logger().expect("init cloudflare logger"); + let manifest_loader = edgezero_core::manifest::ManifestLoader::load_from_str(manifest_src); + let manifest = manifest_loader.manifest(); + let kv_binding = manifest.kv_store_name("cloudflare"); + let kv_required = manifest.stores.kv.is_some(); + let secret_binding = manifest.secret_store_name("cloudflare"); + let secrets_required = manifest.stores.secrets.is_some(); + let app = A::build_app(); + dispatch_with_kv_and_secrets( + &app, req, env, ctx, kv_binding, kv_required, secret_binding, secrets_required, + ) + .await +} +``` + +Update `lib.rs` exports to include `secret_store` module and new dispatch functions: + +```rust +#[cfg(all(feature = "cloudflare", target_arch = "wasm32"))] +pub mod secret_store; + +// in pub use request::{ ... } line: +pub use request::{ + dispatch, dispatch_with_kv, dispatch_with_kv_and_secrets, dispatch_with_secrets, + into_core_request, DEFAULT_KV_BINDING, +}; + +// add: +#[cfg(all(feature = "cloudflare", target_arch = "wasm32"))] +pub use secret_store::CloudflareSecretStore; +``` + +- [ ] **Step 5.4: Build check (WASM — compile only)** + +```bash +cargo check -p edgezero-adapter-cloudflare --features cloudflare --target wasm32-unknown-unknown 2>&1 | tail -10 +``` + +Expected: no errors. + +- [ ] **Step 5.5: Commit** + +```bash +git add crates/edgezero-adapter-cloudflare/src/secret_store.rs \ + crates/edgezero-adapter-cloudflare/src/request.rs \ + crates/edgezero-adapter-cloudflare/src/lib.rs +git commit -m "feat(cloudflare): add CloudflareSecretStore adapter and dispatch_with_secrets" +``` + +--- + +## Task 6: Axum secret adapter + dev server integration + +Closes #63. + +**Files:** +- Create: `crates/edgezero-adapter-axum/src/secret_store.rs` +- Modify: `crates/edgezero-adapter-axum/src/lib.rs` +- Modify: `crates/edgezero-adapter-axum/src/service.rs` +- Modify: `crates/edgezero-adapter-axum/src/dev_server.rs` + +- [ ] **Step 6.1: Write failing tests for `EnvSecretStore`** + +Create `crates/edgezero-adapter-axum/src/secret_store.rs` with tests first: + +```rust +//! Environment variable secret store for local development. +//! +//! Reads secrets from `std::env::var(name)`. Set secrets as environment +//! variables before starting the dev server: +//! +//! ```bash +//! API_KEY=mysecret cargo edgezero dev +//! ``` +//! +//! Or load them from a `.env` file using `dotenvy::dotenv()` in your +//! application entry point before calling `run_app`. + +// ... (implementation here, see Step 6.2) + +#[cfg(test)] +mod tests { + use super::*; + use bytes::Bytes; + use futures::executor::block_on; + + #[test] + fn get_bytes_returns_none_when_var_not_set() { + // Use a name that's very unlikely to be set in the environment + let store = EnvSecretStore::new(); + let result = block_on(store.get_bytes("__EDGEZERO_TEST_MISSING_VAR_XYZ__")).unwrap(); + assert!(result.is_none()); + } + + #[test] + fn get_bytes_returns_value_when_var_set() { + std::env::set_var("__EDGEZERO_TEST_SECRET__", "test_value_123"); + let store = EnvSecretStore::new(); + let result = block_on(store.get_bytes("__EDGEZERO_TEST_SECRET__")).unwrap(); + assert_eq!(result, Some(Bytes::from("test_value_123"))); + std::env::remove_var("__EDGEZERO_TEST_SECRET__"); + } +} +``` + +- [ ] **Step 6.2: Implement `EnvSecretStore` (insert before the test module)** + +```rust +use async_trait::async_trait; +use bytes::Bytes; +use edgezero_core::secret_store::{SecretError, SecretStore}; + +/// Secret store for local development that reads secrets from environment variables. +/// +/// When `[stores.secrets]` is declared in `edgezero.toml`, the dev server +/// creates an `EnvSecretStore` that reads secrets from the process environment. +/// +/// Populate secrets by setting environment variables before starting the server: +/// ```bash +/// MY_API_KEY=secret cargo edgezero dev +/// ``` +pub struct EnvSecretStore; + +impl EnvSecretStore { + pub fn new() -> Self { + Self + } +} + +impl Default for EnvSecretStore { + fn default() -> Self { + Self::new() + } +} + +#[async_trait(?Send)] +impl SecretStore for EnvSecretStore { + async fn get_bytes(&self, name: &str) -> Result, SecretError> { + match std::env::var(name) { + Ok(value) => Ok(Some(Bytes::from(value.into_bytes()))), + Err(std::env::VarError::NotPresent) => Ok(None), + Err(std::env::VarError::NotUnicode(os_str)) => { + Err(SecretError::Internal(anyhow::anyhow!( + "secret '{}' contains non-UTF-8 bytes: {:?}", + name, + os_str + ))) + } + } + } +} +``` + +- [ ] **Step 6.3: Run tests for `EnvSecretStore`** + +```bash +cargo test -p edgezero-adapter-axum secret_store 2>&1 | tail -15 +``` + +Expected: all 2 tests pass. + +- [ ] **Step 6.4: Add `with_secret_handle()` to `EdgeZeroAxumService`** + +In `crates/edgezero-adapter-axum/src/service.rs`: + +Add import: +```rust +use edgezero_core::secret_store::SecretHandle; +``` + +Add field to `EdgeZeroAxumService`: +```rust +pub struct EdgeZeroAxumService { + router: RouterService, + kv_handle: Option, + secret_handle: Option, // NEW +} +``` + +Update `new()`: +```rust +pub fn new(router: RouterService) -> Self { + Self { + router, + kv_handle: None, + secret_handle: None, + } +} +``` + +Add method after `with_kv_handle`: +```rust +/// Attach a shared secret store to this service. +/// +/// The handle is cloned into every request's extensions, making +/// the `Secrets` extractor available in handlers. +#[must_use] +pub fn with_secret_handle(mut self, handle: SecretHandle) -> Self { + self.secret_handle = Some(handle); + self +} +``` + +Update `call()` to inject secret handle after kv handle: +```rust +let secret_handle = self.secret_handle.clone(); +// ... in the async block, after kv handle injection: +if let Some(handle) = secret_handle { + core_request.extensions_mut().insert(handle); +} +``` + +Write a test for `with_secret_handle`: + +```rust +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn with_secret_handle_injects_into_request() { + use crate::secret_store::EnvSecretStore; + use edgezero_core::secret_store::SecretHandle; + use std::sync::Arc; + + std::env::set_var("__EDGEZERO_SERVICE_TEST_SECRET__", "injected_value"); + + let handle = SecretHandle::new(Arc::new(EnvSecretStore::new())); + let router = RouterService::builder() + .get("/check", |ctx: RequestContext| async move { + let secrets = ctx.secret_handle().expect("secret handle should be present"); + let val = secrets + .get_str("__EDGEZERO_SERVICE_TEST_SECRET__") + .await + .unwrap() + .unwrap_or_default(); + let response = response_builder() + .status(StatusCode::OK) + .body(Body::from(val)) + .expect("response"); + Ok::<_, EdgeError>(response) + }) + .build(); + let mut service = EdgeZeroAxumService::new(router).with_secret_handle(handle); + + let request = Request::builder().uri("/check").body(AxumBody::empty()).unwrap(); + let response = service.ready().await.unwrap().call(request).await.unwrap(); + assert_eq!(response.status(), StatusCode::OK); + let body = axum::body::to_bytes(response.into_body(), usize::MAX).await.unwrap(); + assert_eq!(&body[..], b"injected_value"); + + std::env::remove_var("__EDGEZERO_SERVICE_TEST_SECRET__"); +} +``` + +- [ ] **Step 6.5: Wire `EnvSecretStore` into `dev_server.rs`** + +In `crates/edgezero-adapter-axum/src/dev_server.rs`, add: + +```rust +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum SecretInitRequirement { + Optional, + Required, +} + +fn secret_init_requirement( + manifest: &edgezero_core::manifest::Manifest, +) -> SecretInitRequirement { + if manifest.stores.secrets.is_some() { + SecretInitRequirement::Required + } else { + SecretInitRequirement::Optional + } +} + +fn secret_handle_from_env( + store_name: &str, + requirement: SecretInitRequirement, +) -> Option { + let store = std::sync::Arc::new(crate::secret_store::EnvSecretStore::new()); + let handle = edgezero_core::secret_store::SecretHandle::new(store); + if requirement == SecretInitRequirement::Required { + log::info!("Secret store '{}': reading from environment variables", store_name); + } + Some(handle) +} +``` + +Update `serve_with_listener_and_kv_handle` — rename it to `serve_with_listener_and_stores` and add secret handle parameter: + +```rust +async fn serve_with_listener_and_stores( + router: RouterService, + listener: tokio::net::TcpListener, + enable_ctrl_c: bool, + kv_handle: Option, + secret_handle: Option, +) -> anyhow::Result<()> { + let mut service = EdgeZeroAxumService::new(router); + if let Some(kv_handle) = kv_handle { + service = service.with_kv_handle(kv_handle); + } + if let Some(secret_handle) = secret_handle { + service = service.with_secret_handle(secret_handle); + } + // ... rest same as current serve_with_listener_and_kv_handle +} +``` + +Update all three callers of `serve_with_listener_and_kv_handle` — enumerate them explicitly so nothing is missed: + +1. `serve_with_listener_and_kv_path` (line ~199) — change its call to `serve_with_listener_and_stores(..., None)` (no secrets; this path is used by the manifest-unaware `AxumDevServer::run` embedding API) +2. `serve_with_listener` (line ~187) — delegates to `serve_with_listener_and_kv_path`, no change needed here beyond the above +3. `run_app` (line ~297) — updated below to pass a real `secret_handle` + +The private test helper `AxumDevServer::run_with_listener` calls `serve_with_listener_and_kv_path`, so updating #1 above covers it automatically. + +Update `run_app` to initialize and pass the secret handle: + +```rust +pub fn run_app(manifest_src: &str) -> anyhow::Result<()> { + let manifest = ManifestLoader::load_from_str(manifest_src); + let manifest = manifest.manifest(); + // ... existing kv setup ... + let secret_init_requirement = secret_init_requirement(manifest); + let secret_store_name = manifest.secret_store_name("axum").to_string(); + + // ... in async block, after kv_handle: + let secret_handle = + secret_handle_from_env(&secret_store_name, secret_init_requirement); + serve_with_listener_and_stores(router, listener, config.enable_ctrl_c, kv_handle, secret_handle).await +} +``` + +- [ ] **Step 6.6: Update `lib.rs` to export `EnvSecretStore` and `secret_store` module** + +In `crates/edgezero-adapter-axum/src/lib.rs`, add: +```rust +#[cfg(feature = "axum")] +pub mod secret_store; + +// in pub use: +#[cfg(feature = "axum")] +pub use secret_store::EnvSecretStore; +``` + +- [ ] **Step 6.7: Run all axum adapter tests** + +```bash +cargo test -p edgezero-adapter-axum 2>&1 | tail -20 +``` + +Expected: all tests pass. + +- [ ] **Step 6.8: Commit** + +```bash +git add crates/edgezero-adapter-axum/src/secret_store.rs \ + crates/edgezero-adapter-axum/src/lib.rs \ + crates/edgezero-adapter-axum/src/service.rs \ + crates/edgezero-adapter-axum/src/dev_server.rs +git commit -m "feat(axum): add EnvSecretStore for local dev and wire into service/dev server" +``` + +--- + +## Task 7: CLI build-time validation + +Closes #66. + +**Files:** +- Modify: `crates/edgezero-cli/src/main.rs` + +This task adds informational output during `edgezero build` so developers know what secret store bindings they need to configure on each platform. + +- [ ] **Step 7.1: Write test for secret store info message** + +Add to `crates/edgezero-cli/src/main.rs` test block: + +```rust +#[test] +fn secret_store_name_is_readable_from_manifest() { + let manifest_with_secrets = r#" +[app] +name = "demo-app" +entry = "crates/demo-core" + +[stores.secrets] +name = "MY_SECRETS" + +[adapters.fastly.commands] +build = "echo build" +deploy = "echo deploy" +serve = "echo serve" +"#; + let loader = ManifestLoader::load_from_str(manifest_with_secrets); + assert_eq!( + loader.manifest().secret_store_name("fastly"), + "MY_SECRETS" + ); + assert!(loader.manifest().stores.secrets.is_some()); +} +``` + +- [ ] **Step 7.2: Add `log_store_bindings` function and call it from `handle_build`** + +In `crates/edgezero-cli/src/main.rs`, add this function: + +```rust +#[cfg(feature = "cli")] +fn log_store_bindings(adapter_name: &str, manifest: &ManifestLoader) { + let m = manifest.manifest(); + if let Some(ref secrets) = m.stores.secrets { + let binding_name = m.secret_store_name(adapter_name); + println!( + "[edgezero] secret store '{binding_name}' declared -- \ + ensure it is provisioned on the {adapter_name} platform \ + (global name: '{}')", + secrets.name + ); + } +} +``` + +Update `handle_build` to call it: + +```rust +#[cfg(feature = "cli")] +fn handle_build(adapter_name: &str, adapter_args: &[String]) -> Result<(), String> { + let manifest = load_manifest_optional()?; + ensure_adapter_defined(adapter_name, manifest.as_ref())?; + if let Some(ref m) = manifest { + log_store_bindings(adapter_name, m); + } + adapter::execute( + adapter_name, + adapter::Action::Build, + manifest.as_ref(), + adapter_args, + ) +} +``` + +- [ ] **Step 7.3: Run tests** + +```bash +cargo test -p edgezero-cli 2>&1 | tail -15 +``` + +Expected: all tests pass. + +- [ ] **Step 7.4: Commit** + +```bash +git add crates/edgezero-cli/src/main.rs +git commit -m "feat(cli): log secret store binding info during edgezero build" +``` + +--- + +## Task 8: Secret store trait contract tests and compile-time adapter type checks + +Closes #67. + +**What this task actually verifies:** +- `InMemorySecretStore` in `edgezero-core`: proves the `SecretStore` trait contract (get existing, get missing, read two different keys) +- Axum contract invocation: runs the same macro against `InMemorySecretStore` to prove the macro is importable from adapter crates +- `EnvSecretStore` behavior: tested independently in Task 6 unit tests (env var present/absent scenarios) +- Fastly / Cloudflare: compile-time checks only — `FastlySecretStore` and `CloudflareSecretStore` implement `SecretStore` (platform calls cannot run in CI) + +**Files:** +- Modify: `crates/edgezero-adapter-axum/Cargo.toml` (add `edgezero-core` with `test-utils` to dev-dependencies) +- Modify: `crates/edgezero-adapter-axum/src/secret_store.rs` (add contract tests using InMemorySecretStore) +- Modify: `crates/edgezero-core/src/secret_store.rs` (add contract test invocation for InMemorySecretStore) +- Modify: `crates/edgezero-adapter-fastly/tests/contract.rs` (add compile-only secret store stub) +- Modify: `crates/edgezero-adapter-cloudflare/tests/contract.rs` (add compile-only secret store stub) + +- [ ] **Step 8.1: Enable `test-utils` in axum adapter's dev-dependencies** + +`InMemorySecretStore` is gated behind `#[cfg(any(test, feature = "test-utils"))]` in `edgezero-core`. The `test` cfg only applies within `edgezero-core`'s own test compilation — external crates cannot see it unless the feature is explicitly enabled. Add to `crates/edgezero-adapter-axum/Cargo.toml`: + +```toml +[dev-dependencies] +# ... existing dev-dependencies ... +edgezero-core = { workspace = true, features = ["test-utils"] } +``` + +Check if `edgezero-core` already appears in `[dev-dependencies]`; if so, just add `features = ["test-utils"]` to the existing entry. + +- [ ] **Step 8.2: Add `InMemorySecretStore` contract test in `edgezero-core`** + +In `crates/edgezero-core/src/secret_store.rs`, inside the `#[cfg(test)]` module, add: + +```rust +use crate::secret_store_contract_tests; + +secret_store_contract_tests!(in_memory_contract, { + InMemorySecretStore::new([ + ("contract_key", Bytes::from("contract_value")), + ("contract_key_2", Bytes::from("another_value")), + ]) +}); +``` + +- [ ] **Step 8.3: Add `EnvSecretStore` contract test in axum adapter** + +In `crates/edgezero-adapter-axum/src/secret_store.rs`, in the `#[cfg(test)]` module, add after the existing tests: + +```rust +// Contract tests: use InMemorySecretStore since EnvSecretStore needs +// real env vars, which are unsafe in parallel tests. +// The EnvSecretStore is tested individually above. +use edgezero_core::secret_store::InMemorySecretStore; +use edgezero_core::secret_store_contract_tests; + +secret_store_contract_tests!(env_secret_contract, { + InMemorySecretStore::new([ + ("contract_key", Bytes::from("contract_value")), + ("contract_key_2", Bytes::from("another_value")), + ]) +}); +``` + +Note: We test `EnvSecretStore` behavior directly in its own unit tests (Steps 6.1–6.2). The contract tests use `InMemorySecretStore` to verify interface contract without env var race conditions. + +- [ ] **Step 8.4: Add compile-only secret store stubs to Fastly contract tests** + +In `crates/edgezero-adapter-fastly/tests/contract.rs`, add at the bottom: + +```rust +// Secret store contract tests for Fastly require a running Fastly Compute +// environment and cannot be executed in CI. The FastlySecretStore type is +// verified at compile time here. +#[cfg(all(feature = "fastly", target_arch = "wasm32"))] +mod secret_store_compile_check { + use edgezero_adapter_fastly::FastlySecretStore; + use edgezero_core::secret_store::SecretStore; + + // Compile-time check: FastlySecretStore implements SecretStore + fn _assert_impl() {} + fn _check() { + // This function is never called; it only verifies trait impl at compile time. + _assert_impl::(); + } +} +``` + +- [ ] **Step 8.5: Add compile-only secret store stubs to Cloudflare contract tests** + +In `crates/edgezero-adapter-cloudflare/tests/contract.rs`, add at the bottom: + +```rust +#[cfg(all(feature = "cloudflare", target_arch = "wasm32"))] +mod secret_store_compile_check { + use edgezero_adapter_cloudflare::CloudflareSecretStore; + use edgezero_core::secret_store::SecretStore; + + fn _assert_impl() {} + fn _check() { + _assert_impl::(); + } +} +``` + +- [ ] **Step 8.6: Run full test suite** + +```bash +cargo test --workspace --all-targets 2>&1 | tail -30 +``` + +Expected: all tests pass. + +- [ ] **Step 8.7: Run full CI gate checks** + +```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" +``` + +Expected: all four pass. + +- [ ] **Step 8.7: Commit** + +```bash +git add crates/edgezero-core/src/secret_store.rs \ + crates/edgezero-adapter-axum/src/secret_store.rs \ + crates/edgezero-adapter-fastly/tests/contract.rs \ + crates/edgezero-adapter-cloudflare/tests/contract.rs +git commit -m "test: add secret store contract tests across all adapters" +``` + +--- + +## Summary + +| Task | Files Changed | Tests Added | Closes | +|------|---------------|-------------|--------| +| 1 | `core/secret_store.rs`, `core/lib.rs` | 8 unit tests | #60 | +| 2 | `core/context.rs`, `core/extractor.rs` | 4 unit tests | #64 | +| 3 | `core/manifest.rs` | 5 unit tests | #65 | +| 4 | `fastly/secret_store.rs`, `fastly/request.rs`, `fastly/lib.rs` | compile check | #61 | +| 5 | `cloudflare/secret_store.rs`, `cloudflare/request.rs`, `cloudflare/lib.rs` | compile check | #62 | +| 6 | `axum/secret_store.rs`, `axum/lib.rs`, `axum/service.rs`, `axum/dev_server.rs` | 3 unit + 1 service test | #63 | +| 7 | `cli/main.rs` | 1 unit test | #66 | +| 8 | multiple | 3 contract tests + 2 compile checks | #67 | + +**Usage example** (after implementation): + +```toml +# edgezero.toml +[stores.secrets] +name = "MY_APP_SECRETS" + +[stores.secrets.adapters.fastly] +name = "MY_APP_SECRETS" # Fastly SecretStore name in fastly.toml +``` + +```rust +// handler +#[action] +pub async fn fetch_data(Secrets(secrets): Secrets) -> Result { + let api_key = secrets.require_str("API_KEY").await.map_err(EdgeError::from)?; + // use api_key ... +} +``` From 75bdc67fb65d3e826bcf28e336e779aba05bf267 Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Tue, 24 Mar 2026 14:48:00 +0530 Subject: [PATCH 08/18] feat(cloudflare): add CloudflareSecretStore adapter and dispatch_with_secrets Implements the Cloudflare secret store adapter backed by worker::Env::secret(). Adds dispatch_with_secrets and dispatch_with_kv_and_secrets dispatch functions, and updates run_app to wire secrets through on every request. --- crates/edgezero-adapter-cloudflare/src/lib.rs | 23 +++++- .../src/request.rs | 73 +++++++++++++++++++ .../src/secret_store.rs | 49 +++++++++++++ 3 files changed, 143 insertions(+), 2 deletions(-) create mode 100644 crates/edgezero-adapter-cloudflare/src/secret_store.rs diff --git a/crates/edgezero-adapter-cloudflare/src/lib.rs b/crates/edgezero-adapter-cloudflare/src/lib.rs index ec28382..465abd8 100644 --- a/crates/edgezero-adapter-cloudflare/src/lib.rs +++ b/crates/edgezero-adapter-cloudflare/src/lib.rs @@ -13,15 +13,22 @@ mod proxy; mod request; #[cfg(all(feature = "cloudflare", target_arch = "wasm32"))] mod response; +#[cfg(all(feature = "cloudflare", target_arch = "wasm32"))] +pub mod secret_store; #[cfg(all(feature = "cloudflare", target_arch = "wasm32"))] pub use context::CloudflareRequestContext; #[cfg(all(feature = "cloudflare", target_arch = "wasm32"))] pub use proxy::CloudflareProxyClient; #[cfg(all(feature = "cloudflare", target_arch = "wasm32"))] -pub use request::{dispatch, dispatch_with_kv, into_core_request, DEFAULT_KV_BINDING}; +pub use request::{ + dispatch, dispatch_with_kv, dispatch_with_kv_and_secrets, dispatch_with_secrets, + into_core_request, DEFAULT_KV_BINDING, +}; #[cfg(all(feature = "cloudflare", target_arch = "wasm32"))] pub use response::from_core_response; +#[cfg(all(feature = "cloudflare", target_arch = "wasm32"))] +pub use secret_store::CloudflareSecretStore; #[cfg(all(feature = "cloudflare", target_arch = "wasm32"))] pub fn init_logger() -> Result<(), log::SetLoggerError> { @@ -71,8 +78,20 @@ pub async fn run_app( let manifest = manifest_loader.manifest(); let kv_binding = manifest.kv_store_name("cloudflare"); let kv_required = manifest.stores.kv.is_some(); + let secret_binding = manifest.secret_store_name("cloudflare"); + let secrets_required = manifest.stores.secrets.is_some(); let app = A::build_app(); - dispatch_with_kv(&app, req, env, ctx, kv_binding, kv_required).await + dispatch_with_kv_and_secrets( + &app, + req, + env, + ctx, + kv_binding, + kv_required, + secret_binding, + secrets_required, + ) + .await } /// Deprecated: use [`run_app`] which now takes `manifest_src` directly. diff --git a/crates/edgezero-adapter-cloudflare/src/request.rs b/crates/edgezero-adapter-cloudflare/src/request.rs index 86604d7..9650092 100644 --- a/crates/edgezero-adapter-cloudflare/src/request.rs +++ b/crates/edgezero-adapter-cloudflare/src/request.rs @@ -10,6 +10,7 @@ use edgezero_core::error::EdgeError; use edgezero_core::http::{request_builder, Method as CoreMethod, Request, Uri}; use edgezero_core::key_value_store::KvHandle; use edgezero_core::proxy::ProxyHandle; +use edgezero_core::secret_store::SecretHandle; use worker::{ Context, Env, Error as WorkerError, Method, Request as CfRequest, Response as CfResponse, }; @@ -104,6 +105,78 @@ pub async fn dispatch_with_kv( from_core_response(response).map_err(edge_error_to_worker) } +/// Dispatch a Cloudflare Worker request with a secret store attached. +/// +/// Note: `_secrets_required` is intentionally unused. Cloudflare Worker Secrets +/// are individually bound in `wrangler.toml`; there is no namespace to "open" +/// that could fail. The store is always successfully constructed from `Env`. +/// Individual missing secrets surface as `SecretError::NotFound` at access time. +pub async fn dispatch_with_secrets( + app: &App, + req: CfRequest, + env: Env, + ctx: Context, + _secrets_required: bool, +) -> Result { + // Clone env before consuming it in into_core_request. + // Env wraps a JsValue reference; cloning increments the JS reference count. + let secret_store = crate::secret_store::CloudflareSecretStore::from_env(env.clone()); + let secret_handle = SecretHandle::new(std::sync::Arc::new(secret_store)); + + let mut core_request = into_core_request(req, env, ctx) + .await + .map_err(edge_error_to_worker)?; + core_request.extensions_mut().insert(secret_handle); + + let svc = app.router().clone(); + let response = svc.oneshot(core_request).await; + from_core_response(response).map_err(edge_error_to_worker) +} + +/// Dispatch a Cloudflare Worker request with both KV and secret stores attached. +pub async fn dispatch_with_kv_and_secrets( + app: &App, + req: CfRequest, + env: Env, + ctx: Context, + kv_binding: &str, + kv_required: bool, + _secret_binding: &str, // unused: CF secrets have no namespace concept + _secrets_required: bool, // unused: CloudflareSecretStore always constructs OK +) -> Result { + // Open KV by borrowing env + let kv_handle = match crate::key_value_store::CloudflareKvStore::from_env(&env, kv_binding) { + Ok(store) => Some(KvHandle::new(std::sync::Arc::new(store))), + Err(e) => { + if kv_required { + return Err(WorkerError::RustError(format!( + "KV binding '{}' is explicitly configured but could not be opened: {}", + kv_binding, e + ))); + } + warn_missing_kv_binding_once(kv_binding, &e); + None + } + }; + + // Clone env for secrets before consuming it + let secret_store = crate::secret_store::CloudflareSecretStore::from_env(env.clone()); + let secret_handle = SecretHandle::new(std::sync::Arc::new(secret_store)); + + let mut core_request = into_core_request(req, env, ctx) + .await + .map_err(edge_error_to_worker)?; + + if let Some(handle) = kv_handle { + core_request.extensions_mut().insert(handle); + } + core_request.extensions_mut().insert(secret_handle); + + let svc = app.router().clone(); + let response = svc.oneshot(core_request).await; + from_core_response(response).map_err(edge_error_to_worker) +} + fn edge_error_to_worker(err: EdgeError) -> WorkerError { WorkerError::RustError(err.to_string()) } diff --git a/crates/edgezero-adapter-cloudflare/src/secret_store.rs b/crates/edgezero-adapter-cloudflare/src/secret_store.rs new file mode 100644 index 0000000..c7ba68b --- /dev/null +++ b/crates/edgezero-adapter-cloudflare/src/secret_store.rs @@ -0,0 +1,49 @@ +//! Cloudflare Workers secret adapter. +//! +//! Reads secrets from `worker::Env::secret()`. Each call to `get_bytes(name)` +//! invokes `env.secret(name)` to retrieve the value. The `Env` is cloned at +//! dispatch time to outlive `into_core_request`'s ownership of the original. +//! +//! Note: Cloudflare Workers Secrets have no namespace concept — each secret +//! is an individual `[vars]` / Secrets binding in `wrangler.toml`. The +//! `[stores.secrets] name` in `edgezero.toml` is used only for Fastly; +//! Cloudflare accesses all secrets via this adapter regardless of name. + +#[cfg(all(feature = "cloudflare", target_arch = "wasm32"))] +use async_trait::async_trait; +#[cfg(all(feature = "cloudflare", target_arch = "wasm32"))] +use bytes::Bytes; +#[cfg(all(feature = "cloudflare", target_arch = "wasm32"))] +use edgezero_core::secret_store::{SecretError, SecretStore}; + +/// Secret store backed by Cloudflare Workers `Env`. +/// +/// Reads secrets via `env.secret(name)`. Clones the `Env` handle at dispatch +/// time so secrets remain accessible throughout the request lifetime. +#[cfg(all(feature = "cloudflare", target_arch = "wasm32"))] +pub struct CloudflareSecretStore { + env: worker::Env, +} + +#[cfg(all(feature = "cloudflare", target_arch = "wasm32"))] +impl CloudflareSecretStore { + /// Create a secret store from a cloned `Env`. + pub fn from_env(env: worker::Env) -> Self { + Self { env } + } +} + +#[cfg(all(feature = "cloudflare", target_arch = "wasm32"))] +#[async_trait(?Send)] +impl SecretStore for CloudflareSecretStore { + async fn get_bytes(&self, name: &str) -> Result, SecretError> { + match self.env.secret(name) { + Ok(secret) => { + let value = secret.to_string(); + Ok(Some(Bytes::from(value.into_bytes()))) + } + // Workers returns an error when a secret binding is absent + Err(_) => Ok(None), + } + } +} From ccbfa674e4ec7769e2013840d53ddb969aaf5902 Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Tue, 24 Mar 2026 14:50:42 +0530 Subject: [PATCH 09/18] feat(cloudflare): add CloudflareSecretStore adapter and dispatch_with_secrets --- .gitignore | 3 + .../src/request.rs | 5 + .../2026-03-24-secret-store-abstraction.md | 1964 ----------------- 3 files changed, 8 insertions(+), 1964 deletions(-) delete mode 100644 docs/superpowers/plans/2026-03-24-secret-store-abstraction.md diff --git a/.gitignore b/.gitignore index a4c0cda..48a5ede 100644 --- a/.gitignore +++ b/.gitignore @@ -17,6 +17,9 @@ target/ # Worktrees .worktrees/ +# Superpowers plans +docs/superpowers/ + # Editors .claude/* !.claude/settings.json diff --git a/crates/edgezero-adapter-cloudflare/src/request.rs b/crates/edgezero-adapter-cloudflare/src/request.rs index 9650092..d478f5b 100644 --- a/crates/edgezero-adapter-cloudflare/src/request.rs +++ b/crates/edgezero-adapter-cloudflare/src/request.rs @@ -107,6 +107,11 @@ pub async fn dispatch_with_kv( /// Dispatch a Cloudflare Worker request with a secret store attached. /// +/// Dispatch a Cloudflare Worker request with a secret store attached (no KV store). +/// +/// Use this when your application accesses secrets but does not need a KV store. +/// For applications that need both, use [`dispatch_with_kv_and_secrets`] instead. +/// /// Note: `_secrets_required` is intentionally unused. Cloudflare Worker Secrets /// are individually bound in `wrangler.toml`; there is no namespace to "open" /// that could fail. The store is always successfully constructed from `Env`. diff --git a/docs/superpowers/plans/2026-03-24-secret-store-abstraction.md b/docs/superpowers/plans/2026-03-24-secret-store-abstraction.md deleted file mode 100644 index 52bc4b9..0000000 --- a/docs/superpowers/plans/2026-03-24-secret-store-abstraction.md +++ /dev/null @@ -1,1964 +0,0 @@ -# Secret Store Abstraction 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:** Provide a provider-neutral `SecretStore` trait so applications can access sensitive values (API keys, signing keys, tokens) without coupling to platform-specific secret APIs. - -**Architecture:** A thin `SecretStore` trait with a single `get_bytes` method is wrapped by a `SecretHandle` that validates inputs and adds `get_str`/`require_*` helpers. Each adapter implements the trait using the platform's native secret mechanism: Fastly `SecretStore`, Cloudflare `Env::secret()`, and Axum `std::env::var`. The handle is stored in request extensions and retrieved via the `Secrets` extractor — identical pattern to the existing `KvHandle`/`Kv` extractor. - -**Tech Stack:** Rust 1.91, `async-trait`, `bytes`, `thiserror`, `anyhow`, Fastly 0.11 crate, Cloudflare `worker` 0.7 crate - ---- - -## File Map - -### New files -| File | Responsibility | -|------|----------------| -| `crates/edgezero-core/src/secret_store.rs` | `SecretStore` trait, `SecretHandle`, `SecretError`, `NoopSecretStore` (test-utils), `InMemorySecretStore` (test-utils), `secret_store_contract_tests!` macro | -| `crates/edgezero-adapter-fastly/src/secret_store.rs` | `FastlySecretStore` — wraps `fastly::secret_store::SecretStore` | -| `crates/edgezero-adapter-cloudflare/src/secret_store.rs` | `CloudflareSecretStore` — wraps `worker::Env` for on-demand lookup | -| `crates/edgezero-adapter-axum/src/secret_store.rs` | `EnvSecretStore` — reads from `std::env::var` | - -### Modified files -| File | Change | -|------|--------| -| `crates/edgezero-core/src/lib.rs` | Add `pub mod secret_store` + re-exports | -| `crates/edgezero-core/src/context.rs` | Add `secret_handle()` method | -| `crates/edgezero-core/src/extractor.rs` | Add `Secrets` extractor | -| `crates/edgezero-core/src/manifest.rs` | Add `ManifestSecretsConfig`, `ManifestSecretsAdapterConfig`, update `ManifestStores`, add `secret_store_name()`, add `DEFAULT_SECRET_STORE_NAME` constant | -| `crates/edgezero-adapter-fastly/src/lib.rs` | Add `pub mod secret_store`, exports, update `run_app` | -| `crates/edgezero-adapter-fastly/src/request.rs` | Add `dispatch_with_secrets`, `dispatch_with_kv_and_secrets` | -| `crates/edgezero-adapter-cloudflare/src/lib.rs` | Add `pub mod secret_store`, exports, update `run_app` | -| `crates/edgezero-adapter-cloudflare/src/request.rs` | Add `dispatch_with_secrets`, `dispatch_with_kv_and_secrets` | -| `crates/edgezero-adapter-axum/src/lib.rs` | Add `pub mod secret_store`, exports | -| `crates/edgezero-adapter-axum/src/service.rs` | Add `secret_handle` field, `with_secret_handle()` method | -| `crates/edgezero-adapter-axum/src/dev_server.rs` | Add `secret_handle_from_manifest()`, wire into `run_app` and `serve_with_listener_and_kv_handle` → renamed `serve_with_listener_and_stores` | -| `crates/edgezero-cli/src/main.rs` | Add secret store binding info logging in `handle_build` | - ---- - -## Design Context - -### Relationship to `[[environment.secrets]]` - -The manifest already has `[[environment.secrets]]` — **these are completely different concepts**: - -| Concept | Section | When it runs | Purpose | -|---------|---------|-------------|---------| -| Build-time secret declaration | `[[environment.secrets]]` | `edgezero build` / `edgezero deploy` | Declares which env vars must be present for CLI commands. Aborts with an error if any are missing. Not related to request handling. | -| Runtime secret store | `[stores.secrets]` | Every HTTP request | Registers a platform-specific vault (Fastly SecretStore, Cloudflare Worker Secrets, env vars) for handlers to read during request processing. | - -Both coexist. An app may use `[[environment.secrets]]` to ensure that `API_KEY` is set for the build pipeline, and `[stores.secrets]` so that a handler can call `secrets.require_str("API_KEY").await` at request time. - -### Error model - -A missing secret (`SecretError::NotFound`) is a **server-side misconfiguration**, not a client error. The `From for EdgeError` impl maps `NotFound` to `EdgeError::internal` (HTTP 500), not `EdgeError::not_found` (404). Client-visible 404s would leak information about server configuration and would be incorrect HTTP semantics. - -### Manifest `name` semantics per adapter - -The `[stores.secrets] name` field has different meaning per adapter — this asymmetry is intentional: - -| Adapter | `name` usage | -|---------|-------------| -| **Fastly** | Name of the `fastly::secret_store::SecretStore` resource declared in `fastly.toml`. Required for `SecretStore::open(name)` to succeed. | -| **Cloudflare** | Informational only. Cloudflare Worker Secrets are individually bound as separate `[vars]` entries in `wrangler.toml`; there is no namespace concept. The `_secrets_required` flag has no runtime effect since `CloudflareSecretStore::from_env(env)` always succeeds. | -| **Axum (dev)** | Informational only. Secrets are read from env vars by name. `EnvSecretStore` is always successfully constructed. | - -The `secrets_required` flag is only meaningful for Fastly (where `SecretStore::open()` can fail). On Cloudflare and Axum, constructing the store always succeeds and individual secret misses are surfaced at access time via `SecretError::NotFound`. - ---- - -## Task 1: Core `SecretStore` trait and `SecretHandle` - -Closes #60. - -**Files:** -- Create: `crates/edgezero-core/src/secret_store.rs` -- Modify: `crates/edgezero-core/src/lib.rs` - -- [ ] **Step 1.1: Declare the module in `crates/edgezero-core/src/lib.rs` first** - -Add `pub mod secret_store;` after the `key_value_store` line (before adding the implementation file): - -```rust -pub mod secret_store; -``` - -Also add to the `pub use` block: -```rust -pub use secret_store::{SecretError, SecretHandle, SecretStore}; -``` - -This makes the compiler look for `secret_store.rs` — which doesn't exist yet — so the next step's tests will produce the expected error. - -- [ ] **Step 1.2: Run to confirm compile error (module file missing)** - -```bash -cargo build -p edgezero-core 2>&1 | head -5 -``` - -Expected: `error[E0583]: file not found for module 'secret_store'` - -- [ ] **Step 1.3: Write failing tests for `SecretError` and `SecretHandle` validation** - -Create `crates/edgezero-core/src/secret_store.rs` containing **only** the test module (no implementation yet): - -```rust -// In crates/edgezero-core/src/secret_store.rs - -#[cfg(test)] -mod tests { - use super::*; - use bytes::Bytes; - use futures::executor::block_on; - - // Test-only in-memory store - use std::collections::HashMap; - struct SimpleStore(HashMap); - #[async_trait(?Send)] - impl SecretStore for SimpleStore { - async fn get_bytes(&self, name: &str) -> Result, SecretError> { - Ok(self.0.get(name).cloned()) - } - } - - fn store_with(entries: &[(&str, &str)]) -> SecretHandle { - let map: HashMap = entries - .iter() - .map(|(k, v)| (k.to_string(), Bytes::from(v.to_string()))) - .collect(); - SecretHandle::new(std::sync::Arc::new(SimpleStore(map))) - } - - #[test] - fn validate_name_rejects_empty() { - block_on(async { - let h = store_with(&[]); - let err = h.get_bytes("").await.unwrap_err(); - assert!(matches!(err, SecretError::Validation(_))); - }); - } - - #[test] - fn validate_name_rejects_control_chars() { - block_on(async { - let h = store_with(&[]); - let err = h.get_bytes("bad\x00name").await.unwrap_err(); - assert!(matches!(err, SecretError::Validation(_))); - }); - } - - #[test] - fn validate_name_rejects_oversized() { - block_on(async { - let h = store_with(&[]); - let name = "x".repeat(SecretHandle::MAX_NAME_LEN + 1); - let err = h.get_bytes(&name).await.unwrap_err(); - assert!(matches!(err, SecretError::Validation(_))); - }); - } - - #[test] - fn get_bytes_returns_none_for_missing() { - block_on(async { - let h = store_with(&[]); - assert_eq!(h.get_bytes("missing").await.unwrap(), None); - }); - } - - #[test] - fn get_bytes_returns_value_for_existing() { - block_on(async { - let h = store_with(&[("api_key", "secret123")]); - assert_eq!( - h.get_bytes("api_key").await.unwrap(), - Some(Bytes::from("secret123")) - ); - }); - } - - #[test] - fn get_str_decodes_utf8() { - block_on(async { - let h = store_with(&[("token", "bearer xyz")]); - assert_eq!( - h.get_str("token").await.unwrap(), - Some("bearer xyz".to_string()) - ); - }); - } - - #[test] - fn require_bytes_fails_for_missing() { - block_on(async { - let h = store_with(&[]); - let err = h.require_bytes("missing").await.unwrap_err(); - assert!(matches!(err, SecretError::NotFound { .. })); - }); - } - - #[test] - fn require_str_returns_value() { - block_on(async { - let h = store_with(&[("key", "value")]); - assert_eq!(h.require_str("key").await.unwrap(), "value"); - }); - } -} -``` - -- [ ] **Step 1.4: Run tests — verify they fail (symbols not defined yet)** - -```bash -cargo test -p edgezero-core 2>&1 | head -20 -``` - -Expected: compile error `use of undeclared type 'SecretStore'` or similar — the test body references symbols not yet defined. - -- [ ] **Step 1.5: Prepend the full implementation to `secret_store.rs`** - -The `#[cfg(test)]` module from Step 1.3 must stay at the bottom of the file — do not delete it. **Insert everything below before the existing `#[cfg(test)] mod tests {` line.** The final file will be: module doc + use declarations + impl code + (unchanged) test module from Step 1.3. - -```rust -//! Provider-neutral secret store abstraction. -//! -//! # Architecture -//! -//! ```text -//! Handler code SecretHandle (get_str / require_str) -//! │ │ -//! └── Secrets extractor ─►│ UTF-8 / bytes layer -//! │ -//! Arc (object-safe, Bytes) -//! │ -//! ┌──────────────┼──────────────┐ -//! ▼ ▼ ▼ -//! EnvSecretStore FastlySecretStore CloudflareSecretStore -//! ``` -//! -//! Secrets are read-only — this API only retrieves values, -//! it never writes or deletes them. Provisioning secrets is the -//! responsibility of each platform's deployment toolchain. - -use std::fmt; -use std::sync::Arc; - -use async_trait::async_trait; -use bytes::Bytes; - -use crate::error::EdgeError; - -// --------------------------------------------------------------------------- -// Error -// --------------------------------------------------------------------------- - -/// Errors returned by secret store operations. -#[derive(Debug, thiserror::Error)] -pub enum SecretError { - /// The requested secret was not found. - #[error("secret not found: {name}")] - NotFound { name: String }, - - /// The secret store backend is temporarily unavailable. - #[error("secret store unavailable")] - Unavailable, - - /// A validation error (e.g., invalid secret name). - #[error("validation error: {0}")] - Validation(String), - - /// A general internal error. - #[error("secret store error: {0}")] - Internal(#[from] anyhow::Error), -} - -impl From for EdgeError { - fn from(err: SecretError) -> Self { - match err { - // NotFound = server misconfiguration, never a client 404. - // A missing API key means the platform isn't set up correctly, - // not that the request was invalid. - SecretError::NotFound { name } => EdgeError::internal(anyhow::anyhow!( - "required secret '{}' is not configured -- check platform secret store bindings", - name - )), - SecretError::Unavailable => { - EdgeError::service_unavailable("secret store unavailable") - } - // Validation errors are programming errors (bad secret name in code), - // not client errors. - SecretError::Validation(e) => { - EdgeError::internal(anyhow::anyhow!("secret name validation error: {e}")) - } - SecretError::Internal(e) => EdgeError::internal(e), - } - } -} - -// --------------------------------------------------------------------------- -// Trait -// --------------------------------------------------------------------------- - -/// Object-safe interface for secret store backends. -/// -/// All methods take `&self` — backends handle their own access model. -/// -/// This trait is always called through [`SecretHandle`], which validates -/// inputs before delegating here. Implementations may therefore assume: -/// - Names are non-empty and within [`SecretHandle::MAX_NAME_LEN`] -/// - Names contain no control characters -#[async_trait(?Send)] -pub trait SecretStore: Send + Sync { - /// Retrieve a secret as raw bytes. Returns `Ok(None)` if not found. - async fn get_bytes(&self, name: &str) -> Result, SecretError>; -} - -// --------------------------------------------------------------------------- -// Test-only no-op store -// --------------------------------------------------------------------------- - -/// A no-op [`SecretStore`] for tests that only need a [`SecretHandle`] to exist. -/// -/// All reads return `None`. -/// -/// Available in `#[cfg(test)]` builds and via the `test-utils` feature: -/// ```toml -/// [dev-dependencies] -/// edgezero-core = { path = "...", features = ["test-utils"] } -/// ``` -#[cfg(any(test, feature = "test-utils"))] -pub struct NoopSecretStore; - -#[cfg(any(test, feature = "test-utils"))] -#[async_trait(?Send)] -impl SecretStore for NoopSecretStore { - async fn get_bytes(&self, _name: &str) -> Result, SecretError> { - Ok(None) - } -} - -// --------------------------------------------------------------------------- -// In-memory store (test-utils) -// --------------------------------------------------------------------------- - -/// An in-memory [`SecretStore`] pre-populated with known secrets. -/// -/// Useful for contract tests and unit tests that need deterministic secret values. -/// -/// Available in `#[cfg(test)]` builds and via the `test-utils` feature. -#[cfg(any(test, feature = "test-utils"))] -pub struct InMemorySecretStore { - secrets: std::collections::HashMap, -} - -#[cfg(any(test, feature = "test-utils"))] -impl InMemorySecretStore { - /// Create a new in-memory store with the given entries. - /// - /// # Example - /// ```rust,ignore - /// let store = InMemorySecretStore::new([ - /// ("api_key", Bytes::from("secret123")), - /// ("signing_key", Bytes::from("keyvalue")), - /// ]); - /// ``` - pub fn new( - entries: impl IntoIterator, impl Into)>, - ) -> Self { - Self { - secrets: entries - .into_iter() - .map(|(k, v)| (k.into(), v.into())) - .collect(), - } - } -} - -#[cfg(any(test, feature = "test-utils"))] -#[async_trait(?Send)] -impl SecretStore for InMemorySecretStore { - async fn get_bytes(&self, name: &str) -> Result, SecretError> { - Ok(self.secrets.get(name).cloned()) - } -} - -// --------------------------------------------------------------------------- -// Handle -// --------------------------------------------------------------------------- - -/// A cloneable, ergonomic handle to a secret store. -/// -/// Provides typed helpers (`get_str`, `require_bytes`, `require_str`) -/// while delegating to the object-safe `SecretStore` trait underneath. -/// -/// # Example -/// -/// ```ignore -/// #[action] -/// async fn handler(Secrets(secrets): Secrets) -> Result { -/// let api_key: String = secrets.require_str("API_KEY").await?; -/// // use api_key ... -/// } -/// ``` -#[derive(Clone)] -pub struct SecretHandle { - store: Arc, -} - -impl fmt::Debug for SecretHandle { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.debug_struct("SecretHandle").finish_non_exhaustive() - } -} - -impl SecretHandle { - /// Maximum secret name length in bytes. - pub const MAX_NAME_LEN: usize = 512; - - /// Create a new handle wrapping a secret store implementation. - pub fn new(store: Arc) -> Self { - Self { store } - } - - fn validate_name(name: &str) -> Result<(), SecretError> { - if name.is_empty() { - return Err(SecretError::Validation("secret name cannot be empty".to_string())); - } - if name.len() > Self::MAX_NAME_LEN { - return Err(SecretError::Validation(format!( - "secret name length {} exceeds limit of {} bytes", - name.len(), - Self::MAX_NAME_LEN - ))); - } - if name.chars().any(|c| c.is_control()) { - return Err(SecretError::Validation( - "secret name contains invalid control characters".to_string(), - )); - } - Ok(()) - } - - /// Retrieve a secret as raw bytes. Returns `Ok(None)` if not found. - pub async fn get_bytes(&self, name: &str) -> Result, SecretError> { - Self::validate_name(name)?; - self.store.get_bytes(name).await - } - - /// Retrieve a secret as a UTF-8 string. Returns `Ok(None)` if not found. - pub async fn get_str(&self, name: &str) -> Result, SecretError> { - let bytes = self.get_bytes(name).await?; - bytes - .map(|b| { - String::from_utf8(b.to_vec()).map_err(|e| { - SecretError::Internal(anyhow::anyhow!( - "secret '{}' is not valid UTF-8: {e}", - name - )) - }) - }) - .transpose() - } - - /// Retrieve a secret as raw bytes. Returns `SecretError::NotFound` if absent. - pub async fn require_bytes(&self, name: &str) -> Result { - self.get_bytes(name) - .await? - .ok_or_else(|| SecretError::NotFound { name: name.to_string() }) - } - - /// Retrieve a secret as a UTF-8 string. Returns `SecretError::NotFound` if absent. - pub async fn require_str(&self, name: &str) -> Result { - let bytes = self.require_bytes(name).await?; - String::from_utf8(bytes.to_vec()).map_err(|e| { - SecretError::Internal(anyhow::anyhow!( - "secret '{}' is not valid UTF-8: {e}", - name - )) - }) - } -} - -// --------------------------------------------------------------------------- -// Contract test macro -// --------------------------------------------------------------------------- - -/// Generate a suite of contract tests for any [`SecretStore`] implementation. -/// -/// The factory expression must produce a store pre-populated with: -/// - `"contract_key"` → `Bytes::from("contract_value")` -/// - `"contract_key_2"` → `Bytes::from("another_value")` -/// - `"missing_key"` must NOT be present. -/// -/// # Example -/// -/// ```rust,ignore -/// edgezero_core::secret_store_contract_tests!(in_memory_contract, { -/// InMemorySecretStore::new([ -/// ("contract_key", Bytes::from("contract_value")), -/// ("contract_key_2", Bytes::from("another_value")), -/// ]) -/// }); -/// ``` -#[macro_export] -macro_rules! secret_store_contract_tests { - ($mod_name:ident, $factory:expr) => { - mod $mod_name { - use super::*; - use bytes::Bytes; - use $crate::secret_store::SecretStore; - - fn run(f: F) -> F::Output { - futures::executor::block_on(f) - } - - #[test] - fn contract_get_existing_returns_bytes() { - let store = $factory; - run(async { - let result = store.get_bytes("contract_key").await.unwrap(); - assert_eq!(result, Some(Bytes::from("contract_value"))); - }); - } - - #[test] - fn contract_get_second_key_returns_bytes() { - let store = $factory; - run(async { - let result = store.get_bytes("contract_key_2").await.unwrap(); - assert_eq!(result, Some(Bytes::from("another_value"))); - }); - } - - #[test] - fn contract_get_missing_returns_none() { - let store = $factory; - run(async { - let result = store.get_bytes("missing_key").await.unwrap(); - assert!(result.is_none()); - }); - } - } - }; -} - -// ← Stop here. The #[cfg(test)] mod tests { ... } block from Step 1.3 already -// sits below this point in the file and must be preserved as-is. -``` - -The `#[cfg(test)]` test module you created in Step 1.3 remains unchanged at the bottom of the file. - -- [ ] **Step 1.6: Run tests — verify they pass** - -```bash -cargo test -p edgezero-core 2>&1 | tail -20 -``` - -Expected: all tests pass including the new `secret_store::tests::*` tests. - -- [ ] **Step 1.7: Run clippy** - -```bash -cargo clippy -p edgezero-core --all-features -- -D warnings -``` - -Expected: no warnings. - -- [ ] **Step 1.8: Commit** - -```bash -git add crates/edgezero-core/src/secret_store.rs crates/edgezero-core/src/lib.rs -git commit -m "feat(core): add SecretStore trait, SecretHandle, and contract test macro" -``` - ---- - -## Task 2: `RequestContext::secret_handle()` + `Secrets` extractor - -Closes #64. - -**Files:** -- Modify: `crates/edgezero-core/src/context.rs` -- Modify: `crates/edgezero-core/src/extractor.rs` - -- [ ] **Step 2.1: Write failing test in `context.rs`** - -Add to `crates/edgezero-core/src/context.rs` `#[cfg(test)]` block: - -```rust -#[test] -fn secret_handle_is_retrieved_when_present() { - use crate::secret_store::{NoopSecretStore, SecretHandle}; - use std::sync::Arc; - - let mut request = request_builder() - .method(Method::GET) - .uri("/secrets") - .body(Body::empty()) - .expect("request"); - request - .extensions_mut() - .insert(SecretHandle::new(Arc::new(NoopSecretStore))); - - let ctx = RequestContext::new(request, PathParams::default()); - assert!(ctx.secret_handle().is_some()); -} - -#[test] -fn secret_handle_returns_none_when_absent() { - let ctx = ctx("/test", Body::empty(), PathParams::default()); - assert!(ctx.secret_handle().is_none()); -} -``` - -- [ ] **Step 2.2: Run tests — verify they fail** - -```bash -cargo test -p edgezero-core context::tests 2>&1 | grep -E "FAILED|error" -``` - -Expected: error `no method named 'secret_handle'` - -- [ ] **Step 2.3: Add `secret_handle()` method to `RequestContext`** - -In `crates/edgezero-core/src/context.rs`, add import at top: -```rust -use crate::secret_store::SecretHandle; -``` - -Add method after `kv_handle()`: -```rust -pub fn secret_handle(&self) -> Option { - self.request.extensions().get::().cloned() -} -``` - -- [ ] **Step 2.4: Write failing test for `Secrets` extractor in `extractor.rs`** - -Add to `crates/edgezero-core/src/extractor.rs` `#[cfg(test)]` block: - -```rust -#[test] -fn secrets_extractor_returns_handle_when_present() { - use crate::secret_store::{NoopSecretStore, SecretHandle}; - use std::sync::Arc; - - let mut request = request_builder() - .method(Method::GET) - .uri("/secrets") - .body(Body::empty()) - .expect("request"); - request - .extensions_mut() - .insert(SecretHandle::new(Arc::new(NoopSecretStore))); - let ctx = RequestContext::new(request, PathParams::default()); - let result = block_on(Secrets::from_request(&ctx)); - assert!(result.is_ok()); -} - -#[test] -fn secrets_extractor_errors_when_absent() { - let request = request_builder() - .method(Method::GET) - .uri("/secrets") - .body(Body::empty()) - .expect("request"); - let ctx = RequestContext::new(request, PathParams::default()); - let err = block_on(Secrets::from_request(&ctx)).unwrap_err(); - assert_eq!(err.status(), StatusCode::INTERNAL_SERVER_ERROR); -} -``` - -- [ ] **Step 2.5: Add `Secrets` extractor to `extractor.rs`** - -After the `Kv` extractor block (around line 449), add: - -```rust -/// Extracts the [`SecretHandle`] from the request context. -/// -/// Returns `EdgeError::Internal` if no secret store was configured for this request. -/// -/// # Example -/// ```ignore -/// #[action] -/// pub async fn handler(Secrets(secrets): Secrets) -> Result { -/// let key = secrets.require_str("API_KEY").await.map_err(EdgeError::from)?; -/// // use key ... -/// } -/// ``` -#[derive(Debug)] -pub struct Secrets(pub crate::secret_store::SecretHandle); - -#[async_trait(?Send)] -impl FromRequest for Secrets { - async fn from_request(ctx: &RequestContext) -> Result { - ctx.secret_handle().map(Secrets).ok_or_else(|| { - EdgeError::internal(anyhow::anyhow!( - "no secret store configured -- check [stores.secrets] in edgezero.toml and platform bindings" - )) - }) - } -} - -impl std::ops::Deref for Secrets { - type Target = crate::secret_store::SecretHandle; - - fn deref(&self) -> &Self::Target { - &self.0 - } -} - -impl std::ops::DerefMut for Secrets { - fn deref_mut(&mut self) -> &mut Self::Target { - &mut self.0 - } -} - -impl Secrets { - pub fn into_inner(self) -> crate::secret_store::SecretHandle { - self.0 - } -} -``` - -- [ ] **Step 2.6: Run tests** - -```bash -cargo test -p edgezero-core 2>&1 | tail -20 -``` - -Expected: all tests pass. - -- [ ] **Step 2.7: Commit** - -```bash -git add crates/edgezero-core/src/context.rs crates/edgezero-core/src/extractor.rs -git commit -m "feat(core): add secret_handle() to RequestContext and Secrets extractor" -``` - ---- - -## Task 3: Manifest schema for `[stores.secrets]` - -Closes #65. - -**Files:** -- Modify: `crates/edgezero-core/src/manifest.rs` - -- [ ] **Step 3.1: Write failing tests for manifest parsing** - -Add to `manifest.rs` `#[cfg(test)]` block (search for the existing test module): - -```rust -#[test] -fn secret_store_name_defaults_to_constant_when_absent() { - let manifest = ManifestLoader::load_from_str("[app]\nname = \"x\"\n"); - assert_eq!( - manifest.manifest().secret_store_name("fastly"), - DEFAULT_SECRET_STORE_NAME - ); -} - -#[test] -fn secret_store_name_uses_global_name_when_declared() { - let manifest = ManifestLoader::load_from_str( - "[stores.secrets]\nname = \"MY_SECRETS\"\n", - ); - assert_eq!(manifest.manifest().secret_store_name("fastly"), "MY_SECRETS"); - assert_eq!( - manifest.manifest().secret_store_name("cloudflare"), - "MY_SECRETS" - ); -} - -#[test] -fn secret_store_name_uses_per_adapter_override() { - let manifest = ManifestLoader::load_from_str( - "[stores.secrets]\nname = \"MY_SECRETS\"\n\ - [stores.secrets.adapters.fastly]\nname = \"FASTLY_STORE\"\n", - ); - assert_eq!( - manifest.manifest().secret_store_name("fastly"), - "FASTLY_STORE" - ); - assert_eq!( - manifest.manifest().secret_store_name("cloudflare"), - "MY_SECRETS" - ); -} - -#[test] -fn secrets_required_is_false_when_absent() { - let manifest = ManifestLoader::load_from_str("[app]\nname = \"x\"\n"); - assert!(manifest.manifest().stores.secrets.is_none()); -} - -#[test] -fn secrets_required_is_true_when_declared() { - let manifest = ManifestLoader::load_from_str( - "[stores.secrets]\nname = \"MY_SECRETS\"\n", - ); - assert!(manifest.manifest().stores.secrets.is_some()); -} -``` - -- [ ] **Step 3.2: Run tests — verify they fail** - -```bash -cargo test -p edgezero-core manifest 2>&1 | grep -E "FAILED|error" -``` - -Expected: errors about `secret_store_name` and `DEFAULT_SECRET_STORE_NAME` not existing. - -- [ ] **Step 3.3: Add manifest structs and `secret_store_name()` method** - -In `crates/edgezero-core/src/manifest.rs`: - -After the `DEFAULT_KV_STORE_NAME` and `default_kv_name` declarations, add: - -```rust -/// Default secret store / binding name used when `[stores.secrets]` is omitted. -pub const DEFAULT_SECRET_STORE_NAME: &str = "EDGEZERO_SECRETS"; - -fn default_secret_name() -> String { - DEFAULT_SECRET_STORE_NAME.to_string() -} -``` - -Update `ManifestStores` struct to add the `secrets` field: - -```rust -#[derive(Debug, Default, Deserialize, Validate)] -pub struct ManifestStores { - /// KV store configuration. - #[serde(default)] - #[validate(nested)] - pub kv: Option, - - /// Secret store configuration. When absent, the default - /// name `EDGEZERO_SECRETS` is used. - #[serde(default)] - #[validate(nested)] - pub secrets: Option, -} -``` - -Add after `ManifestKvAdapterConfig`: - -```rust -/// Global secret store configuration. -#[derive(Debug, Deserialize, Validate)] -pub struct ManifestSecretsConfig { - /// Store / binding name (default: `"EDGEZERO_SECRETS"`). - #[serde(default = "default_secret_name")] - #[validate(length(min = 1))] - pub name: String, - - /// Per-adapter name overrides. - #[serde(default)] - #[validate(nested)] - pub adapters: BTreeMap, -} - -/// Per-adapter secret store name override. -#[derive(Debug, Deserialize, Validate)] -pub struct ManifestSecretsAdapterConfig { - #[validate(length(min = 1))] - pub name: String, -} -``` - -Add `secret_store_name()` method to `impl Manifest`, after `kv_store_name()`: - -```rust -/// Returns the secret store name for a given adapter. -/// -/// Resolution order: -/// 1. Per-adapter override (`[stores.secrets.adapters.]`) -/// 2. Global name (`[stores.secrets] name = "..."`) -/// 3. Default: `"EDGEZERO_SECRETS"` -pub fn secret_store_name(&self, adapter: &str) -> &str { - match &self.stores.secrets { - Some(secrets) => { - let adapter_lower = adapter.to_ascii_lowercase(); - if let Some(adapter_cfg) = secrets - .adapters - .iter() - .find(|(k, _)| k.eq_ignore_ascii_case(&adapter_lower)) - { - return &adapter_cfg.1.name; - } - &secrets.name - } - None => DEFAULT_SECRET_STORE_NAME, - } -} -``` - -- [ ] **Step 3.4: Run tests** - -```bash -cargo test -p edgezero-core 2>&1 | tail -20 -``` - -Expected: all tests pass including the new manifest tests. - -- [ ] **Step 3.5: Commit** - -```bash -git add crates/edgezero-core/src/manifest.rs -git commit -m "feat(core): add [stores.secrets] manifest schema and secret_store_name()" -``` - ---- - -## Task 4: Fastly secret adapter - -Closes #61. - -**Files:** -- Create: `crates/edgezero-adapter-fastly/src/secret_store.rs` -- Modify: `crates/edgezero-adapter-fastly/src/lib.rs` -- Modify: `crates/edgezero-adapter-fastly/src/request.rs` - -- [ ] **Step 4.1: Create `FastlySecretStore`** - -Create `crates/edgezero-adapter-fastly/src/secret_store.rs`: - -```rust -//! Fastly SecretStore adapter. -//! -//! Wraps `fastly::secret_store::SecretStore` to implement -//! `edgezero_core::secret_store::SecretStore`. - -#[cfg(feature = "fastly")] -use async_trait::async_trait; -#[cfg(feature = "fastly")] -use bytes::Bytes; -#[cfg(feature = "fastly")] -use edgezero_core::secret_store::{SecretError, SecretStore}; - -/// Secret store backed by Fastly's SecretStore API. -#[cfg(feature = "fastly")] -pub struct FastlySecretStore { - store: fastly::secret_store::SecretStore, -} - -#[cfg(feature = "fastly")] -impl FastlySecretStore { - /// Open a Fastly SecretStore by name. - /// - /// Returns `SecretError::Internal` if the store does not exist or cannot - /// be opened. Unlike `KVStore::open`, the Fastly SecretStore API returns - /// `Result` (not `Result, _>`), so there - /// is no `ok_or` unwrap here. - pub fn open(name: &str) -> Result { - let store = fastly::secret_store::SecretStore::open(name) - .map_err(|e| { - SecretError::Internal(anyhow::anyhow!("failed to open secret store '{}': {e}", name)) - })?; - Ok(Self { store }) - } -} - -#[cfg(feature = "fastly")] -#[async_trait(?Send)] -impl SecretStore for FastlySecretStore { - async fn get_bytes(&self, name: &str) -> Result, SecretError> { - match self.store.get(name) { - Some(secret) => Ok(Some(secret.plaintext())), - None => Ok(None), - } - } -} -``` - -- [ ] **Step 4.2: Add `dispatch_with_secrets` and `dispatch_with_kv_and_secrets` to `request.rs`** - -In `crates/edgezero-adapter-fastly/src/request.rs`, add imports: -```rust -use edgezero_core::secret_store::SecretHandle; -``` - -Add after `dispatch_with_kv`: - -```rust -/// Dispatch a Fastly request with a secret store attached. -pub fn dispatch_with_secrets( - app: &App, - req: FastlyRequest, - secret_store_name: &str, - secrets_required: bool, -) -> Result { - let mut core_request = into_core_request(req).map_err(map_edge_error)?; - - match crate::secret_store::FastlySecretStore::open(secret_store_name) { - Ok(store) => { - let handle = SecretHandle::new(std::sync::Arc::new(store)); - core_request.extensions_mut().insert(handle); - } - Err(e) => { - if secrets_required { - return Err(FastlyError::msg(format!( - "secret store '{}' is explicitly configured but could not be opened: {}", - secret_store_name, e - ))); - } - warn_missing_secret_store_once(secret_store_name, &e); - } - } - - let response = executor::block_on(app.router().oneshot(core_request)); - from_core_response(response).map_err(map_edge_error) -} - -/// Dispatch a Fastly request with both KV and secret stores attached. -pub fn dispatch_with_kv_and_secrets( - app: &App, - req: FastlyRequest, - kv_store_name: &str, - kv_required: bool, - secret_store_name: &str, - secrets_required: bool, -) -> Result { - let mut core_request = into_core_request(req).map_err(map_edge_error)?; - - match FastlyKvStore::open(kv_store_name) { - Ok(store) => { - let handle = KvHandle::new(std::sync::Arc::new(store)); - core_request.extensions_mut().insert(handle); - } - Err(e) => { - if kv_required { - return Err(FastlyError::msg(format!( - "KV store '{}' is explicitly configured but could not be opened: {}", - kv_store_name, e - ))); - } - warn_missing_kv_store_once(kv_store_name, &e); - } - } - - match crate::secret_store::FastlySecretStore::open(secret_store_name) { - Ok(store) => { - let handle = SecretHandle::new(std::sync::Arc::new(store)); - core_request.extensions_mut().insert(handle); - } - Err(e) => { - if secrets_required { - return Err(FastlyError::msg(format!( - "secret store '{}' is explicitly configured but could not be opened: {}", - secret_store_name, e - ))); - } - warn_missing_secret_store_once(secret_store_name, &e); - } - } - - let response = executor::block_on(app.router().oneshot(core_request)); - from_core_response(response).map_err(map_edge_error) -} - -fn warn_missing_secret_store_once(name: &str, error: &impl std::fmt::Display) { - static WARNED: OnceLock>> = OnceLock::new(); - let warned = WARNED.get_or_init(|| Mutex::new(BTreeSet::new())); - match warned.lock() { - Ok(mut warned) => { - if !warned.insert(name.to_string()) { - return; - } - log::warn!("secret store '{}' not available: {}", name, error); - } - Err(_) => { - log::warn!("secret store '{}' not available: {}", name, error); - } - } -} -``` - -- [ ] **Step 4.3: Update `run_app` and `run_app_with_logging` in `lib.rs` to handle secrets** - -In `crates/edgezero-adapter-fastly/src/lib.rs`, update `run_app`: - -```rust -#[cfg(feature = "fastly")] -pub fn run_app( - manifest_src: &str, - req: fastly::Request, -) -> Result { - let manifest_loader = edgezero_core::manifest::ManifestLoader::load_from_str(manifest_src); - let manifest = manifest_loader.manifest(); - let logging = manifest.logging_or_default("fastly"); - let kv_name = manifest.kv_store_name("fastly").to_string(); - let kv_required = manifest.stores.kv.is_some(); - let secret_name = manifest.secret_store_name("fastly").to_string(); - let secrets_required = manifest.stores.secrets.is_some(); - run_app_with_logging::( - logging.into(), - req, - &kv_name, - kv_required, - &secret_name, - secrets_required, - ) -} -``` - -Update `run_app_with_logging` signature and body: - -```rust -#[cfg(feature = "fastly")] -pub(crate) fn run_app_with_logging( - logging: FastlyLogging, - req: fastly::Request, - kv_store_name: &str, - kv_required: bool, - secret_store_name: &str, - secrets_required: bool, -) -> Result { - if logging.use_fastly_logger { - let endpoint = logging.endpoint.as_deref().unwrap_or("stdout"); - init_logger(endpoint, logging.level, logging.echo_stdout).expect("init fastly logger"); - } - let app = A::build_app(); - dispatch_with_kv_and_secrets( - &app, - req, - kv_store_name, - kv_required, - secret_store_name, - secrets_required, - ) -} -``` - -Add `pub mod secret_store` and export `dispatch_with_secrets`, `dispatch_with_kv_and_secrets` to the `pub use request::` line. - -Update `lib.rs` exports: -```rust -#[cfg(feature = "fastly")] -pub mod secret_store; -// ... -#[cfg(feature = "fastly")] -pub use request::{ - dispatch, dispatch_with_kv, dispatch_with_kv_and_secrets, dispatch_with_secrets, - into_core_request, DEFAULT_KV_STORE_NAME, -}; -``` - -Also add: -```rust -#[cfg(feature = "fastly")] -pub use secret_store::FastlySecretStore; -``` - -- [ ] **Step 4.4: Verify existing tests in `lib.rs` still pass** - -The only test in `lib.rs` is `fastly_logging_from_manifest_converts_defaults`, which tests `FastlyLogging::from(...)` and does **not** call `run_app_with_logging` at all. No test changes are needed here. - -- [ ] **Step 4.5: Build check (WASM — compile only)** - -```bash -cargo check -p edgezero-adapter-fastly --features fastly --target wasm32-wasip1 2>&1 | tail -10 -``` - -Expected: no errors. - -- [ ] **Step 4.6: Commit** - -```bash -git add crates/edgezero-adapter-fastly/src/secret_store.rs \ - crates/edgezero-adapter-fastly/src/request.rs \ - crates/edgezero-adapter-fastly/src/lib.rs -git commit -m "feat(fastly): add FastlySecretStore adapter and dispatch_with_secrets" -``` - ---- - -## Task 5: Cloudflare secret adapter - -Closes #62. - -**Files:** -- Create: `crates/edgezero-adapter-cloudflare/src/secret_store.rs` -- Modify: `crates/edgezero-adapter-cloudflare/src/lib.rs` -- Modify: `crates/edgezero-adapter-cloudflare/src/request.rs` - -- [ ] **Step 5.1: Create `CloudflareSecretStore`** - -Create `crates/edgezero-adapter-cloudflare/src/secret_store.rs`: - -```rust -//! Cloudflare Workers secret adapter. -//! -//! Reads secrets from `worker::Env::secret()`. Each call to `get_bytes(name)` -//! invokes `env.secret(name)` to retrieve the value. The `Env` is cloned at -//! dispatch time to outlive `into_core_request`'s ownership of the original. -//! -//! Note: Cloudflare Workers Secrets have no namespace concept — each secret -//! is an individual `[vars]` / Secrets binding in `wrangler.toml`. The -//! `[stores.secrets] name` in `edgezero.toml` is used only for Fastly; -//! Cloudflare accesses all secrets via this adapter regardless of name. - -#[cfg(all(feature = "cloudflare", target_arch = "wasm32"))] -use async_trait::async_trait; -#[cfg(all(feature = "cloudflare", target_arch = "wasm32"))] -use bytes::Bytes; -#[cfg(all(feature = "cloudflare", target_arch = "wasm32"))] -use edgezero_core::secret_store::{SecretError, SecretStore}; - -/// Secret store backed by Cloudflare Workers `Env`. -/// -/// Reads secrets via `env.secret(name)`. Clones the `Env` handle at dispatch -/// time so secrets remain accessible throughout the request lifetime. -#[cfg(all(feature = "cloudflare", target_arch = "wasm32"))] -pub struct CloudflareSecretStore { - env: worker::Env, -} - -#[cfg(all(feature = "cloudflare", target_arch = "wasm32"))] -impl CloudflareSecretStore { - /// Create a secret store from a cloned `Env`. - pub fn from_env(env: worker::Env) -> Self { - Self { env } - } -} - -#[cfg(all(feature = "cloudflare", target_arch = "wasm32"))] -#[async_trait(?Send)] -impl SecretStore for CloudflareSecretStore { - async fn get_bytes(&self, name: &str) -> Result, SecretError> { - match self.env.secret(name) { - Ok(secret) => { - let value = secret.to_string(); - Ok(Some(Bytes::from(value.into_bytes()))) - } - // Workers returns an error when a secret binding is absent - Err(_) => Ok(None), - } - } -} -``` - -- [ ] **Step 5.2: Add `dispatch_with_secrets` and `dispatch_with_kv_and_secrets` to `request.rs`** - -In `crates/edgezero-adapter-cloudflare/src/request.rs`, add import: -```rust -use edgezero_core::secret_store::SecretHandle; -``` - -Add after `dispatch_with_kv`: - -```rust -/// Dispatch a Cloudflare Worker request with a secret store attached. -/// -/// Note: `_secrets_required` is intentionally unused. Cloudflare Worker Secrets -/// are individually bound in `wrangler.toml`; there is no namespace to "open" -/// that could fail. The store is always successfully constructed from `Env`. -/// Individual missing secrets surface as `SecretError::NotFound` at access time. -pub async fn dispatch_with_secrets( - app: &App, - req: CfRequest, - env: Env, - ctx: Context, - _secrets_required: bool, -) -> Result { - // Clone env before consuming it in into_core_request. - // Env wraps a JsValue reference; cloning increments the JS reference count. - let secret_store = - crate::secret_store::CloudflareSecretStore::from_env(env.clone()); - let secret_handle = SecretHandle::new(std::sync::Arc::new(secret_store)); - - let mut core_request = into_core_request(req, env, ctx) - .await - .map_err(edge_error_to_worker)?; - core_request.extensions_mut().insert(secret_handle); - - let svc = app.router().clone(); - let response = svc.oneshot(core_request).await; - from_core_response(response).map_err(edge_error_to_worker) -} - -/// Dispatch a Cloudflare Worker request with both KV and secret stores attached. -pub async fn dispatch_with_kv_and_secrets( - app: &App, - req: CfRequest, - env: Env, - ctx: Context, - kv_binding: &str, - kv_required: bool, - _secret_binding: &str, // unused: CF secrets have no namespace concept - _secrets_required: bool, // unused: CloudflareSecretStore always constructs OK -) -> Result { - // Open KV by borrowing env - let kv_handle = match crate::key_value_store::CloudflareKvStore::from_env(&env, kv_binding) { - Ok(store) => Some(KvHandle::new(std::sync::Arc::new(store))), - Err(e) => { - if kv_required { - return Err(WorkerError::RustError(format!( - "KV binding '{}' is explicitly configured but could not be opened: {}", - kv_binding, e - ))); - } - warn_missing_kv_binding_once(kv_binding, &e); - None - } - }; - - // Clone env for secrets before consuming it - let secret_store = - crate::secret_store::CloudflareSecretStore::from_env(env.clone()); - let secret_handle = SecretHandle::new(std::sync::Arc::new(secret_store)); - - let mut core_request = into_core_request(req, env, ctx) - .await - .map_err(edge_error_to_worker)?; - - if let Some(handle) = kv_handle { - core_request.extensions_mut().insert(handle); - } - core_request.extensions_mut().insert(secret_handle); - - let svc = app.router().clone(); - let response = svc.oneshot(core_request).await; - from_core_response(response).map_err(edge_error_to_worker) -} -``` - -- [ ] **Step 5.3: Update `run_app` in `lib.rs` to handle secrets** - -In `crates/edgezero-adapter-cloudflare/src/lib.rs`, update `run_app`: - -```rust -#[cfg(all(feature = "cloudflare", target_arch = "wasm32"))] -pub async fn run_app( - manifest_src: &str, - req: worker::Request, - env: worker::Env, - ctx: worker::Context, -) -> Result { - init_logger().expect("init cloudflare logger"); - let manifest_loader = edgezero_core::manifest::ManifestLoader::load_from_str(manifest_src); - let manifest = manifest_loader.manifest(); - let kv_binding = manifest.kv_store_name("cloudflare"); - let kv_required = manifest.stores.kv.is_some(); - let secret_binding = manifest.secret_store_name("cloudflare"); - let secrets_required = manifest.stores.secrets.is_some(); - let app = A::build_app(); - dispatch_with_kv_and_secrets( - &app, req, env, ctx, kv_binding, kv_required, secret_binding, secrets_required, - ) - .await -} -``` - -Update `lib.rs` exports to include `secret_store` module and new dispatch functions: - -```rust -#[cfg(all(feature = "cloudflare", target_arch = "wasm32"))] -pub mod secret_store; - -// in pub use request::{ ... } line: -pub use request::{ - dispatch, dispatch_with_kv, dispatch_with_kv_and_secrets, dispatch_with_secrets, - into_core_request, DEFAULT_KV_BINDING, -}; - -// add: -#[cfg(all(feature = "cloudflare", target_arch = "wasm32"))] -pub use secret_store::CloudflareSecretStore; -``` - -- [ ] **Step 5.4: Build check (WASM — compile only)** - -```bash -cargo check -p edgezero-adapter-cloudflare --features cloudflare --target wasm32-unknown-unknown 2>&1 | tail -10 -``` - -Expected: no errors. - -- [ ] **Step 5.5: Commit** - -```bash -git add crates/edgezero-adapter-cloudflare/src/secret_store.rs \ - crates/edgezero-adapter-cloudflare/src/request.rs \ - crates/edgezero-adapter-cloudflare/src/lib.rs -git commit -m "feat(cloudflare): add CloudflareSecretStore adapter and dispatch_with_secrets" -``` - ---- - -## Task 6: Axum secret adapter + dev server integration - -Closes #63. - -**Files:** -- Create: `crates/edgezero-adapter-axum/src/secret_store.rs` -- Modify: `crates/edgezero-adapter-axum/src/lib.rs` -- Modify: `crates/edgezero-adapter-axum/src/service.rs` -- Modify: `crates/edgezero-adapter-axum/src/dev_server.rs` - -- [ ] **Step 6.1: Write failing tests for `EnvSecretStore`** - -Create `crates/edgezero-adapter-axum/src/secret_store.rs` with tests first: - -```rust -//! Environment variable secret store for local development. -//! -//! Reads secrets from `std::env::var(name)`. Set secrets as environment -//! variables before starting the dev server: -//! -//! ```bash -//! API_KEY=mysecret cargo edgezero dev -//! ``` -//! -//! Or load them from a `.env` file using `dotenvy::dotenv()` in your -//! application entry point before calling `run_app`. - -// ... (implementation here, see Step 6.2) - -#[cfg(test)] -mod tests { - use super::*; - use bytes::Bytes; - use futures::executor::block_on; - - #[test] - fn get_bytes_returns_none_when_var_not_set() { - // Use a name that's very unlikely to be set in the environment - let store = EnvSecretStore::new(); - let result = block_on(store.get_bytes("__EDGEZERO_TEST_MISSING_VAR_XYZ__")).unwrap(); - assert!(result.is_none()); - } - - #[test] - fn get_bytes_returns_value_when_var_set() { - std::env::set_var("__EDGEZERO_TEST_SECRET__", "test_value_123"); - let store = EnvSecretStore::new(); - let result = block_on(store.get_bytes("__EDGEZERO_TEST_SECRET__")).unwrap(); - assert_eq!(result, Some(Bytes::from("test_value_123"))); - std::env::remove_var("__EDGEZERO_TEST_SECRET__"); - } -} -``` - -- [ ] **Step 6.2: Implement `EnvSecretStore` (insert before the test module)** - -```rust -use async_trait::async_trait; -use bytes::Bytes; -use edgezero_core::secret_store::{SecretError, SecretStore}; - -/// Secret store for local development that reads secrets from environment variables. -/// -/// When `[stores.secrets]` is declared in `edgezero.toml`, the dev server -/// creates an `EnvSecretStore` that reads secrets from the process environment. -/// -/// Populate secrets by setting environment variables before starting the server: -/// ```bash -/// MY_API_KEY=secret cargo edgezero dev -/// ``` -pub struct EnvSecretStore; - -impl EnvSecretStore { - pub fn new() -> Self { - Self - } -} - -impl Default for EnvSecretStore { - fn default() -> Self { - Self::new() - } -} - -#[async_trait(?Send)] -impl SecretStore for EnvSecretStore { - async fn get_bytes(&self, name: &str) -> Result, SecretError> { - match std::env::var(name) { - Ok(value) => Ok(Some(Bytes::from(value.into_bytes()))), - Err(std::env::VarError::NotPresent) => Ok(None), - Err(std::env::VarError::NotUnicode(os_str)) => { - Err(SecretError::Internal(anyhow::anyhow!( - "secret '{}' contains non-UTF-8 bytes: {:?}", - name, - os_str - ))) - } - } - } -} -``` - -- [ ] **Step 6.3: Run tests for `EnvSecretStore`** - -```bash -cargo test -p edgezero-adapter-axum secret_store 2>&1 | tail -15 -``` - -Expected: all 2 tests pass. - -- [ ] **Step 6.4: Add `with_secret_handle()` to `EdgeZeroAxumService`** - -In `crates/edgezero-adapter-axum/src/service.rs`: - -Add import: -```rust -use edgezero_core::secret_store::SecretHandle; -``` - -Add field to `EdgeZeroAxumService`: -```rust -pub struct EdgeZeroAxumService { - router: RouterService, - kv_handle: Option, - secret_handle: Option, // NEW -} -``` - -Update `new()`: -```rust -pub fn new(router: RouterService) -> Self { - Self { - router, - kv_handle: None, - secret_handle: None, - } -} -``` - -Add method after `with_kv_handle`: -```rust -/// Attach a shared secret store to this service. -/// -/// The handle is cloned into every request's extensions, making -/// the `Secrets` extractor available in handlers. -#[must_use] -pub fn with_secret_handle(mut self, handle: SecretHandle) -> Self { - self.secret_handle = Some(handle); - self -} -``` - -Update `call()` to inject secret handle after kv handle: -```rust -let secret_handle = self.secret_handle.clone(); -// ... in the async block, after kv handle injection: -if let Some(handle) = secret_handle { - core_request.extensions_mut().insert(handle); -} -``` - -Write a test for `with_secret_handle`: - -```rust -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn with_secret_handle_injects_into_request() { - use crate::secret_store::EnvSecretStore; - use edgezero_core::secret_store::SecretHandle; - use std::sync::Arc; - - std::env::set_var("__EDGEZERO_SERVICE_TEST_SECRET__", "injected_value"); - - let handle = SecretHandle::new(Arc::new(EnvSecretStore::new())); - let router = RouterService::builder() - .get("/check", |ctx: RequestContext| async move { - let secrets = ctx.secret_handle().expect("secret handle should be present"); - let val = secrets - .get_str("__EDGEZERO_SERVICE_TEST_SECRET__") - .await - .unwrap() - .unwrap_or_default(); - let response = response_builder() - .status(StatusCode::OK) - .body(Body::from(val)) - .expect("response"); - Ok::<_, EdgeError>(response) - }) - .build(); - let mut service = EdgeZeroAxumService::new(router).with_secret_handle(handle); - - let request = Request::builder().uri("/check").body(AxumBody::empty()).unwrap(); - let response = service.ready().await.unwrap().call(request).await.unwrap(); - assert_eq!(response.status(), StatusCode::OK); - let body = axum::body::to_bytes(response.into_body(), usize::MAX).await.unwrap(); - assert_eq!(&body[..], b"injected_value"); - - std::env::remove_var("__EDGEZERO_SERVICE_TEST_SECRET__"); -} -``` - -- [ ] **Step 6.5: Wire `EnvSecretStore` into `dev_server.rs`** - -In `crates/edgezero-adapter-axum/src/dev_server.rs`, add: - -```rust -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -enum SecretInitRequirement { - Optional, - Required, -} - -fn secret_init_requirement( - manifest: &edgezero_core::manifest::Manifest, -) -> SecretInitRequirement { - if manifest.stores.secrets.is_some() { - SecretInitRequirement::Required - } else { - SecretInitRequirement::Optional - } -} - -fn secret_handle_from_env( - store_name: &str, - requirement: SecretInitRequirement, -) -> Option { - let store = std::sync::Arc::new(crate::secret_store::EnvSecretStore::new()); - let handle = edgezero_core::secret_store::SecretHandle::new(store); - if requirement == SecretInitRequirement::Required { - log::info!("Secret store '{}': reading from environment variables", store_name); - } - Some(handle) -} -``` - -Update `serve_with_listener_and_kv_handle` — rename it to `serve_with_listener_and_stores` and add secret handle parameter: - -```rust -async fn serve_with_listener_and_stores( - router: RouterService, - listener: tokio::net::TcpListener, - enable_ctrl_c: bool, - kv_handle: Option, - secret_handle: Option, -) -> anyhow::Result<()> { - let mut service = EdgeZeroAxumService::new(router); - if let Some(kv_handle) = kv_handle { - service = service.with_kv_handle(kv_handle); - } - if let Some(secret_handle) = secret_handle { - service = service.with_secret_handle(secret_handle); - } - // ... rest same as current serve_with_listener_and_kv_handle -} -``` - -Update all three callers of `serve_with_listener_and_kv_handle` — enumerate them explicitly so nothing is missed: - -1. `serve_with_listener_and_kv_path` (line ~199) — change its call to `serve_with_listener_and_stores(..., None)` (no secrets; this path is used by the manifest-unaware `AxumDevServer::run` embedding API) -2. `serve_with_listener` (line ~187) — delegates to `serve_with_listener_and_kv_path`, no change needed here beyond the above -3. `run_app` (line ~297) — updated below to pass a real `secret_handle` - -The private test helper `AxumDevServer::run_with_listener` calls `serve_with_listener_and_kv_path`, so updating #1 above covers it automatically. - -Update `run_app` to initialize and pass the secret handle: - -```rust -pub fn run_app(manifest_src: &str) -> anyhow::Result<()> { - let manifest = ManifestLoader::load_from_str(manifest_src); - let manifest = manifest.manifest(); - // ... existing kv setup ... - let secret_init_requirement = secret_init_requirement(manifest); - let secret_store_name = manifest.secret_store_name("axum").to_string(); - - // ... in async block, after kv_handle: - let secret_handle = - secret_handle_from_env(&secret_store_name, secret_init_requirement); - serve_with_listener_and_stores(router, listener, config.enable_ctrl_c, kv_handle, secret_handle).await -} -``` - -- [ ] **Step 6.6: Update `lib.rs` to export `EnvSecretStore` and `secret_store` module** - -In `crates/edgezero-adapter-axum/src/lib.rs`, add: -```rust -#[cfg(feature = "axum")] -pub mod secret_store; - -// in pub use: -#[cfg(feature = "axum")] -pub use secret_store::EnvSecretStore; -``` - -- [ ] **Step 6.7: Run all axum adapter tests** - -```bash -cargo test -p edgezero-adapter-axum 2>&1 | tail -20 -``` - -Expected: all tests pass. - -- [ ] **Step 6.8: Commit** - -```bash -git add crates/edgezero-adapter-axum/src/secret_store.rs \ - crates/edgezero-adapter-axum/src/lib.rs \ - crates/edgezero-adapter-axum/src/service.rs \ - crates/edgezero-adapter-axum/src/dev_server.rs -git commit -m "feat(axum): add EnvSecretStore for local dev and wire into service/dev server" -``` - ---- - -## Task 7: CLI build-time validation - -Closes #66. - -**Files:** -- Modify: `crates/edgezero-cli/src/main.rs` - -This task adds informational output during `edgezero build` so developers know what secret store bindings they need to configure on each platform. - -- [ ] **Step 7.1: Write test for secret store info message** - -Add to `crates/edgezero-cli/src/main.rs` test block: - -```rust -#[test] -fn secret_store_name_is_readable_from_manifest() { - let manifest_with_secrets = r#" -[app] -name = "demo-app" -entry = "crates/demo-core" - -[stores.secrets] -name = "MY_SECRETS" - -[adapters.fastly.commands] -build = "echo build" -deploy = "echo deploy" -serve = "echo serve" -"#; - let loader = ManifestLoader::load_from_str(manifest_with_secrets); - assert_eq!( - loader.manifest().secret_store_name("fastly"), - "MY_SECRETS" - ); - assert!(loader.manifest().stores.secrets.is_some()); -} -``` - -- [ ] **Step 7.2: Add `log_store_bindings` function and call it from `handle_build`** - -In `crates/edgezero-cli/src/main.rs`, add this function: - -```rust -#[cfg(feature = "cli")] -fn log_store_bindings(adapter_name: &str, manifest: &ManifestLoader) { - let m = manifest.manifest(); - if let Some(ref secrets) = m.stores.secrets { - let binding_name = m.secret_store_name(adapter_name); - println!( - "[edgezero] secret store '{binding_name}' declared -- \ - ensure it is provisioned on the {adapter_name} platform \ - (global name: '{}')", - secrets.name - ); - } -} -``` - -Update `handle_build` to call it: - -```rust -#[cfg(feature = "cli")] -fn handle_build(adapter_name: &str, adapter_args: &[String]) -> Result<(), String> { - let manifest = load_manifest_optional()?; - ensure_adapter_defined(adapter_name, manifest.as_ref())?; - if let Some(ref m) = manifest { - log_store_bindings(adapter_name, m); - } - adapter::execute( - adapter_name, - adapter::Action::Build, - manifest.as_ref(), - adapter_args, - ) -} -``` - -- [ ] **Step 7.3: Run tests** - -```bash -cargo test -p edgezero-cli 2>&1 | tail -15 -``` - -Expected: all tests pass. - -- [ ] **Step 7.4: Commit** - -```bash -git add crates/edgezero-cli/src/main.rs -git commit -m "feat(cli): log secret store binding info during edgezero build" -``` - ---- - -## Task 8: Secret store trait contract tests and compile-time adapter type checks - -Closes #67. - -**What this task actually verifies:** -- `InMemorySecretStore` in `edgezero-core`: proves the `SecretStore` trait contract (get existing, get missing, read two different keys) -- Axum contract invocation: runs the same macro against `InMemorySecretStore` to prove the macro is importable from adapter crates -- `EnvSecretStore` behavior: tested independently in Task 6 unit tests (env var present/absent scenarios) -- Fastly / Cloudflare: compile-time checks only — `FastlySecretStore` and `CloudflareSecretStore` implement `SecretStore` (platform calls cannot run in CI) - -**Files:** -- Modify: `crates/edgezero-adapter-axum/Cargo.toml` (add `edgezero-core` with `test-utils` to dev-dependencies) -- Modify: `crates/edgezero-adapter-axum/src/secret_store.rs` (add contract tests using InMemorySecretStore) -- Modify: `crates/edgezero-core/src/secret_store.rs` (add contract test invocation for InMemorySecretStore) -- Modify: `crates/edgezero-adapter-fastly/tests/contract.rs` (add compile-only secret store stub) -- Modify: `crates/edgezero-adapter-cloudflare/tests/contract.rs` (add compile-only secret store stub) - -- [ ] **Step 8.1: Enable `test-utils` in axum adapter's dev-dependencies** - -`InMemorySecretStore` is gated behind `#[cfg(any(test, feature = "test-utils"))]` in `edgezero-core`. The `test` cfg only applies within `edgezero-core`'s own test compilation — external crates cannot see it unless the feature is explicitly enabled. Add to `crates/edgezero-adapter-axum/Cargo.toml`: - -```toml -[dev-dependencies] -# ... existing dev-dependencies ... -edgezero-core = { workspace = true, features = ["test-utils"] } -``` - -Check if `edgezero-core` already appears in `[dev-dependencies]`; if so, just add `features = ["test-utils"]` to the existing entry. - -- [ ] **Step 8.2: Add `InMemorySecretStore` contract test in `edgezero-core`** - -In `crates/edgezero-core/src/secret_store.rs`, inside the `#[cfg(test)]` module, add: - -```rust -use crate::secret_store_contract_tests; - -secret_store_contract_tests!(in_memory_contract, { - InMemorySecretStore::new([ - ("contract_key", Bytes::from("contract_value")), - ("contract_key_2", Bytes::from("another_value")), - ]) -}); -``` - -- [ ] **Step 8.3: Add `EnvSecretStore` contract test in axum adapter** - -In `crates/edgezero-adapter-axum/src/secret_store.rs`, in the `#[cfg(test)]` module, add after the existing tests: - -```rust -// Contract tests: use InMemorySecretStore since EnvSecretStore needs -// real env vars, which are unsafe in parallel tests. -// The EnvSecretStore is tested individually above. -use edgezero_core::secret_store::InMemorySecretStore; -use edgezero_core::secret_store_contract_tests; - -secret_store_contract_tests!(env_secret_contract, { - InMemorySecretStore::new([ - ("contract_key", Bytes::from("contract_value")), - ("contract_key_2", Bytes::from("another_value")), - ]) -}); -``` - -Note: We test `EnvSecretStore` behavior directly in its own unit tests (Steps 6.1–6.2). The contract tests use `InMemorySecretStore` to verify interface contract without env var race conditions. - -- [ ] **Step 8.4: Add compile-only secret store stubs to Fastly contract tests** - -In `crates/edgezero-adapter-fastly/tests/contract.rs`, add at the bottom: - -```rust -// Secret store contract tests for Fastly require a running Fastly Compute -// environment and cannot be executed in CI. The FastlySecretStore type is -// verified at compile time here. -#[cfg(all(feature = "fastly", target_arch = "wasm32"))] -mod secret_store_compile_check { - use edgezero_adapter_fastly::FastlySecretStore; - use edgezero_core::secret_store::SecretStore; - - // Compile-time check: FastlySecretStore implements SecretStore - fn _assert_impl() {} - fn _check() { - // This function is never called; it only verifies trait impl at compile time. - _assert_impl::(); - } -} -``` - -- [ ] **Step 8.5: Add compile-only secret store stubs to Cloudflare contract tests** - -In `crates/edgezero-adapter-cloudflare/tests/contract.rs`, add at the bottom: - -```rust -#[cfg(all(feature = "cloudflare", target_arch = "wasm32"))] -mod secret_store_compile_check { - use edgezero_adapter_cloudflare::CloudflareSecretStore; - use edgezero_core::secret_store::SecretStore; - - fn _assert_impl() {} - fn _check() { - _assert_impl::(); - } -} -``` - -- [ ] **Step 8.6: Run full test suite** - -```bash -cargo test --workspace --all-targets 2>&1 | tail -30 -``` - -Expected: all tests pass. - -- [ ] **Step 8.7: Run full CI gate checks** - -```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" -``` - -Expected: all four pass. - -- [ ] **Step 8.7: Commit** - -```bash -git add crates/edgezero-core/src/secret_store.rs \ - crates/edgezero-adapter-axum/src/secret_store.rs \ - crates/edgezero-adapter-fastly/tests/contract.rs \ - crates/edgezero-adapter-cloudflare/tests/contract.rs -git commit -m "test: add secret store contract tests across all adapters" -``` - ---- - -## Summary - -| Task | Files Changed | Tests Added | Closes | -|------|---------------|-------------|--------| -| 1 | `core/secret_store.rs`, `core/lib.rs` | 8 unit tests | #60 | -| 2 | `core/context.rs`, `core/extractor.rs` | 4 unit tests | #64 | -| 3 | `core/manifest.rs` | 5 unit tests | #65 | -| 4 | `fastly/secret_store.rs`, `fastly/request.rs`, `fastly/lib.rs` | compile check | #61 | -| 5 | `cloudflare/secret_store.rs`, `cloudflare/request.rs`, `cloudflare/lib.rs` | compile check | #62 | -| 6 | `axum/secret_store.rs`, `axum/lib.rs`, `axum/service.rs`, `axum/dev_server.rs` | 3 unit + 1 service test | #63 | -| 7 | `cli/main.rs` | 1 unit test | #66 | -| 8 | multiple | 3 contract tests + 2 compile checks | #67 | - -**Usage example** (after implementation): - -```toml -# edgezero.toml -[stores.secrets] -name = "MY_APP_SECRETS" - -[stores.secrets.adapters.fastly] -name = "MY_APP_SECRETS" # Fastly SecretStore name in fastly.toml -``` - -```rust -// handler -#[action] -pub async fn fetch_data(Secrets(secrets): Secrets) -> Result { - let api_key = secrets.require_str("API_KEY").await.map_err(EdgeError::from)?; - // use api_key ... -} -``` From 8b705f9cc05d5df386ffefb8a0ccd9f12655b96a Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Tue, 24 Mar 2026 14:54:47 +0530 Subject: [PATCH 10/18] feat(axum): add EnvSecretStore for local dev and wire into service/dev server --- .../edgezero-adapter-axum/src/dev_server.rs | 30 ++++++++- crates/edgezero-adapter-axum/src/lib.rs | 4 ++ .../edgezero-adapter-axum/src/secret_store.rs | 66 +++++++++++++++++++ crates/edgezero-adapter-axum/src/service.rs | 60 +++++++++++++++++ 4 files changed, 157 insertions(+), 3 deletions(-) create mode 100644 crates/edgezero-adapter-axum/src/secret_store.rs diff --git a/crates/edgezero-adapter-axum/src/dev_server.rs b/crates/edgezero-adapter-axum/src/dev_server.rs index a984cdb..7fe5bc2 100644 --- a/crates/edgezero-adapter-axum/src/dev_server.rs +++ b/crates/edgezero-adapter-axum/src/dev_server.rs @@ -196,19 +196,23 @@ async fn serve_with_listener_and_kv_path( let kv_handle = kv_path .map(|kv_path| kv_handle_from_path(Path::new(kv_path))) .transpose()?; - serve_with_listener_and_kv_handle(router, listener, enable_ctrl_c, kv_handle).await + serve_with_listener_and_stores(router, listener, enable_ctrl_c, kv_handle, None).await } -async fn serve_with_listener_and_kv_handle( +async fn serve_with_listener_and_stores( router: RouterService, listener: tokio::net::TcpListener, enable_ctrl_c: bool, kv_handle: Option, + secret_handle: Option, ) -> anyhow::Result<()> { let mut service = EdgeZeroAxumService::new(router); if let Some(kv_handle) = kv_handle { service = service.with_kv_handle(kv_handle); } + if let Some(secret_handle) = secret_handle { + service = service.with_secret_handle(secret_handle); + } let service = service; let router = Router::new().fallback_service(service_fn(move |req| { @@ -243,6 +247,8 @@ pub fn run_app(manifest_src: &str) -> anyhow::Result<()> { let kv_init_requirement = kv_init_requirement(manifest); let kv_store_name = manifest.kv_store_name("axum").to_string(); let kv_path = kv_store_path(&kv_store_name); + let secret_store_name = manifest.secret_store_name("axum").to_string(); + let has_secret_store = manifest.stores.secrets.is_some(); let level: LevelFilter = logging.level.into(); let level = if logging.echo_stdout.unwrap_or(true) { @@ -294,7 +300,25 @@ pub fn run_app(manifest_src: &str) -> anyhow::Result<()> { } } }; - serve_with_listener_and_kv_handle(router, listener, config.enable_ctrl_c, kv_handle).await + let secret_handle = if has_secret_store { + log::info!( + "Secret store '{}': reading from environment variables", + secret_store_name + ); + Some(edgezero_core::secret_store::SecretHandle::new( + std::sync::Arc::new(crate::secret_store::EnvSecretStore::new()), + )) + } else { + None + }; + serve_with_listener_and_stores( + router, + listener, + config.enable_ctrl_c, + kv_handle, + secret_handle, + ) + .await }) } diff --git a/crates/edgezero-adapter-axum/src/lib.rs b/crates/edgezero-adapter-axum/src/lib.rs index ef78ffe..0d97448 100644 --- a/crates/edgezero-adapter-axum/src/lib.rs +++ b/crates/edgezero-adapter-axum/src/lib.rs @@ -13,6 +13,8 @@ mod request; #[cfg(feature = "axum")] mod response; #[cfg(feature = "axum")] +pub mod secret_store; +#[cfg(feature = "axum")] mod service; #[cfg(feature = "cli")] @@ -31,4 +33,6 @@ pub use request::into_core_request; #[cfg(feature = "axum")] pub use response::into_axum_response; #[cfg(feature = "axum")] +pub use secret_store::EnvSecretStore; +#[cfg(feature = "axum")] pub use service::EdgeZeroAxumService; diff --git a/crates/edgezero-adapter-axum/src/secret_store.rs b/crates/edgezero-adapter-axum/src/secret_store.rs new file mode 100644 index 0000000..9806c5b --- /dev/null +++ b/crates/edgezero-adapter-axum/src/secret_store.rs @@ -0,0 +1,66 @@ +//! Environment variable secret store for local development. +//! +//! Reads secrets from `std::env::var(name)`. Set secrets as environment +//! variables before starting the dev server: +//! +//! ```bash +//! API_KEY=mysecret cargo edgezero dev +//! ``` + +use async_trait::async_trait; +use bytes::Bytes; +use edgezero_core::secret_store::{SecretError, SecretStore}; + +/// Secret store for local development that reads secrets from environment variables. +/// +/// When `[stores.secrets]` is declared in `edgezero.toml`, the dev server +/// creates an `EnvSecretStore` that reads secrets from the process environment. +pub struct EnvSecretStore; + +impl EnvSecretStore { + pub fn new() -> Self { + Self + } +} + +impl Default for EnvSecretStore { + fn default() -> Self { + Self::new() + } +} + +#[async_trait(?Send)] +impl SecretStore for EnvSecretStore { + async fn get_bytes(&self, name: &str) -> Result, SecretError> { + match std::env::var(name) { + Ok(value) => Ok(Some(Bytes::from(value.into_bytes()))), + Err(std::env::VarError::NotPresent) => Ok(None), + Err(std::env::VarError::NotUnicode(os_str)) => Err(SecretError::Internal( + anyhow::anyhow!("secret '{}' contains non-UTF-8 bytes: {:?}", name, os_str), + )), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use bytes::Bytes; + use futures::executor::block_on; + + #[test] + fn get_bytes_returns_none_when_var_not_set() { + let store = EnvSecretStore::new(); + let result = block_on(store.get_bytes("__EDGEZERO_TEST_MISSING_VAR_XYZ__")).unwrap(); + assert!(result.is_none()); + } + + #[test] + fn get_bytes_returns_value_when_var_set() { + std::env::set_var("__EDGEZERO_TEST_SECRET__", "test_value_123"); + let store = EnvSecretStore::new(); + let result = block_on(store.get_bytes("__EDGEZERO_TEST_SECRET__")).unwrap(); + assert_eq!(result, Some(Bytes::from("test_value_123"))); + std::env::remove_var("__EDGEZERO_TEST_SECRET__"); + } +} diff --git a/crates/edgezero-adapter-axum/src/service.rs b/crates/edgezero-adapter-axum/src/service.rs index e273aea..346a3f3 100644 --- a/crates/edgezero-adapter-axum/src/service.rs +++ b/crates/edgezero-adapter-axum/src/service.rs @@ -11,6 +11,7 @@ use tower::Service; use edgezero_core::key_value_store::KvHandle; use edgezero_core::router::RouterService; +use edgezero_core::secret_store::SecretHandle; use crate::request::into_core_request; use crate::response::into_axum_response; @@ -20,6 +21,7 @@ use crate::response::into_axum_response; pub struct EdgeZeroAxumService { router: RouterService, kv_handle: Option, + secret_handle: Option, } impl EdgeZeroAxumService { @@ -27,6 +29,7 @@ impl EdgeZeroAxumService { Self { router, kv_handle: None, + secret_handle: None, } } @@ -39,6 +42,16 @@ impl EdgeZeroAxumService { self.kv_handle = Some(handle); self } + + /// Attach a shared secret store to this service. + /// + /// The handle is cloned into every request's extensions, making + /// the `Secrets` extractor available in handlers. + #[must_use] + pub fn with_secret_handle(mut self, handle: SecretHandle) -> Self { + self.secret_handle = Some(handle); + self + } } impl Service> for EdgeZeroAxumService { @@ -53,6 +66,7 @@ impl Service> for EdgeZeroAxumService { fn call(&mut self, request: Request) -> Self::Future { let router = self.router.clone(); let kv_handle = self.kv_handle.clone(); + let secret_handle = self.secret_handle.clone(); Box::pin(async move { let mut core_request = match into_core_request(request).await { Ok(req) => req, @@ -68,6 +82,10 @@ impl Service> for EdgeZeroAxumService { core_request.extensions_mut().insert(handle); } + if let Some(secret_handle) = secret_handle { + core_request.extensions_mut().insert(secret_handle); + } + let core_response = task::block_in_place(move || { Handle::current().block_on(router.oneshot(core_request)) }); @@ -142,6 +160,48 @@ mod tests { assert_eq!(&body[..], b"injected"); } + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn with_secret_handle_injects_into_request() { + use crate::secret_store::EnvSecretStore; + use edgezero_core::secret_store::SecretHandle; + use std::sync::Arc; + + std::env::set_var("__EDGEZERO_SERVICE_TEST_SECRET__", "injected_value"); + + let handle = SecretHandle::new(Arc::new(EnvSecretStore::new())); + let router = RouterService::builder() + .get("/check", |ctx: RequestContext| async move { + let secrets = ctx + .secret_handle() + .expect("secret handle should be present"); + let val = secrets + .get_str("__EDGEZERO_SERVICE_TEST_SECRET__") + .await + .unwrap() + .unwrap_or_default(); + let response = response_builder() + .status(StatusCode::OK) + .body(Body::from(val)) + .expect("response"); + Ok::<_, EdgeError>(response) + }) + .build(); + let mut service = EdgeZeroAxumService::new(router).with_secret_handle(handle); + + let request = Request::builder() + .uri("/check") + .body(AxumBody::empty()) + .unwrap(); + let response = service.ready().await.unwrap().call(request).await.unwrap(); + assert_eq!(response.status(), StatusCode::OK); + let body = axum::body::to_bytes(response.into_body(), usize::MAX) + .await + .unwrap(); + assert_eq!(&body[..], b"injected_value"); + + std::env::remove_var("__EDGEZERO_SERVICE_TEST_SECRET__"); + } + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn service_without_kv_handle_still_works() { let router = RouterService::builder() From 000710bea0a77c2ef1694983341a731de715e47a Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Tue, 24 Mar 2026 14:58:56 +0530 Subject: [PATCH 11/18] feat(cli): log secret store binding info during edgezero build --- crates/edgezero-cli/src/main.rs | 37 +++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/crates/edgezero-cli/src/main.rs b/crates/edgezero-cli/src/main.rs index d562934..8e51c76 100644 --- a/crates/edgezero-cli/src/main.rs +++ b/crates/edgezero-cli/src/main.rs @@ -77,10 +77,27 @@ fn main() { eprintln!("edgezero-cli built without `cli` feature. Rebuild with `--features cli`."); } +#[cfg(feature = "cli")] +fn log_store_bindings(adapter_name: &str, manifest: &ManifestLoader) { + let m = manifest.manifest(); + if let Some(ref secrets) = m.stores.secrets { + let binding_name = m.secret_store_name(adapter_name); + println!( + "[edgezero] secret store '{binding_name}' declared -- \ + ensure it is provisioned on the {adapter_name} platform \ + (global name: '{}')", + secrets.name + ); + } +} + #[cfg(feature = "cli")] fn handle_build(adapter_name: &str, adapter_args: &[String]) -> Result<(), String> { let manifest = load_manifest_optional()?; ensure_adapter_defined(adapter_name, manifest.as_ref())?; + if let Some(ref m) = manifest { + log_store_bindings(adapter_name, m); + } adapter::execute( adapter_name, adapter::Action::Build, @@ -289,4 +306,24 @@ serve = "echo serve" let _env = EnvOverride::set("EDGEZERO_MANIFEST", &manifest_str); handle_serve("fastly").expect("serve command runs"); } + + #[test] + fn secret_store_name_is_readable_from_manifest() { + let manifest_with_secrets = r#" +[app] +name = "demo-app" +entry = "crates/demo-core" + +[stores.secrets] +name = "MY_SECRETS" + +[adapters.fastly.commands] +build = "echo build" +deploy = "echo deploy" +serve = "echo serve" +"#; + let loader = ManifestLoader::load_from_str(manifest_with_secrets); + assert_eq!(loader.manifest().secret_store_name("fastly"), "MY_SECRETS"); + assert!(loader.manifest().stores.secrets.is_some()); + } } From d030304a0f7fcb730e44bdec7c3d4f98b5e46f28 Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Tue, 24 Mar 2026 15:03:10 +0530 Subject: [PATCH 12/18] test: add secret store contract tests across all adapters --- crates/edgezero-adapter-axum/Cargo.toml | 1 + crates/edgezero-adapter-axum/src/secret_store.rs | 13 +++++++++++++ .../tests/contract.rs | 11 +++++++++++ crates/edgezero-adapter-fastly/tests/contract.rs | 16 ++++++++++++++++ crates/edgezero-core/src/secret_store.rs | 7 +++++++ 5 files changed, 48 insertions(+) diff --git a/crates/edgezero-adapter-axum/Cargo.toml b/crates/edgezero-adapter-axum/Cargo.toml index 10356ee..9f9b3c9 100644 --- a/crates/edgezero-adapter-axum/Cargo.toml +++ b/crates/edgezero-adapter-axum/Cargo.toml @@ -50,6 +50,7 @@ walkdir = { workspace = true, optional = true } [dev-dependencies] async-trait = { workspace = true } axum = { workspace = true, features = ["macros"] } +edgezero-core = { path = "../edgezero-core", features = ["test-utils"] } serde = { workspace = true } tempfile = { workspace = true } tokio = { workspace = true, features = ["macros", "rt", "rt-multi-thread"] } diff --git a/crates/edgezero-adapter-axum/src/secret_store.rs b/crates/edgezero-adapter-axum/src/secret_store.rs index 9806c5b..c035d79 100644 --- a/crates/edgezero-adapter-axum/src/secret_store.rs +++ b/crates/edgezero-adapter-axum/src/secret_store.rs @@ -63,4 +63,17 @@ mod tests { assert_eq!(result, Some(Bytes::from("test_value_123"))); std::env::remove_var("__EDGEZERO_TEST_SECRET__"); } + + // Contract tests: use InMemorySecretStore since EnvSecretStore needs + // real env vars, which are unsafe in parallel tests. + // The EnvSecretStore is tested individually above. + use edgezero_core::secret_store::InMemorySecretStore; + use edgezero_core::secret_store_contract_tests; + + secret_store_contract_tests!(env_secret_contract, { + InMemorySecretStore::new([ + ("contract_key", Bytes::from("contract_value")), + ("contract_key_2", Bytes::from("another_value")), + ]) + }); } diff --git a/crates/edgezero-adapter-cloudflare/tests/contract.rs b/crates/edgezero-adapter-cloudflare/tests/contract.rs index 192885d..4ca9603 100644 --- a/crates/edgezero-adapter-cloudflare/tests/contract.rs +++ b/crates/edgezero-adapter-cloudflare/tests/contract.rs @@ -165,3 +165,14 @@ async fn dispatch_passes_request_body_to_handlers() { let bytes = response.bytes().await.expect("bytes"); assert_eq!(bytes.as_slice(), b"echo"); } + +#[cfg(all(feature = "cloudflare", target_arch = "wasm32"))] +mod secret_store_compile_check { + use edgezero_adapter_cloudflare::CloudflareSecretStore; + use edgezero_core::secret_store::SecretStore; + + fn _assert_impl() {} + fn _check() { + _assert_impl::(); + } +} diff --git a/crates/edgezero-adapter-fastly/tests/contract.rs b/crates/edgezero-adapter-fastly/tests/contract.rs index f3c25b3..5029a6d 100644 --- a/crates/edgezero-adapter-fastly/tests/contract.rs +++ b/crates/edgezero-adapter-fastly/tests/contract.rs @@ -141,3 +141,19 @@ fn dispatch_passes_request_body_to_handlers() { assert_eq!(response.get_status(), FastlyStatus::OK); assert_eq!(response.take_body_bytes(), b"echo"); } + +// Secret store contract tests for Fastly require a running Fastly Compute +// environment and cannot be executed in CI. The FastlySecretStore type is +// verified at compile time here. +#[cfg(all(feature = "fastly", target_arch = "wasm32"))] +mod secret_store_compile_check { + use edgezero_adapter_fastly::FastlySecretStore; + use edgezero_core::secret_store::SecretStore; + + // Compile-time check: FastlySecretStore implements SecretStore + fn _assert_impl() {} + fn _check() { + // This function is never called; it only verifies trait impl at compile time. + _assert_impl::(); + } +} diff --git a/crates/edgezero-core/src/secret_store.rs b/crates/edgezero-core/src/secret_store.rs index fdc76a7..aaecd5f 100644 --- a/crates/edgezero-core/src/secret_store.rs +++ b/crates/edgezero-core/src/secret_store.rs @@ -389,4 +389,11 @@ mod tests { assert_eq!(h.require_str("key").await.unwrap(), "value"); }); } + + secret_store_contract_tests!(in_memory_contract, { + InMemorySecretStore::new([ + ("contract_key", Bytes::from("contract_value")), + ("contract_key_2", Bytes::from("another_value")), + ]) + }); } From 9e7b8deaa70d4d85b6f4001cebd2c5520dae2c7a Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Tue, 24 Mar 2026 17:50:08 +0530 Subject: [PATCH 13/18] fix(secret-store): address security findings from review - Prevent secret name leakage: NotFound/Validation/Internal errors now return generic messages with no secret identifiers in response bodies - Enforce consistent adapter opt-in: secret_store_enabled(adapter) replaces stores.secrets.is_some() so per-adapter enabled=false is respected - Fix Fastly SDK panic path: try_get/try_plaintext replace get/plaintext - Add manifest-level enabled flag with per-adapter override support - Fix EnvSecretStore: use var_os on unix for binary secret support - Serialize env-mutation tests with tokio::sync::Mutex + RAII EnvOverride - Add adapter-specific CLI build messages for secret store bindings --- .../edgezero-adapter-axum/src/dev_server.rs | 2 +- .../edgezero-adapter-axum/src/secret_store.rs | 103 +++++++++++++++--- crates/edgezero-adapter-axum/src/service.rs | 35 +++++- crates/edgezero-adapter-cloudflare/src/lib.rs | 30 +++-- .../src/request.rs | 47 +++++--- .../src/secret_store.rs | 14 ++- crates/edgezero-adapter-fastly/src/lib.rs | 24 ++-- crates/edgezero-adapter-fastly/src/request.rs | 46 +++----- .../src/secret_store.rs | 11 +- crates/edgezero-cli/src/main.rs | 64 +++++++++-- crates/edgezero-core/src/manifest.rs | 80 +++++++++++++- crates/edgezero-core/src/secret_store.rs | 64 +++++++++-- 12 files changed, 403 insertions(+), 117 deletions(-) diff --git a/crates/edgezero-adapter-axum/src/dev_server.rs b/crates/edgezero-adapter-axum/src/dev_server.rs index 7fe5bc2..93b2340 100644 --- a/crates/edgezero-adapter-axum/src/dev_server.rs +++ b/crates/edgezero-adapter-axum/src/dev_server.rs @@ -248,7 +248,7 @@ pub fn run_app(manifest_src: &str) -> anyhow::Result<()> { let kv_store_name = manifest.kv_store_name("axum").to_string(); let kv_path = kv_store_path(&kv_store_name); let secret_store_name = manifest.secret_store_name("axum").to_string(); - let has_secret_store = manifest.stores.secrets.is_some(); + let has_secret_store = manifest.secret_store_enabled("axum"); let level: LevelFilter = logging.level.into(); let level = if logging.echo_stdout.unwrap_or(true) { diff --git a/crates/edgezero-adapter-axum/src/secret_store.rs b/crates/edgezero-adapter-axum/src/secret_store.rs index c035d79..fca358e 100644 --- a/crates/edgezero-adapter-axum/src/secret_store.rs +++ b/crates/edgezero-adapter-axum/src/secret_store.rs @@ -1,6 +1,6 @@ //! Environment variable secret store for local development. //! -//! Reads secrets from `std::env::var(name)`. Set secrets as environment +//! Reads secrets from the process environment. Set secrets as environment //! variables before starting the dev server: //! //! ```bash @@ -32,12 +32,25 @@ impl Default for EnvSecretStore { #[async_trait(?Send)] impl SecretStore for EnvSecretStore { async fn get_bytes(&self, name: &str) -> Result, SecretError> { - match std::env::var(name) { - Ok(value) => Ok(Some(Bytes::from(value.into_bytes()))), - Err(std::env::VarError::NotPresent) => Ok(None), - Err(std::env::VarError::NotUnicode(os_str)) => Err(SecretError::Internal( - anyhow::anyhow!("secret '{}' contains non-UTF-8 bytes: {:?}", name, os_str), - )), + #[cfg(unix)] + { + use std::os::unix::ffi::OsStringExt; + + match std::env::var_os(name) { + Some(value) => Ok(Some(Bytes::from(value.into_vec()))), + None => Ok(None), + } + } + + #[cfg(not(unix))] + { + match std::env::var(name) { + Ok(value) => Ok(Some(Bytes::from(value.into_bytes()))), + Err(std::env::VarError::NotPresent) => Ok(None), + Err(std::env::VarError::NotUnicode(_)) => Err(SecretError::Internal( + anyhow::anyhow!("secret store returned an invalid Unicode value"), + )), + } } } } @@ -46,22 +59,80 @@ impl SecretStore for EnvSecretStore { mod tests { use super::*; use bytes::Bytes; - use futures::executor::block_on; + use std::ffi::OsString; + use std::sync::OnceLock; + + fn env_guard() -> &'static tokio::sync::Mutex<()> { + static GUARD: OnceLock> = OnceLock::new(); + GUARD.get_or_init(|| tokio::sync::Mutex::new(())) + } - #[test] - fn get_bytes_returns_none_when_var_not_set() { + struct EnvOverride { + key: &'static str, + original: Option, + } + + impl EnvOverride { + fn set(key: &'static str, value: impl AsRef) -> Self { + let original = std::env::var_os(key); + std::env::set_var(key, value); + Self { key, original } + } + + fn clear(key: &'static str) -> Self { + let original = std::env::var_os(key); + std::env::remove_var(key); + Self { key, original } + } + } + + impl Drop for EnvOverride { + fn drop(&mut self) { + if let Some(ref original) = self.original { + std::env::set_var(self.key, original); + } else { + std::env::remove_var(self.key); + } + } + } + + #[tokio::test(flavor = "current_thread")] + async fn get_bytes_returns_none_when_var_not_set() { + let _guard = env_guard().lock().await; + let _env = EnvOverride::clear("__EDGEZERO_TEST_MISSING_VAR_XYZ__"); let store = EnvSecretStore::new(); - let result = block_on(store.get_bytes("__EDGEZERO_TEST_MISSING_VAR_XYZ__")).unwrap(); + let result = store + .get_bytes("__EDGEZERO_TEST_MISSING_VAR_XYZ__") + .await + .unwrap(); assert!(result.is_none()); } - #[test] - fn get_bytes_returns_value_when_var_set() { - std::env::set_var("__EDGEZERO_TEST_SECRET__", "test_value_123"); + #[tokio::test(flavor = "current_thread")] + async fn get_bytes_returns_value_when_var_set() { + let _guard = env_guard().lock().await; + let _env = EnvOverride::set("__EDGEZERO_TEST_SECRET__", "test_value_123"); let store = EnvSecretStore::new(); - let result = block_on(store.get_bytes("__EDGEZERO_TEST_SECRET__")).unwrap(); + let result = store.get_bytes("__EDGEZERO_TEST_SECRET__").await.unwrap(); assert_eq!(result, Some(Bytes::from("test_value_123"))); - std::env::remove_var("__EDGEZERO_TEST_SECRET__"); + } + + #[cfg(unix)] + #[tokio::test(flavor = "current_thread")] + async fn get_bytes_preserves_non_utf8_secret_values() { + use std::os::unix::ffi::OsStringExt; + + let _guard = env_guard().lock().await; + let _env = EnvOverride::set( + "__EDGEZERO_TEST_BINARY_SECRET__", + OsString::from_vec(vec![0xff, 0x61]), + ); + let store = EnvSecretStore::new(); + let result = store + .get_bytes("__EDGEZERO_TEST_BINARY_SECRET__") + .await + .unwrap(); + assert_eq!(result, Some(Bytes::from_static(&[0xff, 0x61]))); } // Contract tests: use InMemorySecretStore since EnvSecretStore needs diff --git a/crates/edgezero-adapter-axum/src/service.rs b/crates/edgezero-adapter-axum/src/service.rs index 346a3f3..169f469 100644 --- a/crates/edgezero-adapter-axum/src/service.rs +++ b/crates/edgezero-adapter-axum/src/service.rs @@ -102,8 +102,38 @@ mod tests { use edgezero_core::context::RequestContext; use edgezero_core::error::EdgeError; use edgezero_core::http::{response_builder, StatusCode}; + use std::ffi::OsString; + use std::sync::OnceLock; use tower::ServiceExt; + fn env_guard() -> &'static tokio::sync::Mutex<()> { + static GUARD: OnceLock> = OnceLock::new(); + GUARD.get_or_init(|| tokio::sync::Mutex::new(())) + } + + struct EnvOverride { + key: &'static str, + original: Option, + } + + impl EnvOverride { + fn set(key: &'static str, value: impl AsRef) -> Self { + let original = std::env::var_os(key); + std::env::set_var(key, value); + Self { key, original } + } + } + + impl Drop for EnvOverride { + fn drop(&mut self) { + if let Some(ref original) = self.original { + std::env::set_var(self.key, original); + } else { + std::env::remove_var(self.key); + } + } + } + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn forwards_request_to_router() { let router = RouterService::builder() @@ -166,7 +196,8 @@ mod tests { use edgezero_core::secret_store::SecretHandle; use std::sync::Arc; - std::env::set_var("__EDGEZERO_SERVICE_TEST_SECRET__", "injected_value"); + let _guard = env_guard().lock().await; + let _env = EnvOverride::set("__EDGEZERO_SERVICE_TEST_SECRET__", "injected_value"); let handle = SecretHandle::new(Arc::new(EnvSecretStore::new())); let router = RouterService::builder() @@ -198,8 +229,6 @@ mod tests { .await .unwrap(); assert_eq!(&body[..], b"injected_value"); - - std::env::remove_var("__EDGEZERO_SERVICE_TEST_SECRET__"); } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] diff --git a/crates/edgezero-adapter-cloudflare/src/lib.rs b/crates/edgezero-adapter-cloudflare/src/lib.rs index 465abd8..2e25344 100644 --- a/crates/edgezero-adapter-cloudflare/src/lib.rs +++ b/crates/edgezero-adapter-cloudflare/src/lib.rs @@ -79,19 +79,25 @@ pub async fn run_app( let kv_binding = manifest.kv_store_name("cloudflare"); let kv_required = manifest.stores.kv.is_some(); let secret_binding = manifest.secret_store_name("cloudflare"); - let secrets_required = manifest.stores.secrets.is_some(); + let secrets_required = manifest.secret_store_enabled("cloudflare"); let app = A::build_app(); - dispatch_with_kv_and_secrets( - &app, - req, - env, - ctx, - kv_binding, - kv_required, - secret_binding, - secrets_required, - ) - .await + if secrets_required && kv_required { + dispatch_with_kv_and_secrets( + &app, + req, + env, + ctx, + kv_binding, + kv_required, + secret_binding, + secrets_required, + ) + .await + } else if secrets_required { + dispatch_with_secrets(&app, req, env, ctx, secrets_required).await + } else { + dispatch_with_kv(&app, req, env, ctx, kv_binding, kv_required).await + } } /// Deprecated: use [`run_app`] which now takes `manifest_src` directly. diff --git a/crates/edgezero-adapter-cloudflare/src/request.rs b/crates/edgezero-adapter-cloudflare/src/request.rs index d478f5b..aa4cc98 100644 --- a/crates/edgezero-adapter-cloudflare/src/request.rs +++ b/crates/edgezero-adapter-cloudflare/src/request.rs @@ -112,26 +112,32 @@ pub async fn dispatch_with_kv( /// Use this when your application accesses secrets but does not need a KV store. /// For applications that need both, use [`dispatch_with_kv_and_secrets`] instead. /// -/// Note: `_secrets_required` is intentionally unused. Cloudflare Worker Secrets -/// are individually bound in `wrangler.toml`; there is no namespace to "open" -/// that could fail. The store is always successfully constructed from `Env`. +/// The store is only attached when `secrets_required` is `true`. /// Individual missing secrets surface as `SecretError::NotFound` at access time. pub async fn dispatch_with_secrets( app: &App, req: CfRequest, env: Env, ctx: Context, - _secrets_required: bool, + secrets_required: bool, ) -> Result { - // Clone env before consuming it in into_core_request. - // Env wraps a JsValue reference; cloning increments the JS reference count. - let secret_store = crate::secret_store::CloudflareSecretStore::from_env(env.clone()); - let secret_handle = SecretHandle::new(std::sync::Arc::new(secret_store)); - let mut core_request = into_core_request(req, env, ctx) .await .map_err(edge_error_to_worker)?; - core_request.extensions_mut().insert(secret_handle); + + if secrets_required { + // Env wraps a JsValue reference; cloning increments the JS reference count. + let secret_store = crate::secret_store::CloudflareSecretStore::from_env( + core_request + .extensions() + .get::() + .expect("cloudflare request context inserted") + .env() + .clone(), + ); + let secret_handle = SecretHandle::new(std::sync::Arc::new(secret_store)); + core_request.extensions_mut().insert(secret_handle); + } let svc = app.router().clone(); let response = svc.oneshot(core_request).await; @@ -146,8 +152,8 @@ pub async fn dispatch_with_kv_and_secrets( ctx: Context, kv_binding: &str, kv_required: bool, - _secret_binding: &str, // unused: CF secrets have no namespace concept - _secrets_required: bool, // unused: CloudflareSecretStore always constructs OK + _secret_binding: &str, // unused: CF secrets have no namespace concept + secrets_required: bool, ) -> Result { // Open KV by borrowing env let kv_handle = match crate::key_value_store::CloudflareKvStore::from_env(&env, kv_binding) { @@ -164,10 +170,6 @@ pub async fn dispatch_with_kv_and_secrets( } }; - // Clone env for secrets before consuming it - let secret_store = crate::secret_store::CloudflareSecretStore::from_env(env.clone()); - let secret_handle = SecretHandle::new(std::sync::Arc::new(secret_store)); - let mut core_request = into_core_request(req, env, ctx) .await .map_err(edge_error_to_worker)?; @@ -175,7 +177,18 @@ pub async fn dispatch_with_kv_and_secrets( if let Some(handle) = kv_handle { core_request.extensions_mut().insert(handle); } - core_request.extensions_mut().insert(secret_handle); + if secrets_required { + let secret_store = crate::secret_store::CloudflareSecretStore::from_env( + core_request + .extensions() + .get::() + .expect("cloudflare request context inserted") + .env() + .clone(), + ); + let secret_handle = SecretHandle::new(std::sync::Arc::new(secret_store)); + core_request.extensions_mut().insert(secret_handle); + } let svc = app.router().clone(); let response = svc.oneshot(core_request).await; diff --git a/crates/edgezero-adapter-cloudflare/src/secret_store.rs b/crates/edgezero-adapter-cloudflare/src/secret_store.rs index c7ba68b..37d542c 100644 --- a/crates/edgezero-adapter-cloudflare/src/secret_store.rs +++ b/crates/edgezero-adapter-cloudflare/src/secret_store.rs @@ -15,6 +15,8 @@ use async_trait::async_trait; use bytes::Bytes; #[cfg(all(feature = "cloudflare", target_arch = "wasm32"))] use edgezero_core::secret_store::{SecretError, SecretStore}; +#[cfg(all(feature = "cloudflare", target_arch = "wasm32"))] +use worker::Error as WorkerError; /// Secret store backed by Cloudflare Workers `Env`. /// @@ -42,8 +44,16 @@ impl SecretStore for CloudflareSecretStore { let value = secret.to_string(); Ok(Some(Bytes::from(value.into_bytes()))) } - // Workers returns an error when a secret binding is absent - Err(_) => Ok(None), + Err(WorkerError::BindingError(_)) => Ok(None), + Err(WorkerError::JsError(message)) + if message.contains("does not contain binding") + || message.contains("is undefined") => + { + Ok(None) + } + Err(err) => Err(SecretError::Internal(anyhow::anyhow!( + "secret lookup failed: {err}" + ))), } } } diff --git a/crates/edgezero-adapter-fastly/src/lib.rs b/crates/edgezero-adapter-fastly/src/lib.rs index 864c20e..7905fc3 100644 --- a/crates/edgezero-adapter-fastly/src/lib.rs +++ b/crates/edgezero-adapter-fastly/src/lib.rs @@ -92,7 +92,7 @@ pub fn run_app( let kv_name = manifest.kv_store_name("fastly").to_string(); let kv_required = manifest.stores.kv.is_some(); let secret_name = manifest.secret_store_name("fastly").to_string(); - let secrets_required = manifest.stores.secrets.is_some(); + let secrets_required = manifest.secret_store_enabled("fastly"); run_app_with_logging::( logging.into(), req, @@ -118,14 +118,20 @@ pub(crate) fn run_app_with_logging( } let app = A::build_app(); - dispatch_with_kv_and_secrets( - &app, - req, - kv_store_name, - kv_required, - secret_store_name, - secrets_required, - ) + if secrets_required && kv_required { + dispatch_with_kv_and_secrets( + &app, + req, + kv_store_name, + kv_required, + secret_store_name, + secrets_required, + ) + } else if secrets_required { + dispatch_with_secrets(&app, req, secret_store_name, secrets_required) + } else { + dispatch_with_kv(&app, req, kv_store_name, kv_required) + } } #[cfg(all(test, feature = "fastly"))] diff --git a/crates/edgezero-adapter-fastly/src/request.rs b/crates/edgezero-adapter-fastly/src/request.rs index 6fd8c10..79b8aa6 100644 --- a/crates/edgezero-adapter-fastly/src/request.rs +++ b/crates/edgezero-adapter-fastly/src/request.rs @@ -118,19 +118,18 @@ pub fn dispatch_with_secrets( ) -> Result { let mut core_request = into_core_request(req).map_err(map_edge_error)?; - match crate::secret_store::FastlySecretStore::open(secret_store_name) { - Ok(store) => { - let handle = SecretHandle::new(std::sync::Arc::new(store)); - core_request.extensions_mut().insert(handle); - } - Err(e) => { - if secrets_required { + if secrets_required { + match crate::secret_store::FastlySecretStore::open(secret_store_name) { + Ok(store) => { + let handle = SecretHandle::new(std::sync::Arc::new(store)); + core_request.extensions_mut().insert(handle); + } + Err(e) => { return Err(FastlyError::msg(format!( "secret store '{}' is explicitly configured but could not be opened: {}", secret_store_name, e ))); } - warn_missing_secret_store_once(secret_store_name, &e); } } @@ -165,38 +164,21 @@ pub fn dispatch_with_kv_and_secrets( } } - match crate::secret_store::FastlySecretStore::open(secret_store_name) { - Ok(store) => { - let handle = SecretHandle::new(std::sync::Arc::new(store)); - core_request.extensions_mut().insert(handle); - } - Err(e) => { - if secrets_required { + if secrets_required { + match crate::secret_store::FastlySecretStore::open(secret_store_name) { + Ok(store) => { + let handle = SecretHandle::new(std::sync::Arc::new(store)); + core_request.extensions_mut().insert(handle); + } + Err(e) => { return Err(FastlyError::msg(format!( "secret store '{}' is explicitly configured but could not be opened: {}", secret_store_name, e ))); } - warn_missing_secret_store_once(secret_store_name, &e); } } let response = executor::block_on(app.router().oneshot(core_request)); from_core_response(response).map_err(map_edge_error) } - -fn warn_missing_secret_store_once(name: &str, error: &impl std::fmt::Display) { - static WARNED: OnceLock>> = OnceLock::new(); - let warned = WARNED.get_or_init(|| Mutex::new(BTreeSet::new())); - match warned.lock() { - Ok(mut warned) => { - if !warned.insert(name.to_string()) { - return; - } - log::warn!("secret store '{}' not available: {}", name, error); - } - Err(_) => { - log::warn!("secret store '{}' not available: {}", name, error); - } - } -} diff --git a/crates/edgezero-adapter-fastly/src/secret_store.rs b/crates/edgezero-adapter-fastly/src/secret_store.rs index 960751a..c07e7c2 100644 --- a/crates/edgezero-adapter-fastly/src/secret_store.rs +++ b/crates/edgezero-adapter-fastly/src/secret_store.rs @@ -39,8 +39,15 @@ impl FastlySecretStore { #[async_trait(?Send)] impl SecretStore for FastlySecretStore { async fn get_bytes(&self, name: &str) -> Result, SecretError> { - match self.store.get(name) { - Some(secret) => Ok(Some(secret.plaintext())), + let secret = self + .store + .try_get(name) + .map_err(|e| SecretError::Internal(anyhow::anyhow!("secret lookup failed: {e}")))?; + + match secret { + Some(secret) => secret.try_plaintext().map(Some).map_err(|e| { + SecretError::Internal(anyhow::anyhow!("secret decryption failed: {e}")) + }), None => Ok(None), } } diff --git a/crates/edgezero-cli/src/main.rs b/crates/edgezero-cli/src/main.rs index 8e51c76..e5c7ae4 100644 --- a/crates/edgezero-cli/src/main.rs +++ b/crates/edgezero-cli/src/main.rs @@ -78,16 +78,32 @@ fn main() { } #[cfg(feature = "cli")] -fn log_store_bindings(adapter_name: &str, manifest: &ManifestLoader) { +fn store_bindings_message(adapter_name: &str, manifest: &ManifestLoader) -> Option { let m = manifest.manifest(); - if let Some(ref secrets) = m.stores.secrets { - let binding_name = m.secret_store_name(adapter_name); - println!( - "[edgezero] secret store '{binding_name}' declared -- \ - ensure it is provisioned on the {adapter_name} platform \ - (global name: '{}')", - secrets.name - ); + if !m.secret_store_enabled(adapter_name) { + return None; + } + + let binding_name = m.secret_store_name(adapter_name); + let message = match adapter_name { + "axum" => format!( + "[edgezero] secrets enabled for axum -- ensure the required environment variables are set for local runs (configured store name: '{binding_name}')" + ), + "cloudflare" => format!( + "[edgezero] secrets enabled for cloudflare -- ensure the required secret bindings exist in wrangler (configured store name: '{binding_name}' is metadata only)" + ), + _ => format!( + "[edgezero] secret store '{binding_name}' enabled for {adapter_name} -- ensure it is provisioned on the target platform" + ), + }; + + Some(message) +} + +#[cfg(feature = "cli")] +fn log_store_bindings(adapter_name: &str, manifest: &ManifestLoader) { + if let Some(message) = store_bindings_message(adapter_name, manifest) { + println!("{message}"); } } @@ -326,4 +342,34 @@ serve = "echo serve" assert_eq!(loader.manifest().secret_store_name("fastly"), "MY_SECRETS"); assert!(loader.manifest().stores.secrets.is_some()); } + + #[test] + fn store_bindings_message_is_adapter_specific() { + let loader = ManifestLoader::load_from_str( + r#" +[stores.secrets] +name = "MY_SECRETS" +"#, + ); + + let axum = store_bindings_message("axum", &loader).expect("axum message"); + assert!(axum.contains("environment variables")); + + let cloudflare = store_bindings_message("cloudflare", &loader).expect("cloudflare message"); + assert!(cloudflare.contains("wrangler")); + + let fastly = store_bindings_message("fastly", &loader).expect("fastly message"); + assert!(fastly.contains("secret store 'MY_SECRETS'")); + } + + #[test] + fn store_bindings_message_respects_secret_store_enabled() { + let loader = ManifestLoader::load_from_str( + r#" +[stores.secrets] +enabled = false +"#, + ); + assert!(store_bindings_message("fastly", &loader).is_none()); + } } diff --git a/crates/edgezero-core/src/manifest.rs b/crates/edgezero-core/src/manifest.rs index aa6f3fe..a417e8f 100644 --- a/crates/edgezero-core/src/manifest.rs +++ b/crates/edgezero-core/src/manifest.rs @@ -157,7 +157,9 @@ impl Manifest { .iter() .find(|(k, _)| k.eq_ignore_ascii_case(&adapter_lower)) { - return &adapter_cfg.1.name; + if let Some(name) = adapter_cfg.1.name.as_deref() { + return name; + } } &secrets.name } @@ -165,6 +167,24 @@ impl Manifest { } } + /// Returns whether the secret store should be attached for a given adapter. + pub fn secret_store_enabled(&self, adapter: &str) -> bool { + match &self.stores.secrets { + Some(secrets) => { + let adapter_lower = adapter.to_ascii_lowercase(); + if let Some(adapter_cfg) = secrets + .adapters + .iter() + .find(|(k, _)| k.eq_ignore_ascii_case(&adapter_lower)) + { + return adapter_cfg.1.enabled; + } + secrets.enabled + } + None => false, + } + } + fn finalize(&mut self) { let mut resolved = BTreeMap::new(); @@ -427,6 +447,10 @@ fn default_secret_name() -> String { DEFAULT_SECRET_STORE_NAME.to_string() } +fn default_enabled() -> bool { + true +} + /// Configuration for external stores (e.g., KV, object storage). /// /// ```toml @@ -475,6 +499,10 @@ pub struct ManifestKvAdapterConfig { /// Global secret store configuration. #[derive(Debug, Deserialize, Validate)] pub struct ManifestSecretsConfig { + /// Whether the secret store is enabled for adapters without overrides. + #[serde(default = "default_enabled")] + pub enabled: bool, + /// Store / binding name (default: `"EDGEZERO_SECRETS"`). #[serde(default = "default_secret_name")] #[validate(length(min = 1))] @@ -489,8 +517,14 @@ pub struct ManifestSecretsConfig { /// Per-adapter secret store name override. #[derive(Debug, Deserialize, Validate)] pub struct ManifestSecretsAdapterConfig { + /// Whether the secret store is enabled for this adapter. + #[serde(default = "default_enabled")] + pub enabled: bool, + + /// Optional per-adapter secret store name override. + #[serde(default)] #[validate(length(min = 1))] - pub name: String, + pub name: Option, } #[derive(Clone, Debug, Eq, PartialEq)] @@ -1348,4 +1382,46 @@ name = "FASTLY_STORE" let manifest = ManifestLoader::load_from_str("[stores.secrets]\nname = \"MY_SECRETS\"\n"); assert!(manifest.manifest().stores.secrets.is_some()); } + + #[test] + fn secret_store_enabled_is_false_when_absent() { + let manifest = ManifestLoader::load_from_str("[app]\nname = \"x\"\n"); + assert!(!manifest.manifest().secret_store_enabled("fastly")); + assert!(!manifest.manifest().secret_store_enabled("cloudflare")); + } + + #[test] + fn secret_store_enabled_is_true_when_declared() { + let manifest = ManifestLoader::load_from_str("[stores.secrets]\nname = \"MY_SECRETS\"\n"); + assert!(manifest.manifest().secret_store_enabled("fastly")); + assert!(manifest.manifest().secret_store_enabled("cloudflare")); + } + + #[test] + fn secret_store_enabled_can_be_disabled_per_adapter() { + let manifest = ManifestLoader::load_from_str( + "[stores.secrets]\nname = \"MY_SECRETS\"\n\ + [stores.secrets.adapters.cloudflare]\nenabled = false\n", + ); + assert!(manifest.manifest().secret_store_enabled("fastly")); + assert!(!manifest.manifest().secret_store_enabled("cloudflare")); + } + + #[test] + fn secret_store_enabled_can_be_enabled_only_for_specific_adapter() { + let manifest = ManifestLoader::load_from_str( + "[stores.secrets]\nenabled = false\n\ + [stores.secrets.adapters.fastly]\nenabled = true\nname = \"FASTLY_STORE\"\n", + ); + assert!(manifest.manifest().secret_store_enabled("fastly")); + assert!(!manifest.manifest().secret_store_enabled("cloudflare")); + assert_eq!( + manifest.manifest().secret_store_name("fastly"), + "FASTLY_STORE" + ); + assert_eq!( + manifest.manifest().secret_store_name("cloudflare"), + DEFAULT_SECRET_STORE_NAME + ); + } } diff --git a/crates/edgezero-core/src/secret_store.rs b/crates/edgezero-core/src/secret_store.rs index aaecd5f..6627a89 100644 --- a/crates/edgezero-core/src/secret_store.rs +++ b/crates/edgezero-core/src/secret_store.rs @@ -53,20 +53,16 @@ pub enum SecretError { impl From for EdgeError { fn from(err: SecretError) -> Self { match err { - // NotFound = server misconfiguration, never a client 404. - // A missing API key means the platform isn't set up correctly, - // not that the request was invalid. - SecretError::NotFound { name } => EdgeError::internal(anyhow::anyhow!( - "required secret '{}' is not configured -- check platform secret store bindings", - name - )), + SecretError::NotFound { .. } => { + EdgeError::internal(anyhow::anyhow!("required secret is not configured")) + } SecretError::Unavailable => EdgeError::service_unavailable("secret store unavailable"), - // Validation errors are programming errors (bad secret name in code), - // not client errors. - SecretError::Validation(e) => { - EdgeError::internal(anyhow::anyhow!("secret name validation error: {e}")) + SecretError::Validation(..) => { + EdgeError::internal(anyhow::anyhow!("secret lookup failed")) + } + SecretError::Internal(..) => { + EdgeError::internal(anyhow::anyhow!("secret store operation failed")) } - SecretError::Internal(e) => EdgeError::internal(e), } } } @@ -294,6 +290,7 @@ macro_rules! secret_store_contract_tests { #[cfg(test)] mod tests { use super::*; + use crate::http::StatusCode; use bytes::Bytes; use futures::executor::block_on; @@ -315,6 +312,14 @@ mod tests { SecretHandle::new(std::sync::Arc::new(SimpleStore(map))) } + fn store_with_bytes(entries: &[(&str, &[u8])]) -> SecretHandle { + let map: HashMap = entries + .iter() + .map(|(k, v)| (k.to_string(), Bytes::copy_from_slice(v))) + .collect(); + SecretHandle::new(std::sync::Arc::new(SimpleStore(map))) + } + #[test] fn validate_name_rejects_empty() { block_on(async { @@ -390,6 +395,41 @@ mod tests { }); } + #[test] + fn get_str_rejects_invalid_utf8() { + block_on(async { + let h = store_with_bytes(&[("binary", &[0xff])]); + let err = h.get_str("binary").await.unwrap_err(); + assert!(matches!(err, SecretError::Internal(_))); + }); + } + + #[test] + fn require_str_rejects_invalid_utf8() { + block_on(async { + let h = store_with_bytes(&[("binary", &[0xff])]); + let err = h.require_str("binary").await.unwrap_err(); + assert!(matches!(err, SecretError::Internal(_))); + }); + } + + #[test] + fn secret_error_not_found_does_not_leak_secret_name() { + let err: EdgeError = SecretError::NotFound { + name: "API_KEY".to_string(), + } + .into(); + assert_eq!(err.status(), StatusCode::INTERNAL_SERVER_ERROR); + assert!(!err.message().contains("API_KEY")); + } + + #[test] + fn secret_error_validation_does_not_leak_details() { + let err: EdgeError = SecretError::Validation("bad\x00name".to_string()).into(); + assert_eq!(err.status(), StatusCode::INTERNAL_SERVER_ERROR); + assert!(!err.message().contains("bad")); + } + secret_store_contract_tests!(in_memory_contract, { InMemorySecretStore::new([ ("contract_key", Bytes::from("contract_value")), From aa53635cb0973f1b54763b95e902a19ccce6980a Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Tue, 24 Mar 2026 17:53:17 +0530 Subject: [PATCH 14/18] test(axum): add TCP integration tests for secret store wiring --- .../edgezero-adapter-axum/src/dev_server.rs | 111 ++++++++++++++++++ 1 file changed, 111 insertions(+) diff --git a/crates/edgezero-adapter-axum/src/dev_server.rs b/crates/edgezero-adapter-axum/src/dev_server.rs index 93b2340..ced9966 100644 --- a/crates/edgezero-adapter-axum/src/dev_server.rs +++ b/crates/edgezero-adapter-axum/src/dev_server.rs @@ -805,4 +805,115 @@ mod integration_tests { server.handle.abort(); } + + // ----------------------------------------------------------------------- + // Secret store helpers + // ----------------------------------------------------------------------- + + struct TestServerSecrets { + base_url: String, + handle: tokio::task::JoinHandle<()>, + } + + async fn start_test_server_with_secret_handle( + router: RouterService, + secret_handle: Option, + ) -> TestServerSecrets { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("bind secrets test server"); + let addr = listener.local_addr().expect("local addr"); + let handle = tokio::spawn(async move { + let _ = super::serve_with_listener_and_stores( + router, + listener, + false, + None, + secret_handle, + ) + .await; + }); + TestServerSecrets { + base_url: format!("http://{}", addr), + handle, + } + } + + // ----------------------------------------------------------------------- + // Secret store integration tests + // ----------------------------------------------------------------------- + + #[tokio::test(flavor = "multi_thread")] + async fn secret_present_returns_value() { + use edgezero_core::secret_store::{InMemorySecretStore, SecretHandle}; + use std::sync::Arc; + + async fn handler(ctx: RequestContext) -> Result { + let store = ctx + .secret_handle() + .ok_or_else(|| EdgeError::internal(anyhow::anyhow!("no secret store configured")))?; + store.require_str("API_KEY").await.map_err(EdgeError::from) + } + + let router = RouterService::builder().get("/secret", handler).build(); + let store = InMemorySecretStore::new([("API_KEY", bytes::Bytes::from("s3cr3t"))]); + let handle = SecretHandle::new(Arc::new(store)); + let server = start_test_server_with_secret_handle(router, Some(handle)).await; + + let client = reqwest::Client::new(); + let url = format!("{}/secret", server.base_url); + let response = send_with_retry(&client, |c| c.get(url.as_str())).await; + + assert_eq!(response.status(), reqwest::StatusCode::OK); + assert_eq!(response.text().await.unwrap(), "s3cr3t"); + + server.handle.abort(); + } + + #[tokio::test(flavor = "multi_thread")] + async fn secret_missing_returns_500() { + use edgezero_core::secret_store::{InMemorySecretStore, SecretHandle}; + use std::sync::Arc; + + async fn handler(ctx: RequestContext) -> Result { + let store = ctx + .secret_handle() + .ok_or_else(|| EdgeError::internal(anyhow::anyhow!("no secret store configured")))?; + store.require_str("API_KEY").await.map_err(EdgeError::from) + } + + let router = RouterService::builder().get("/secret", handler).build(); + let store = InMemorySecretStore::new(std::iter::empty::<(&str, bytes::Bytes)>()); + let handle = SecretHandle::new(Arc::new(store)); + let server = start_test_server_with_secret_handle(router, Some(handle)).await; + + let client = reqwest::Client::new(); + let url = format!("{}/secret", server.base_url); + let response = send_with_retry(&client, |c| c.get(url.as_str())).await; + + assert_eq!(response.status(), reqwest::StatusCode::INTERNAL_SERVER_ERROR); + + server.handle.abort(); + } + + #[tokio::test(flavor = "multi_thread")] + async fn no_secret_store_configured_returns_500() { + async fn handler(ctx: RequestContext) -> Result { + let store = ctx + .secret_handle() + .ok_or_else(|| EdgeError::internal(anyhow::anyhow!("no secret store configured")))?; + store.require_str("API_KEY").await.map_err(EdgeError::from) + } + + let router = RouterService::builder().get("/secret", handler).build(); + let server = start_test_server_with_secret_handle(router, None).await; + + let client = reqwest::Client::new(); + let url = format!("{}/secret", server.base_url); + let response = send_with_retry(&client, |c| c.get(url.as_str())).await; + + assert_eq!(response.status(), reqwest::StatusCode::INTERNAL_SERVER_ERROR); + + server.handle.abort(); + } } From a9ac734fcea5792ae0b26cabc7c6cadefbc03bac Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Tue, 24 Mar 2026 17:58:54 +0530 Subject: [PATCH 15/18] feat(demo): add secrets_echo handler and [stores.secrets] manifest config --- .../app-demo/crates/app-demo-core/Cargo.toml | 1 + .../crates/app-demo-core/src/handlers.rs | 67 ++++++++++++++++++- examples/app-demo/edgezero.toml | 16 +++++ 3 files changed, 83 insertions(+), 1 deletion(-) diff --git a/examples/app-demo/crates/app-demo-core/Cargo.toml b/examples/app-demo/crates/app-demo-core/Cargo.toml index b356b4e..91c2281 100644 --- a/examples/app-demo/crates/app-demo-core/Cargo.toml +++ b/examples/app-demo/crates/app-demo-core/Cargo.toml @@ -15,3 +15,4 @@ validator = { workspace = true } [dev-dependencies] async-trait = { workspace = true } +edgezero-core = { path = "../../../../crates/edgezero-core", features = ["test-utils"] } 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 1eb44dc..d31575d 100644 --- a/examples/app-demo/crates/app-demo-core/src/handlers.rs +++ b/examples/app-demo/crates/app-demo-core/src/handlers.rs @@ -3,7 +3,7 @@ use edgezero_core::action; use edgezero_core::body::Body; use edgezero_core::context::RequestContext; use edgezero_core::error::EdgeError; -use edgezero_core::extractor::{Headers, Json, Kv, Path, ValidatedPath}; +use edgezero_core::extractor::{Headers, Json, Kv, Path, Query, Secrets, ValidatedPath}; use edgezero_core::http::{self, Response, StatusCode, Uri}; use edgezero_core::proxy::ProxyRequest; use edgezero_core::response::Text; @@ -191,6 +191,24 @@ pub(crate) async fn kv_note_delete( .map_err(EdgeError::internal) } +// --------------------------------------------------------------------------- +// Secrets demo handler — illustrates platform-neutral secret access. +// WARNING: This handler returns the raw secret value in the response body. +// It exists solely for smoke-testing. Never do this in production. +// --------------------------------------------------------------------------- + +/// Echo the value of a named secret from the configured secret store. +/// +/// Usage: GET /secrets/echo?name=MY_SECRET_NAME +#[action] +pub(crate) async fn secrets_echo( + Secrets(store): Secrets, + Query(params): Query, +) -> Result, EdgeError> { + let value = store.require_str(¶ms.name).await.map_err(EdgeError::from)?; + Ok(Text::new(value)) +} + #[cfg(test)] mod tests { use super::*; @@ -516,4 +534,51 @@ mod tests { let resp = block_on(kv_note_delete(ctx2)).expect("response"); assert_eq!(resp.status(), StatusCode::NO_CONTENT); } + + // -- Secrets handler tests ---------------------------------------------- + + use edgezero_core::secret_store::{InMemorySecretStore, SecretHandle}; + + fn context_with_secrets( + path: &str, + query: &str, + entries: &[(&str, &str)], + ) -> RequestContext { + let store = InMemorySecretStore::new( + entries + .iter() + .map(|(k, v)| (*k, bytes::Bytes::from(v.to_string()))), + ); + let handle = SecretHandle::new(std::sync::Arc::new(store)); + let uri = format!("{}?{}", path, query); + let mut request = request_builder() + .method(Method::GET) + .uri(uri.as_str()) + .body(Body::empty()) + .expect("request"); + request.extensions_mut().insert(handle); + RequestContext::new(request, PathParams::default()) + } + + #[test] + fn secrets_echo_returns_secret_value() { + let ctx = context_with_secrets( + "/secrets/echo", + "name=API_KEY", + &[("API_KEY", "my-secret-value")], + ); + let response = block_on(secrets_echo(ctx)) + .expect("handler ok") + .into_response(); + let bytes = response.into_body().into_bytes(); + assert_eq!(bytes.as_ref(), b"my-secret-value"); + } + + #[test] + fn secrets_echo_returns_500_for_missing_secret() { + use edgezero_core::http::StatusCode; + let ctx = context_with_secrets("/secrets/echo", "name=MISSING_KEY", &[]); + let err = block_on(secrets_echo(ctx)).expect_err("should fail"); + assert_eq!(err.status(), StatusCode::INTERNAL_SERVER_ERROR); + } } diff --git a/examples/app-demo/edgezero.toml b/examples/app-demo/edgezero.toml index a187197..7c505c8 100644 --- a/examples/app-demo/edgezero.toml +++ b/examples/app-demo/edgezero.toml @@ -86,6 +86,16 @@ handler = "app_demo_core::handlers::kv_note_delete" adapters = ["axum", "cloudflare", "fastly"] description = "Delete a note by id" +# -- Secrets demo route -------------------------------------------------------- + +[[triggers.http]] +id = "secrets_echo" +path = "/secrets/echo" +methods = ["GET"] +handler = "app_demo_core::handlers::secrets_echo" +adapters = ["axum", "cloudflare", "fastly"] +description = "Echo a named secret value (smoke-test only — do not use in production)" + # -- Stores ---------------------------------------------------------------- [stores.kv] @@ -96,6 +106,12 @@ description = "Delete a note by id" # [stores.kv.adapters.cloudflare] # name = "CF_KV_BINDING" +[stores.secrets] +# Uses the default name "EDGEZERO_SECRETS". +# Axum reads secrets from environment variables of the same name. +# Cloudflare reads from Worker secret bindings (local: .dev.vars). +# Fastly reads from the declared secret store (local: fastly.toml [local_server.secret_stores]). + # [environment] # # [[environment.variables]] From 9c6746f6459e9a4a206d15e1e6ec42b48a29874e Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Tue, 24 Mar 2026 23:05:39 +0530 Subject: [PATCH 16/18] docs(guide): document runtime secret store configuration --- .../edgezero-adapter-axum/src/dev_server.rs | 68 +++--- crates/edgezero-adapter-cloudflare/src/lib.rs | 1 + .../src/request.rs | 125 +++++------ .../src/store_handles.rs | 113 ++++++++++ crates/edgezero-adapter-fastly/src/lib.rs | 1 + crates/edgezero-adapter-fastly/src/request.rs | 109 +++++----- .../src/store_handles.rs | 113 ++++++++++ docs/guide/configuration.md | 45 ++++ .../app-demo-adapter-fastly/fastly.toml | 10 + .../crates/app-demo-core/src/handlers.rs | 61 ++++-- examples/app-demo/edgezero.toml | 2 +- scripts/smoke_test_secrets.sh | 195 ++++++++++++++++++ 12 files changed, 661 insertions(+), 182 deletions(-) create mode 100644 crates/edgezero-adapter-cloudflare/src/store_handles.rs create mode 100644 crates/edgezero-adapter-fastly/src/store_handles.rs create mode 100755 scripts/smoke_test_secrets.sh diff --git a/crates/edgezero-adapter-axum/src/dev_server.rs b/crates/edgezero-adapter-axum/src/dev_server.rs index ced9966..52d489b 100644 --- a/crates/edgezero-adapter-axum/src/dev_server.rs +++ b/crates/edgezero-adapter-axum/src/dev_server.rs @@ -451,8 +451,10 @@ name = "EDGEZERO_KV" #[cfg(test)] mod integration_tests { use super::*; + use edgezero_core::action; use edgezero_core::context::RequestContext; use edgezero_core::error::EdgeError; + use edgezero_core::extractor::Secrets; use edgezero_core::router::RouterService; use std::time::{Duration, Instant}; @@ -824,14 +826,9 @@ mod integration_tests { .expect("bind secrets test server"); let addr = listener.local_addr().expect("local addr"); let handle = tokio::spawn(async move { - let _ = super::serve_with_listener_and_stores( - router, - listener, - false, - None, - secret_handle, - ) - .await; + let _ = + super::serve_with_listener_and_stores(router, listener, false, None, secret_handle) + .await; }); TestServerSecrets { base_url: format!("http://{}", addr), @@ -839,6 +836,11 @@ mod integration_tests { } } + #[action] + async fn secret_value_handler(Secrets(store): Secrets) -> Result { + store.require_str("API_KEY").await.map_err(EdgeError::from) + } + // ----------------------------------------------------------------------- // Secret store integration tests // ----------------------------------------------------------------------- @@ -848,14 +850,9 @@ mod integration_tests { use edgezero_core::secret_store::{InMemorySecretStore, SecretHandle}; use std::sync::Arc; - async fn handler(ctx: RequestContext) -> Result { - let store = ctx - .secret_handle() - .ok_or_else(|| EdgeError::internal(anyhow::anyhow!("no secret store configured")))?; - store.require_str("API_KEY").await.map_err(EdgeError::from) - } - - let router = RouterService::builder().get("/secret", handler).build(); + let router = RouterService::builder() + .get("/secret", secret_value_handler) + .build(); let store = InMemorySecretStore::new([("API_KEY", bytes::Bytes::from("s3cr3t"))]); let handle = SecretHandle::new(Arc::new(store)); let server = start_test_server_with_secret_handle(router, Some(handle)).await; @@ -875,14 +872,9 @@ mod integration_tests { use edgezero_core::secret_store::{InMemorySecretStore, SecretHandle}; use std::sync::Arc; - async fn handler(ctx: RequestContext) -> Result { - let store = ctx - .secret_handle() - .ok_or_else(|| EdgeError::internal(anyhow::anyhow!("no secret store configured")))?; - store.require_str("API_KEY").await.map_err(EdgeError::from) - } - - let router = RouterService::builder().get("/secret", handler).build(); + let router = RouterService::builder() + .get("/secret", secret_value_handler) + .build(); let store = InMemorySecretStore::new(std::iter::empty::<(&str, bytes::Bytes)>()); let handle = SecretHandle::new(Arc::new(store)); let server = start_test_server_with_secret_handle(router, Some(handle)).await; @@ -891,28 +883,36 @@ mod integration_tests { let url = format!("{}/secret", server.base_url); let response = send_with_retry(&client, |c| c.get(url.as_str())).await; - assert_eq!(response.status(), reqwest::StatusCode::INTERNAL_SERVER_ERROR); + assert_eq!( + response.status(), + reqwest::StatusCode::INTERNAL_SERVER_ERROR + ); + let body = response.text().await.unwrap(); + assert!(!body.contains("API_KEY")); + assert!(body.contains("required secret is not configured")); server.handle.abort(); } #[tokio::test(flavor = "multi_thread")] async fn no_secret_store_configured_returns_500() { - async fn handler(ctx: RequestContext) -> Result { - let store = ctx - .secret_handle() - .ok_or_else(|| EdgeError::internal(anyhow::anyhow!("no secret store configured")))?; - store.require_str("API_KEY").await.map_err(EdgeError::from) - } - - let router = RouterService::builder().get("/secret", handler).build(); + let router = RouterService::builder() + .get("/secret", secret_value_handler) + .build(); let server = start_test_server_with_secret_handle(router, None).await; let client = reqwest::Client::new(); let url = format!("{}/secret", server.base_url); let response = send_with_retry(&client, |c| c.get(url.as_str())).await; - assert_eq!(response.status(), reqwest::StatusCode::INTERNAL_SERVER_ERROR); + assert_eq!( + response.status(), + reqwest::StatusCode::INTERNAL_SERVER_ERROR + ); + let body = response.text().await.unwrap(); + assert!(body.contains( + "no secret store configured -- check [stores.secrets] in edgezero.toml and platform bindings" + )); server.handle.abort(); } diff --git a/crates/edgezero-adapter-cloudflare/src/lib.rs b/crates/edgezero-adapter-cloudflare/src/lib.rs index 2e25344..6e5d99b 100644 --- a/crates/edgezero-adapter-cloudflare/src/lib.rs +++ b/crates/edgezero-adapter-cloudflare/src/lib.rs @@ -15,6 +15,7 @@ mod request; mod response; #[cfg(all(feature = "cloudflare", target_arch = "wasm32"))] pub mod secret_store; +mod store_handles; #[cfg(all(feature = "cloudflare", target_arch = "wasm32"))] pub use context::CloudflareRequestContext; diff --git a/crates/edgezero-adapter-cloudflare/src/request.rs b/crates/edgezero-adapter-cloudflare/src/request.rs index aa4cc98..7a38c83 100644 --- a/crates/edgezero-adapter-cloudflare/src/request.rs +++ b/crates/edgezero-adapter-cloudflare/src/request.rs @@ -3,6 +3,7 @@ use std::sync::{Mutex, OnceLock}; use crate::proxy::CloudflareProxyClient; use crate::response::from_core_response; +use crate::store_handles::insert_store_handles; use crate::CloudflareRequestContext; use edgezero_core::app::App; use edgezero_core::body::Body; @@ -76,33 +77,8 @@ pub async fn dispatch_with_kv( kv_binding: &str, kv_required: bool, ) -> Result { - // Try to open the KV binding from `env` before consuming it in `into_core_request`. - // We borrow `env` here; `into_core_request` takes ownership afterwards. - let kv_handle = match crate::key_value_store::CloudflareKvStore::from_env(&env, kv_binding) { - Ok(store) => Some(KvHandle::new(std::sync::Arc::new(store))), - Err(e) => { - if kv_required { - return Err(WorkerError::RustError(format!( - "KV binding '{}' is explicitly configured but could not be opened: {}", - kv_binding, e - ))); - } - warn_missing_kv_binding_once(kv_binding, &e); - None - } - }; - - let mut core_request = into_core_request(req, env, ctx) - .await - .map_err(edge_error_to_worker)?; - - if let Some(handle) = kv_handle { - core_request.extensions_mut().insert(handle); - } - - let svc = app.router().clone(); - let response = svc.oneshot(core_request).await; - from_core_response(response).map_err(edge_error_to_worker) + let kv_handle = resolve_kv_handle(&env, kv_binding, kv_required)?; + dispatch_with_handles(app, req, env, ctx, kv_handle, None).await } /// Dispatch a Cloudflare Worker request with a secret store attached. @@ -121,27 +97,8 @@ pub async fn dispatch_with_secrets( ctx: Context, secrets_required: bool, ) -> Result { - let mut core_request = into_core_request(req, env, ctx) - .await - .map_err(edge_error_to_worker)?; - - if secrets_required { - // Env wraps a JsValue reference; cloning increments the JS reference count. - let secret_store = crate::secret_store::CloudflareSecretStore::from_env( - core_request - .extensions() - .get::() - .expect("cloudflare request context inserted") - .env() - .clone(), - ); - let secret_handle = SecretHandle::new(std::sync::Arc::new(secret_store)); - core_request.extensions_mut().insert(secret_handle); - } - - let svc = app.router().clone(); - let response = svc.oneshot(core_request).await; - from_core_response(response).map_err(edge_error_to_worker) + let secret_handle = resolve_secret_handle(&env, secrets_required); + dispatch_with_handles(app, req, env, ctx, None, secret_handle).await } /// Dispatch a Cloudflare Worker request with both KV and secret stores attached. @@ -155,9 +112,44 @@ pub async fn dispatch_with_kv_and_secrets( _secret_binding: &str, // unused: CF secrets have no namespace concept secrets_required: bool, ) -> Result { - // Open KV by borrowing env - let kv_handle = match crate::key_value_store::CloudflareKvStore::from_env(&env, kv_binding) { - Ok(store) => Some(KvHandle::new(std::sync::Arc::new(store))), + let kv_handle = resolve_kv_handle(&env, kv_binding, kv_required)?; + let secret_handle = resolve_secret_handle(&env, secrets_required); + dispatch_with_handles(app, req, env, ctx, kv_handle, secret_handle).await +} + +async fn dispatch_with_handles( + app: &App, + req: CfRequest, + env: Env, + ctx: Context, + kv_handle: Option, + secret_handle: Option, +) -> Result { + let core_request = into_core_request(req, env, ctx) + .await + .map_err(edge_error_to_worker)?; + dispatch_core_request(app, core_request, kv_handle, secret_handle).await +} + +async fn dispatch_core_request( + app: &App, + mut core_request: Request, + kv_handle: Option, + secret_handle: Option, +) -> Result { + insert_store_handles(&mut core_request, kv_handle, secret_handle); + let svc = app.router().clone(); + let response = svc.oneshot(core_request).await; + from_core_response(response).map_err(edge_error_to_worker) +} + +fn resolve_kv_handle( + env: &Env, + kv_binding: &str, + kv_required: bool, +) -> Result, WorkerError> { + match crate::key_value_store::CloudflareKvStore::from_env(env, kv_binding) { + Ok(store) => Ok(Some(KvHandle::new(std::sync::Arc::new(store)))), Err(e) => { if kv_required { return Err(WorkerError::RustError(format!( @@ -166,33 +158,18 @@ pub async fn dispatch_with_kv_and_secrets( ))); } warn_missing_kv_binding_once(kv_binding, &e); - None + Ok(None) } - }; - - let mut core_request = into_core_request(req, env, ctx) - .await - .map_err(edge_error_to_worker)?; - - if let Some(handle) = kv_handle { - core_request.extensions_mut().insert(handle); } - if secrets_required { - let secret_store = crate::secret_store::CloudflareSecretStore::from_env( - core_request - .extensions() - .get::() - .expect("cloudflare request context inserted") - .env() - .clone(), - ); - let secret_handle = SecretHandle::new(std::sync::Arc::new(secret_store)); - core_request.extensions_mut().insert(secret_handle); +} + +fn resolve_secret_handle(env: &Env, secrets_required: bool) -> Option { + if !secrets_required { + return None; } - let svc = app.router().clone(); - let response = svc.oneshot(core_request).await; - from_core_response(response).map_err(edge_error_to_worker) + let secret_store = crate::secret_store::CloudflareSecretStore::from_env(env.clone()); + Some(SecretHandle::new(std::sync::Arc::new(secret_store))) } fn edge_error_to_worker(err: EdgeError) -> WorkerError { diff --git a/crates/edgezero-adapter-cloudflare/src/store_handles.rs b/crates/edgezero-adapter-cloudflare/src/store_handles.rs new file mode 100644 index 0000000..d9f1bfb --- /dev/null +++ b/crates/edgezero-adapter-cloudflare/src/store_handles.rs @@ -0,0 +1,113 @@ +#[cfg(any(test, all(feature = "cloudflare", target_arch = "wasm32")))] +use edgezero_core::http::Request; +#[cfg(any(test, all(feature = "cloudflare", target_arch = "wasm32")))] +use edgezero_core::key_value_store::KvHandle; +#[cfg(any(test, all(feature = "cloudflare", target_arch = "wasm32")))] +use edgezero_core::secret_store::SecretHandle; + +#[cfg(any(test, all(feature = "cloudflare", target_arch = "wasm32")))] +pub(crate) fn insert_store_handles( + request: &mut Request, + kv_handle: Option, + secret_handle: Option, +) { + if let Some(handle) = kv_handle { + request.extensions_mut().insert(handle); + } + + if let Some(handle) = secret_handle { + request.extensions_mut().insert(handle); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use async_trait::async_trait; + use bytes::Bytes; + use edgezero_core::body::Body; + use edgezero_core::http::request_builder; + use edgezero_core::key_value_store::{KvError, KvPage, KvStore}; + use edgezero_core::secret_store::{SecretError, SecretStore}; + use std::sync::Arc; + use std::time::Duration; + + struct DummyKvStore; + + #[async_trait(?Send)] + impl KvStore for DummyKvStore { + async fn get_bytes(&self, _key: &str) -> Result, KvError> { + Ok(None) + } + + async fn put_bytes(&self, _key: &str, _value: Bytes) -> Result<(), KvError> { + Ok(()) + } + + async fn put_bytes_with_ttl( + &self, + _key: &str, + _value: Bytes, + _ttl: Duration, + ) -> Result<(), KvError> { + Ok(()) + } + + async fn delete(&self, _key: &str) -> Result<(), KvError> { + Ok(()) + } + + async fn list_keys_page( + &self, + _prefix: &str, + _cursor: Option<&str>, + _limit: usize, + ) -> Result { + Ok(KvPage { + keys: Vec::new(), + cursor: None, + }) + } + } + + struct DummySecretStore; + + #[async_trait(?Send)] + impl SecretStore for DummySecretStore { + async fn get_bytes(&self, _name: &str) -> Result, SecretError> { + Ok(None) + } + } + + #[test] + fn insert_store_handles_adds_present_handles() { + let mut request = request_builder() + .uri("https://example.com") + .body(Body::empty()) + .expect("request"); + let kv_handle = KvHandle::new(Arc::new(DummyKvStore)); + let secret_handle = SecretHandle::new(Arc::new(DummySecretStore)); + + insert_store_handles( + &mut request, + Some(kv_handle.clone()), + Some(secret_handle.clone()), + ); + + assert!(request.extensions().get::().is_some()); + assert!(request.extensions().get::().is_some()); + } + + #[test] + fn insert_store_handles_skips_absent_handles() { + let mut request = request_builder() + .uri("https://example.com") + .body(Body::empty()) + .expect("request"); + + insert_store_handles(&mut request, None, None); + + assert!(request.extensions().get::().is_none()); + assert!(request.extensions().get::().is_none()); + } +} diff --git a/crates/edgezero-adapter-fastly/src/lib.rs b/crates/edgezero-adapter-fastly/src/lib.rs index 7905fc3..0156f02 100644 --- a/crates/edgezero-adapter-fastly/src/lib.rs +++ b/crates/edgezero-adapter-fastly/src/lib.rs @@ -16,6 +16,7 @@ mod request; mod response; #[cfg(feature = "fastly")] pub mod secret_store; +mod store_handles; pub use context::FastlyRequestContext; #[cfg(feature = "fastly")] diff --git a/crates/edgezero-adapter-fastly/src/request.rs b/crates/edgezero-adapter-fastly/src/request.rs index 79b8aa6..c69a2da 100644 --- a/crates/edgezero-adapter-fastly/src/request.rs +++ b/crates/edgezero-adapter-fastly/src/request.rs @@ -15,6 +15,7 @@ use futures::executor; use crate::key_value_store::FastlyKvStore; use crate::proxy::FastlyProxyClient; use crate::response::{from_core_response, parse_uri}; +use crate::store_handles::insert_store_handles; use crate::FastlyRequestContext; /// Default Fastly KV Store name. @@ -66,26 +67,8 @@ pub fn dispatch_with_kv( kv_store_name: &str, kv_required: bool, ) -> Result { - let mut core_request = into_core_request(req).map_err(map_edge_error)?; - - match FastlyKvStore::open(kv_store_name) { - Ok(store) => { - let handle = KvHandle::new(std::sync::Arc::new(store)); - core_request.extensions_mut().insert(handle); - } - Err(e) => { - if kv_required { - return Err(FastlyError::msg(format!( - "KV store '{}' is explicitly configured but could not be opened: {}", - kv_store_name, e - ))); - } - warn_missing_kv_store_once(kv_store_name, &e); - } - } - - let response = executor::block_on(app.router().oneshot(core_request)); - from_core_response(response).map_err(map_edge_error) + let kv_handle = resolve_kv_handle(kv_store_name, kv_required)?; + dispatch_with_handles(app, req, kv_handle, None) } fn map_edge_error(err: EdgeError) -> FastlyError { @@ -116,25 +99,8 @@ pub fn dispatch_with_secrets( secret_store_name: &str, secrets_required: bool, ) -> Result { - let mut core_request = into_core_request(req).map_err(map_edge_error)?; - - if secrets_required { - match crate::secret_store::FastlySecretStore::open(secret_store_name) { - Ok(store) => { - let handle = SecretHandle::new(std::sync::Arc::new(store)); - core_request.extensions_mut().insert(handle); - } - Err(e) => { - return Err(FastlyError::msg(format!( - "secret store '{}' is explicitly configured but could not be opened: {}", - secret_store_name, e - ))); - } - } - } - - let response = executor::block_on(app.router().oneshot(core_request)); - from_core_response(response).map_err(map_edge_error) + let secret_handle = resolve_secret_handle(secret_store_name, secrets_required)?; + dispatch_with_handles(app, req, None, secret_handle) } /// Dispatch a Fastly request with both KV and secret stores attached. @@ -146,13 +112,38 @@ pub fn dispatch_with_kv_and_secrets( secret_store_name: &str, secrets_required: bool, ) -> Result { - let mut core_request = into_core_request(req).map_err(map_edge_error)?; + let kv_handle = resolve_kv_handle(kv_store_name, kv_required)?; + let secret_handle = resolve_secret_handle(secret_store_name, secrets_required)?; + dispatch_with_handles(app, req, kv_handle, secret_handle) +} +fn dispatch_with_handles( + app: &App, + req: FastlyRequest, + kv_handle: Option, + secret_handle: Option, +) -> Result { + let core_request = into_core_request(req).map_err(map_edge_error)?; + dispatch_core_request(app, core_request, kv_handle, secret_handle) +} + +fn dispatch_core_request( + app: &App, + mut core_request: Request, + kv_handle: Option, + secret_handle: Option, +) -> Result { + insert_store_handles(&mut core_request, kv_handle, secret_handle); + let response = executor::block_on(app.router().oneshot(core_request)); + from_core_response(response).map_err(map_edge_error) +} + +fn resolve_kv_handle( + kv_store_name: &str, + kv_required: bool, +) -> Result, FastlyError> { match FastlyKvStore::open(kv_store_name) { - Ok(store) => { - let handle = KvHandle::new(std::sync::Arc::new(store)); - core_request.extensions_mut().insert(handle); - } + Ok(store) => Ok(Some(KvHandle::new(std::sync::Arc::new(store)))), Err(e) => { if kv_required { return Err(FastlyError::msg(format!( @@ -161,24 +152,24 @@ pub fn dispatch_with_kv_and_secrets( ))); } warn_missing_kv_store_once(kv_store_name, &e); + Ok(None) } } +} - if secrets_required { - match crate::secret_store::FastlySecretStore::open(secret_store_name) { - Ok(store) => { - let handle = SecretHandle::new(std::sync::Arc::new(store)); - core_request.extensions_mut().insert(handle); - } - Err(e) => { - return Err(FastlyError::msg(format!( - "secret store '{}' is explicitly configured but could not be opened: {}", - secret_store_name, e - ))); - } - } +fn resolve_secret_handle( + secret_store_name: &str, + secrets_required: bool, +) -> Result, FastlyError> { + if !secrets_required { + return Ok(None); } - let response = executor::block_on(app.router().oneshot(core_request)); - from_core_response(response).map_err(map_edge_error) + match crate::secret_store::FastlySecretStore::open(secret_store_name) { + Ok(store) => Ok(Some(SecretHandle::new(std::sync::Arc::new(store)))), + Err(e) => Err(FastlyError::msg(format!( + "secret store '{}' is explicitly configured but could not be opened: {}", + secret_store_name, e + ))), + } } diff --git a/crates/edgezero-adapter-fastly/src/store_handles.rs b/crates/edgezero-adapter-fastly/src/store_handles.rs new file mode 100644 index 0000000..1f54fdb --- /dev/null +++ b/crates/edgezero-adapter-fastly/src/store_handles.rs @@ -0,0 +1,113 @@ +#[cfg(any(test, feature = "fastly"))] +use edgezero_core::http::Request; +#[cfg(any(test, feature = "fastly"))] +use edgezero_core::key_value_store::KvHandle; +#[cfg(any(test, feature = "fastly"))] +use edgezero_core::secret_store::SecretHandle; + +#[cfg(any(test, feature = "fastly"))] +pub(crate) fn insert_store_handles( + request: &mut Request, + kv_handle: Option, + secret_handle: Option, +) { + if let Some(handle) = kv_handle { + request.extensions_mut().insert(handle); + } + + if let Some(handle) = secret_handle { + request.extensions_mut().insert(handle); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use async_trait::async_trait; + use bytes::Bytes; + use edgezero_core::body::Body; + use edgezero_core::http::request_builder; + use edgezero_core::key_value_store::{KvError, KvPage, KvStore}; + use edgezero_core::secret_store::{SecretError, SecretStore}; + use std::sync::Arc; + use std::time::Duration; + + struct DummyKvStore; + + #[async_trait(?Send)] + impl KvStore for DummyKvStore { + async fn get_bytes(&self, _key: &str) -> Result, KvError> { + Ok(None) + } + + async fn put_bytes(&self, _key: &str, _value: Bytes) -> Result<(), KvError> { + Ok(()) + } + + async fn put_bytes_with_ttl( + &self, + _key: &str, + _value: Bytes, + _ttl: Duration, + ) -> Result<(), KvError> { + Ok(()) + } + + async fn delete(&self, _key: &str) -> Result<(), KvError> { + Ok(()) + } + + async fn list_keys_page( + &self, + _prefix: &str, + _cursor: Option<&str>, + _limit: usize, + ) -> Result { + Ok(KvPage { + keys: Vec::new(), + cursor: None, + }) + } + } + + struct DummySecretStore; + + #[async_trait(?Send)] + impl SecretStore for DummySecretStore { + async fn get_bytes(&self, _name: &str) -> Result, SecretError> { + Ok(None) + } + } + + #[test] + fn insert_store_handles_adds_present_handles() { + let mut request = request_builder() + .uri("https://example.com") + .body(Body::empty()) + .expect("request"); + let kv_handle = KvHandle::new(Arc::new(DummyKvStore)); + let secret_handle = SecretHandle::new(Arc::new(DummySecretStore)); + + insert_store_handles( + &mut request, + Some(kv_handle.clone()), + Some(secret_handle.clone()), + ); + + assert!(request.extensions().get::().is_some()); + assert!(request.extensions().get::().is_some()); + } + + #[test] + fn insert_store_handles_skips_absent_handles() { + let mut request = request_builder() + .uri("https://example.com") + .body(Body::empty()) + .expect("request"); + + insert_store_handles(&mut request, None, None); + + assert!(request.extensions().get::().is_none()); + assert!(request.extensions().get::().is_none()); + } +} diff --git a/docs/guide/configuration.md b/docs/guide/configuration.md index a7a34cb..55770ba 100644 --- a/docs/guide/configuration.md +++ b/docs/guide/configuration.md @@ -137,6 +137,48 @@ Variables with a default `value` are injected when running CLI commands. Secrets must be present in the environment; missing secrets abort CLI commands with an error. +These declarations are for CLI and deployment workflows. To expose a runtime +secret store to request handlers, configure `[stores.secrets]`. + +## Runtime Secret Stores + +Use `[stores.secrets]` when your application reads secrets at request time via +the `Secrets` extractor. This is separate from `[[environment.secrets]]`: + +- `[[environment.secrets]]` declares required environment variables for CLI commands +- `[stores.secrets]` enables runtime secret lookup during request handling + +```toml +[stores.secrets] +name = "EDGEZERO_SECRETS" + +[stores.secrets.adapters.fastly] +name = "MY_FASTLY_SECRETS" +``` + +### Global Fields + +| Field | Required | Description | +| --------- | -------- | ----------------------------------------------------------------------------------------------------------- | +| `enabled` | No | Whether secrets are enabled for adapters without overrides (defaults to `true` when the section is present) | +| `name` | No | Store or binding name (defaults to `EDGEZERO_SECRETS`) | + +### Per-Adapter Overrides + +| Field | Required | Description | +| ---------------------------- | -------- | --------------------------------------------- | +| `adapters..enabled` | No | Override whether that adapter exposes secrets | +| `adapters..name` | No | Override the adapter-specific store name | + +### Adapter Behavior + +- Axum reads secrets from process environment variables of the same name. +- Fastly opens the configured secret store name from `fastly.toml`. +- Cloudflare reads Worker Secrets individually; the configured `name` is metadata only. + +If `[stores.secrets]` is omitted, the `Secrets` extractor is not attached for +that adapter. + ## Adapters Section Each adapter has its own configuration block: @@ -238,6 +280,9 @@ value = "https://api.example.com" [[environment.secrets]] name = "API_KEY" +[stores.secrets] +name = "EDGEZERO_SECRETS" + [adapters.fastly.adapter] crate = "crates/my-app-adapter-fastly" manifest = "crates/my-app-adapter-fastly/fastly.toml" diff --git a/examples/app-demo/crates/app-demo-adapter-fastly/fastly.toml b/examples/app-demo/crates/app-demo-adapter-fastly/fastly.toml index 8d5c4ac..c95fd2f 100644 --- a/examples/app-demo/crates/app-demo-adapter-fastly/fastly.toml +++ b/examples/app-demo/crates/app-demo-adapter-fastly/fastly.toml @@ -16,11 +16,21 @@ service_id = "" key = "__init__" data = "" +[local_server.secret_stores] + +[[local_server.secret_stores.EDGEZERO_SECRETS]] +key = "SMOKE_SECRET" +env = "SMOKE_SECRET" + [setup] [setup.kv_stores] [setup.kv_stores.EDGEZERO_KV] description = "KV store for EdgeZero demo" +[setup.secret_stores] +[setup.secret_stores.EDGEZERO_SECRETS] +description = "Secret store for EdgeZero demo" + [scripts] build = "cargo build --profile release --target wasm32-wasip1" 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 d31575d..22d310a 100644 --- a/examples/app-demo/crates/app-demo-core/src/handlers.rs +++ b/examples/app-demo/crates/app-demo-core/src/handlers.rs @@ -10,6 +10,8 @@ use edgezero_core::response::Text; use futures::{stream, StreamExt}; const DEFAULT_PROXY_BASE: &str = "https://httpbin.org"; +const SMOKE_SECRET_NAME: &str = "SMOKE_SECRET"; +const SMOKE_SECRET_MISSING_NAME: &str = "SMOKE_SECRET_MISSING"; #[derive(serde::Deserialize)] pub(crate) struct EchoParams { @@ -195,17 +197,30 @@ pub(crate) async fn kv_note_delete( // Secrets demo handler — illustrates platform-neutral secret access. // WARNING: This handler returns the raw secret value in the response body. // It exists solely for smoke-testing. Never do this in production. +// Only fixed smoke-test key names are accepted. // --------------------------------------------------------------------------- -/// Echo the value of a named secret from the configured secret store. +/// Echo the value of an allowlisted smoke-test secret from the configured store. /// -/// Usage: GET /secrets/echo?name=MY_SECRET_NAME +/// Usage: GET /secrets/echo?name=SMOKE_SECRET #[action] pub(crate) async fn secrets_echo( Secrets(store): Secrets, Query(params): Query, ) -> Result, EdgeError> { - let value = store.require_str(¶ms.name).await.map_err(EdgeError::from)?; + match params.name.as_str() { + SMOKE_SECRET_NAME | SMOKE_SECRET_MISSING_NAME => {} + _ => { + return Err(EdgeError::bad_request( + "only smoke-test secret names are allowed", + )) + } + } + + let value = store + .require_str(¶ms.name) + .await + .map_err(EdgeError::from)?; Ok(Text::new(value)) } @@ -539,11 +554,7 @@ mod tests { use edgezero_core::secret_store::{InMemorySecretStore, SecretHandle}; - fn context_with_secrets( - path: &str, - query: &str, - entries: &[(&str, &str)], - ) -> RequestContext { + fn context_with_secrets(path: &str, query: &str, entries: &[(&str, &str)]) -> RequestContext { let store = InMemorySecretStore::new( entries .iter() @@ -564,8 +575,8 @@ mod tests { fn secrets_echo_returns_secret_value() { let ctx = context_with_secrets( "/secrets/echo", - "name=API_KEY", - &[("API_KEY", "my-secret-value")], + "name=SMOKE_SECRET", + &[("SMOKE_SECRET", "my-secret-value")], ); let response = block_on(secrets_echo(ctx)) .expect("handler ok") @@ -575,10 +586,32 @@ mod tests { } #[test] - fn secrets_echo_returns_500_for_missing_secret() { + fn secrets_echo_returns_sanitized_500_for_missing_allowed_secret() { + use edgezero_core::http::StatusCode; + + let ctx = context_with_secrets("/secrets/echo", "name=SMOKE_SECRET_MISSING", &[]); + let response = block_on(secrets_echo(ctx)) + .expect_err("should fail") + .into_response(); + + assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR); + let body = String::from_utf8(response.into_body().into_bytes().to_vec()).expect("utf8"); + assert!(body.contains("required secret is not configured")); + assert!(!body.contains("SMOKE_SECRET_MISSING")); + } + + #[test] + fn secrets_echo_rejects_non_smoke_secret_names() { use edgezero_core::http::StatusCode; - let ctx = context_with_secrets("/secrets/echo", "name=MISSING_KEY", &[]); - let err = block_on(secrets_echo(ctx)).expect_err("should fail"); - assert_eq!(err.status(), StatusCode::INTERNAL_SERVER_ERROR); + + let ctx = context_with_secrets("/secrets/echo", "name=API_KEY", &[("API_KEY", "secret")]); + let response = block_on(secrets_echo(ctx)) + .expect_err("should reject arbitrary secret names") + .into_response(); + + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + let body = String::from_utf8(response.into_body().into_bytes().to_vec()).expect("utf8"); + assert!(body.contains("only smoke-test secret names are allowed")); + assert!(!body.contains("API_KEY")); } } diff --git a/examples/app-demo/edgezero.toml b/examples/app-demo/edgezero.toml index 7c505c8..34bdccf 100644 --- a/examples/app-demo/edgezero.toml +++ b/examples/app-demo/edgezero.toml @@ -94,7 +94,7 @@ path = "/secrets/echo" methods = ["GET"] handler = "app_demo_core::handlers::secrets_echo" adapters = ["axum", "cloudflare", "fastly"] -description = "Echo a named secret value (smoke-test only — do not use in production)" +description = "Echo an allowlisted smoke-test secret value (smoke-test only — do not use in production)" # -- Stores ---------------------------------------------------------------- diff --git a/scripts/smoke_test_secrets.sh b/scripts/smoke_test_secrets.sh new file mode 100755 index 0000000..764c1a3 --- /dev/null +++ b/scripts/smoke_test_secrets.sh @@ -0,0 +1,195 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Smoke-test the secret-store demo handlers by starting an adapter, running +# checks, and tearing it down automatically. +# +# Usage: +# ./scripts/smoke_test_secrets.sh # defaults to axum +# ./scripts/smoke_test_secrets.sh axum +# ./scripts/smoke_test_secrets.sh fastly +# ./scripts/smoke_test_secrets.sh cloudflare + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +DEMO_DIR="$ROOT_DIR/examples/app-demo" +ADAPTER="${1:-axum}" +SERVER_PID="" +DEV_VARS_FILE="" +SMOKE_SECRET_NAME="SMOKE_SECRET" +MISSING_SECRET_NAME="SMOKE_SECRET_MISSING" +DISALLOWED_SECRET_NAME="API_KEY" +SMOKE_SECRET_VALUE="smoke-secret-$(date +%s)-$$" +PASS=0 +FAIL=0 + +export SMOKE_SECRET="$SMOKE_SECRET_VALUE" + +cleanup() { + if [ -n "$SERVER_PID" ]; then + echo "" + echo "==> Stopping server (PID $SERVER_PID)..." + pkill -P "$SERVER_PID" 2>/dev/null || true + kill "$SERVER_PID" 2>/dev/null || true + wait "$SERVER_PID" 2>/dev/null || true + fi + + if [ -n "$DEV_VARS_FILE" ] && [ -f "$DEV_VARS_FILE" ]; then + rm -f "$DEV_VARS_FILE" + fi +} +trap cleanup EXIT + +section() { + printf '\n--- %s ---\n' "$1" +} + +check() { + local label="$1" expect="$2" actual="$3" + if [ "$actual" = "$expect" ]; then + printf ' PASS %s\n' "$label" + PASS=$((PASS + 1)) + else + printf ' FAIL %s (expected %s, got %s)\n' "$label" "$expect" "$actual" + FAIL=$((FAIL + 1)) + fi +} + +check_contains() { + local label="$1" needle="$2" haystack="$3" + if [[ "$haystack" == *"$needle"* ]]; then + printf ' PASS %s\n' "$label" + PASS=$((PASS + 1)) + else + printf ' FAIL %s (expected body to contain %s)\n' "$label" "$needle" + FAIL=$((FAIL + 1)) + fi +} + +check_not_contains() { + local label="$1" needle="$2" haystack="$3" + if [[ "$haystack" == *"$needle"* ]]; then + printf ' FAIL %s (body unexpectedly contained %s)\n' "$label" "$needle" + FAIL=$((FAIL + 1)) + else + printf ' PASS %s\n' "$label" + PASS=$((PASS + 1)) + fi +} + +start_server() { + case "$ADAPTER" in + axum) + PORT=8787 + echo "==> Building app-demo (axum)..." + (cd "$DEMO_DIR" && cargo build -p app-demo-adapter-axum 2>&1) + echo "==> Starting Axum adapter on port $PORT..." + (cd "$DEMO_DIR" && cargo run -p app-demo-adapter-axum 2>&1) & + SERVER_PID=$! + ;; + fastly) + PORT=7676 + command -v fastly >/dev/null 2>&1 || { + echo "Fastly CLI is required. Install from https://developer.fastly.com/reference/cli/" >&2 + exit 1 + } + echo "==> Starting Fastly Viceroy on port $PORT..." + (cd "$DEMO_DIR" && fastly compute serve -C crates/app-demo-adapter-fastly 2>&1) & + SERVER_PID=$! + ;; + cloudflare) + PORT=8787 + command -v wrangler >/dev/null 2>&1 || { + echo "wrangler is required. Install with 'npm i -g wrangler'" >&2 + exit 1 + } + DEV_VARS_FILE="$DEMO_DIR/crates/app-demo-adapter-cloudflare/.dev.vars" + printf '%s=%s\n' "$SMOKE_SECRET_NAME" "$SMOKE_SECRET_VALUE" > "$DEV_VARS_FILE" + echo "==> Starting Cloudflare wrangler dev on port $PORT..." + (cd "$DEMO_DIR" && wrangler dev --cwd crates/app-demo-adapter-cloudflare --port "$PORT" 2>&1) & + SERVER_PID=$! + ;; + *) + echo "Unknown adapter: $ADAPTER" >&2 + echo "Usage: $0 [axum|fastly|cloudflare]" >&2 + exit 1 + ;; + esac +} + +wait_for_server() { + BASE="http://127.0.0.1:${PORT}" + + echo "==> Waiting for server at $BASE ..." + MAX_WAIT=60 + WAITED=0 + until curl -fsS -o /dev/null "$BASE/" 2>/dev/null; do + if ! kill -0 "$SERVER_PID" 2>/dev/null; then + echo "Server process exited early" >&2 + return 1 + fi + sleep 1 + WAITED=$((WAITED + 1)) + if [ "$WAITED" -ge "$MAX_WAIT" ]; then + echo "Server did not start within ${MAX_WAIT}s" >&2 + return 1 + fi + done + echo "==> Server ready (${WAITED}s)" +} + +run_checks() { + section "Health check" + STATUS=$(curl -s -o /dev/null -w '%{http_code}' "$BASE/") + check "GET / returns 200" "200" "$STATUS" + + section "Secret echo" + STATUS=$(curl -s -o /dev/null -w '%{http_code}' "$BASE/secrets/echo?name=$SMOKE_SECRET_NAME") + check "GET /secrets/echo?name=$SMOKE_SECRET_NAME returns 200" "200" "$STATUS" + + BODY=$(curl -s "$BASE/secrets/echo?name=$SMOKE_SECRET_NAME") + check "GET /secrets/echo?name=$SMOKE_SECRET_NAME returns secret value" "$SMOKE_SECRET_VALUE" "$BODY" + + STATUS=$(curl -s -o /dev/null -w '%{http_code}' "$BASE/secrets/echo?name=$MISSING_SECRET_NAME") + check "GET /secrets/echo?name=$MISSING_SECRET_NAME returns 500" "500" "$STATUS" + + BODY=$(curl -s "$BASE/secrets/echo?name=$MISSING_SECRET_NAME") + check_contains \ + "Missing allowed secret response is sanitized" \ + "required secret is not configured" \ + "$BODY" + check_not_contains \ + "Missing allowed secret response does not leak the key name" \ + "$MISSING_SECRET_NAME" \ + "$BODY" + + STATUS=$(curl -s -o /dev/null -w '%{http_code}' "$BASE/secrets/echo?name=$DISALLOWED_SECRET_NAME") + check "GET /secrets/echo?name=$DISALLOWED_SECRET_NAME returns 400" "400" "$STATUS" + + BODY=$(curl -s "$BASE/secrets/echo?name=$DISALLOWED_SECRET_NAME") + check_contains \ + "Disallowed secret name returns a policy error" \ + "only smoke-test secret names are allowed" \ + "$BODY" + check_not_contains \ + "Disallowed secret name response does not echo user input" \ + "$DISALLOWED_SECRET_NAME" \ + "$BODY" +} + +start_server + +if wait_for_server; then + run_checks +else + FAIL=$((FAIL + 1)) + echo "==> Skipping checks because the server did not become ready" +fi + +printf '\n==============================\n' +printf 'Adapter: %s\n' "$ADAPTER" +printf 'Secret: %s\n' "$SMOKE_SECRET_NAME" +printf 'Missing: %s\n' "$MISSING_SECRET_NAME" +printf 'Results: %d passed, %d failed\n' "$PASS" "$FAIL" +printf '==============================\n' + +[ "$FAIL" -eq 0 ] || exit 1 From 3da648bab193bfbdf0f64d7893bd8a158b40e701 Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Thu, 26 Mar 2026 17:50:19 +0530 Subject: [PATCH 17/18] refactor(secret-store): collapse two-trait design into single SecretStore + SecretHandle Replace the redundant single-arg SecretStore / SecretHandle pair and the two-arg SecretStoreProvider / SecretProviderHandle pair with a single two-arg SecretStore trait and SecretHandle wrapper, matching the KvStore / KvHandle naming convention. All adapters now implement SecretStore with get_bytes(store_name, key). Fastly opens a named store per lookup (FastlyNamedStore internal helper); Cloudflare and Axum ignore store_name (flat namespaces). InMemorySecretStore and NoopSecretStore are the test/noop implementations. --- .../edgezero-adapter-axum/src/dev_server.rs | 14 +- .../edgezero-adapter-axum/src/secret_store.rs | 24 +- crates/edgezero-adapter-axum/src/service.rs | 3 +- crates/edgezero-adapter-cloudflare/Cargo.toml | 1 + crates/edgezero-adapter-cloudflare/src/lib.rs | 2 - .../src/secret_store.rs | 4 +- .../src/store_handles.rs | 13 +- .../tests/contract.rs | 6 +- crates/edgezero-adapter-fastly/Cargo.toml | 1 + crates/edgezero-adapter-fastly/src/lib.rs | 22 +- crates/edgezero-adapter-fastly/src/request.rs | 24 +- .../src/secret_store.rs | 46 ++- .../src/store_handles.rs | 13 +- .../edgezero-adapter-fastly/tests/contract.rs | 9 +- crates/edgezero-core/src/extractor.rs | 2 +- crates/edgezero-core/src/lib.rs | 2 + crates/edgezero-core/src/secret_store.rs | 343 ++++++++++-------- .../crates/app-demo-core/src/handlers.rs | 16 +- 18 files changed, 271 insertions(+), 274 deletions(-) diff --git a/crates/edgezero-adapter-axum/src/dev_server.rs b/crates/edgezero-adapter-axum/src/dev_server.rs index 52d489b..343da7c 100644 --- a/crates/edgezero-adapter-axum/src/dev_server.rs +++ b/crates/edgezero-adapter-axum/src/dev_server.rs @@ -247,7 +247,6 @@ pub fn run_app(manifest_src: &str) -> anyhow::Result<()> { let kv_init_requirement = kv_init_requirement(manifest); let kv_store_name = manifest.kv_store_name("axum").to_string(); let kv_path = kv_store_path(&kv_store_name); - let secret_store_name = manifest.secret_store_name("axum").to_string(); let has_secret_store = manifest.secret_store_enabled("axum"); let level: LevelFilter = logging.level.into(); @@ -301,10 +300,7 @@ pub fn run_app(manifest_src: &str) -> anyhow::Result<()> { } }; let secret_handle = if has_secret_store { - log::info!( - "Secret store '{}': reading from environment variables", - secret_store_name - ); + log::info!("Secret store: reading from environment variables"); Some(edgezero_core::secret_store::SecretHandle::new( std::sync::Arc::new(crate::secret_store::EnvSecretStore::new()), )) @@ -838,7 +834,10 @@ mod integration_tests { #[action] async fn secret_value_handler(Secrets(store): Secrets) -> Result { - store.require_str("API_KEY").await.map_err(EdgeError::from) + store + .require_str("test-store", "API_KEY") + .await + .map_err(EdgeError::from) } // ----------------------------------------------------------------------- @@ -853,7 +852,8 @@ mod integration_tests { let router = RouterService::builder() .get("/secret", secret_value_handler) .build(); - let store = InMemorySecretStore::new([("API_KEY", bytes::Bytes::from("s3cr3t"))]); + let store = + InMemorySecretStore::new([("test-store/API_KEY", bytes::Bytes::from("s3cr3t"))]); let handle = SecretHandle::new(Arc::new(store)); let server = start_test_server_with_secret_handle(router, Some(handle)).await; diff --git a/crates/edgezero-adapter-axum/src/secret_store.rs b/crates/edgezero-adapter-axum/src/secret_store.rs index fca358e..214f853 100644 --- a/crates/edgezero-adapter-axum/src/secret_store.rs +++ b/crates/edgezero-adapter-axum/src/secret_store.rs @@ -31,12 +31,12 @@ impl Default for EnvSecretStore { #[async_trait(?Send)] impl SecretStore for EnvSecretStore { - async fn get_bytes(&self, name: &str) -> Result, SecretError> { + async fn get_bytes(&self, _store_name: &str, key: &str) -> Result, SecretError> { #[cfg(unix)] { use std::os::unix::ffi::OsStringExt; - match std::env::var_os(name) { + match std::env::var_os(key) { Some(value) => Ok(Some(Bytes::from(value.into_vec()))), None => Ok(None), } @@ -44,7 +44,7 @@ impl SecretStore for EnvSecretStore { #[cfg(not(unix))] { - match std::env::var(name) { + match std::env::var(key) { Ok(value) => Ok(Some(Bytes::from(value.into_bytes()))), Err(std::env::VarError::NotPresent) => Ok(None), Err(std::env::VarError::NotUnicode(_)) => Err(SecretError::Internal( @@ -102,7 +102,7 @@ mod tests { let _env = EnvOverride::clear("__EDGEZERO_TEST_MISSING_VAR_XYZ__"); let store = EnvSecretStore::new(); let result = store - .get_bytes("__EDGEZERO_TEST_MISSING_VAR_XYZ__") + .get_bytes("env", "__EDGEZERO_TEST_MISSING_VAR_XYZ__") .await .unwrap(); assert!(result.is_none()); @@ -113,7 +113,10 @@ mod tests { let _guard = env_guard().lock().await; let _env = EnvOverride::set("__EDGEZERO_TEST_SECRET__", "test_value_123"); let store = EnvSecretStore::new(); - let result = store.get_bytes("__EDGEZERO_TEST_SECRET__").await.unwrap(); + let result = store + .get_bytes("env", "__EDGEZERO_TEST_SECRET__") + .await + .unwrap(); assert_eq!(result, Some(Bytes::from("test_value_123"))); } @@ -129,22 +132,21 @@ mod tests { ); let store = EnvSecretStore::new(); let result = store - .get_bytes("__EDGEZERO_TEST_BINARY_SECRET__") + .get_bytes("env", "__EDGEZERO_TEST_BINARY_SECRET__") .await .unwrap(); assert_eq!(result, Some(Bytes::from_static(&[0xff, 0x61]))); } - // Contract tests: use InMemorySecretStore since EnvSecretStore needs + // Contract tests: use InMemorySecretStoreProvider since EnvSecretStore needs // real env vars, which are unsafe in parallel tests. // The EnvSecretStore is tested individually above. - use edgezero_core::secret_store::InMemorySecretStore; use edgezero_core::secret_store_contract_tests; secret_store_contract_tests!(env_secret_contract, { - InMemorySecretStore::new([ - ("contract_key", Bytes::from("contract_value")), - ("contract_key_2", Bytes::from("another_value")), + edgezero_core::InMemorySecretStore::new([ + ("mystore/contract_key", Bytes::from("contract_value")), + ("mystore/contract_key_2", Bytes::from("another_value")), ]) }); } diff --git a/crates/edgezero-adapter-axum/src/service.rs b/crates/edgezero-adapter-axum/src/service.rs index 169f469..d5c69c7 100644 --- a/crates/edgezero-adapter-axum/src/service.rs +++ b/crates/edgezero-adapter-axum/src/service.rs @@ -206,9 +206,10 @@ mod tests { .secret_handle() .expect("secret handle should be present"); let val = secrets - .get_str("__EDGEZERO_SERVICE_TEST_SECRET__") + .get_bytes("env", "__EDGEZERO_SERVICE_TEST_SECRET__") .await .unwrap() + .map(|b| String::from_utf8_lossy(&b).into_owned()) .unwrap_or_default(); let response = response_builder() .status(StatusCode::OK) diff --git a/crates/edgezero-adapter-cloudflare/Cargo.toml b/crates/edgezero-adapter-cloudflare/Cargo.toml index 43f4f1e..aedf270 100644 --- a/crates/edgezero-adapter-cloudflare/Cargo.toml +++ b/crates/edgezero-adapter-cloudflare/Cargo.toml @@ -36,6 +36,7 @@ walkdir = { workspace = true, optional = true } wasm-bindgen-test = "0.3" [dev-dependencies] +edgezero-core = { path = "../edgezero-core", features = ["test-utils"] } web-sys = { version = "0.3", features = [ "Window", "Response", diff --git a/crates/edgezero-adapter-cloudflare/src/lib.rs b/crates/edgezero-adapter-cloudflare/src/lib.rs index 6e5d99b..4de08c7 100644 --- a/crates/edgezero-adapter-cloudflare/src/lib.rs +++ b/crates/edgezero-adapter-cloudflare/src/lib.rs @@ -28,8 +28,6 @@ pub use request::{ }; #[cfg(all(feature = "cloudflare", target_arch = "wasm32"))] pub use response::from_core_response; -#[cfg(all(feature = "cloudflare", target_arch = "wasm32"))] -pub use secret_store::CloudflareSecretStore; #[cfg(all(feature = "cloudflare", target_arch = "wasm32"))] pub fn init_logger() -> Result<(), log::SetLoggerError> { diff --git a/crates/edgezero-adapter-cloudflare/src/secret_store.rs b/crates/edgezero-adapter-cloudflare/src/secret_store.rs index 37d542c..e8c3bbe 100644 --- a/crates/edgezero-adapter-cloudflare/src/secret_store.rs +++ b/crates/edgezero-adapter-cloudflare/src/secret_store.rs @@ -38,8 +38,8 @@ impl CloudflareSecretStore { #[cfg(all(feature = "cloudflare", target_arch = "wasm32"))] #[async_trait(?Send)] impl SecretStore for CloudflareSecretStore { - async fn get_bytes(&self, name: &str) -> Result, SecretError> { - match self.env.secret(name) { + async fn get_bytes(&self, _store_name: &str, key: &str) -> Result, SecretError> { + match self.env.secret(key) { Ok(secret) => { let value = secret.to_string(); Ok(Some(Bytes::from(value.into_bytes()))) diff --git a/crates/edgezero-adapter-cloudflare/src/store_handles.rs b/crates/edgezero-adapter-cloudflare/src/store_handles.rs index d9f1bfb..5e50509 100644 --- a/crates/edgezero-adapter-cloudflare/src/store_handles.rs +++ b/crates/edgezero-adapter-cloudflare/src/store_handles.rs @@ -28,7 +28,7 @@ mod tests { use edgezero_core::body::Body; use edgezero_core::http::request_builder; use edgezero_core::key_value_store::{KvError, KvPage, KvStore}; - use edgezero_core::secret_store::{SecretError, SecretStore}; + use edgezero_core::secret_store::{NoopSecretStore, SecretHandle}; use std::sync::Arc; use std::time::Duration; @@ -70,15 +70,6 @@ mod tests { } } - struct DummySecretStore; - - #[async_trait(?Send)] - impl SecretStore for DummySecretStore { - async fn get_bytes(&self, _name: &str) -> Result, SecretError> { - Ok(None) - } - } - #[test] fn insert_store_handles_adds_present_handles() { let mut request = request_builder() @@ -86,7 +77,7 @@ mod tests { .body(Body::empty()) .expect("request"); let kv_handle = KvHandle::new(Arc::new(DummyKvStore)); - let secret_handle = SecretHandle::new(Arc::new(DummySecretStore)); + let secret_handle = SecretHandle::new(Arc::new(NoopSecretStore)); insert_store_handles( &mut request, diff --git a/crates/edgezero-adapter-cloudflare/tests/contract.rs b/crates/edgezero-adapter-cloudflare/tests/contract.rs index 4ca9603..279436e 100644 --- a/crates/edgezero-adapter-cloudflare/tests/contract.rs +++ b/crates/edgezero-adapter-cloudflare/tests/contract.rs @@ -168,11 +168,11 @@ async fn dispatch_passes_request_body_to_handlers() { #[cfg(all(feature = "cloudflare", target_arch = "wasm32"))] mod secret_store_compile_check { - use edgezero_adapter_cloudflare::CloudflareSecretStore; + use edgezero_adapter_cloudflare::secret_store::CloudflareSecretStore; use edgezero_core::secret_store::SecretStore; - fn _assert_impl() {} + fn _assert_provider_impl() {} fn _check() { - _assert_impl::(); + _assert_provider_impl::(); } } diff --git a/crates/edgezero-adapter-fastly/Cargo.toml b/crates/edgezero-adapter-fastly/Cargo.toml index fe112df..f052e57 100644 --- a/crates/edgezero-adapter-fastly/Cargo.toml +++ b/crates/edgezero-adapter-fastly/Cargo.toml @@ -37,4 +37,5 @@ chrono = { workspace = true } walkdir = { workspace = true, optional = true } [dev-dependencies] +edgezero-core = { path = "../edgezero-core", features = ["test-utils"] } tempfile = { workspace = true } diff --git a/crates/edgezero-adapter-fastly/src/lib.rs b/crates/edgezero-adapter-fastly/src/lib.rs index 0156f02..e315207 100644 --- a/crates/edgezero-adapter-fastly/src/lib.rs +++ b/crates/edgezero-adapter-fastly/src/lib.rs @@ -92,16 +92,8 @@ pub fn run_app( let logging = manifest.logging_or_default("fastly"); let kv_name = manifest.kv_store_name("fastly").to_string(); let kv_required = manifest.stores.kv.is_some(); - let secret_name = manifest.secret_store_name("fastly").to_string(); let secrets_required = manifest.secret_store_enabled("fastly"); - run_app_with_logging::( - logging.into(), - req, - &kv_name, - kv_required, - &secret_name, - secrets_required, - ) + run_app_with_logging::(logging.into(), req, &kv_name, kv_required, secrets_required) } #[cfg(feature = "fastly")] @@ -110,7 +102,6 @@ pub(crate) fn run_app_with_logging( req: fastly::Request, kv_store_name: &str, kv_required: bool, - secret_store_name: &str, secrets_required: bool, ) -> Result { if logging.use_fastly_logger { @@ -120,16 +111,9 @@ pub(crate) fn run_app_with_logging( let app = A::build_app(); if secrets_required && kv_required { - dispatch_with_kv_and_secrets( - &app, - req, - kv_store_name, - kv_required, - secret_store_name, - secrets_required, - ) + dispatch_with_kv_and_secrets(&app, req, kv_store_name, kv_required, secrets_required) } else if secrets_required { - dispatch_with_secrets(&app, req, secret_store_name, secrets_required) + dispatch_with_secrets(&app, req, secrets_required) } else { dispatch_with_kv(&app, req, kv_store_name, kv_required) } diff --git a/crates/edgezero-adapter-fastly/src/request.rs b/crates/edgezero-adapter-fastly/src/request.rs index c69a2da..d76df02 100644 --- a/crates/edgezero-adapter-fastly/src/request.rs +++ b/crates/edgezero-adapter-fastly/src/request.rs @@ -96,10 +96,9 @@ fn warn_missing_kv_store_once(kv_store_name: &str, error: &impl std::fmt::Displa pub fn dispatch_with_secrets( app: &App, req: FastlyRequest, - secret_store_name: &str, secrets_required: bool, ) -> Result { - let secret_handle = resolve_secret_handle(secret_store_name, secrets_required)?; + let secret_handle = resolve_secret_handle(secrets_required); dispatch_with_handles(app, req, None, secret_handle) } @@ -109,11 +108,10 @@ pub fn dispatch_with_kv_and_secrets( req: FastlyRequest, kv_store_name: &str, kv_required: bool, - secret_store_name: &str, secrets_required: bool, ) -> Result { let kv_handle = resolve_kv_handle(kv_store_name, kv_required)?; - let secret_handle = resolve_secret_handle(secret_store_name, secrets_required)?; + let secret_handle = resolve_secret_handle(secrets_required); dispatch_with_handles(app, req, kv_handle, secret_handle) } @@ -157,19 +155,11 @@ fn resolve_kv_handle( } } -fn resolve_secret_handle( - secret_store_name: &str, - secrets_required: bool, -) -> Result, FastlyError> { +fn resolve_secret_handle(secrets_required: bool) -> Option { if !secrets_required { - return Ok(None); - } - - match crate::secret_store::FastlySecretStore::open(secret_store_name) { - Ok(store) => Ok(Some(SecretHandle::new(std::sync::Arc::new(store)))), - Err(e) => Err(FastlyError::msg(format!( - "secret store '{}' is explicitly configured but could not be opened: {}", - secret_store_name, e - ))), + return None; } + Some(SecretHandle::new(std::sync::Arc::new( + crate::secret_store::FastlySecretStore, + ))) } diff --git a/crates/edgezero-adapter-fastly/src/secret_store.rs b/crates/edgezero-adapter-fastly/src/secret_store.rs index c07e7c2..6458aa0 100644 --- a/crates/edgezero-adapter-fastly/src/secret_store.rs +++ b/crates/edgezero-adapter-fastly/src/secret_store.rs @@ -1,23 +1,24 @@ -//! Fastly SecretStore adapter. +//! Fastly secret store adapter. //! -//! Wraps `fastly::secret_store::SecretStore` to implement -//! `edgezero_core::secret_store::SecretStore`. +//! Implements `edgezero_core::secret_store::SecretStore` via +//! `FastlySecretStore`, which opens a named Fastly SecretStore on +//! each lookup. #[cfg(feature = "fastly")] use async_trait::async_trait; #[cfg(feature = "fastly")] use bytes::Bytes; #[cfg(feature = "fastly")] -use edgezero_core::secret_store::{SecretError, SecretStore}; +use edgezero_core::secret_store::SecretError; -/// Secret store backed by Fastly's SecretStore API. +/// Internal helper that opens a single named Fastly SecretStore. #[cfg(feature = "fastly")] -pub struct FastlySecretStore { +pub struct FastlyNamedStore { store: fastly::secret_store::SecretStore, } #[cfg(feature = "fastly")] -impl FastlySecretStore { +impl FastlyNamedStore { /// Open a Fastly SecretStore by name. /// /// Returns `SecretError::Internal` if the store does not exist or cannot @@ -33,15 +34,11 @@ impl FastlySecretStore { })?; Ok(Self { store }) } -} -#[cfg(feature = "fastly")] -#[async_trait(?Send)] -impl SecretStore for FastlySecretStore { - async fn get_bytes(&self, name: &str) -> Result, SecretError> { + pub(crate) fn get_bytes_sync(&self, key: &str) -> Result, SecretError> { let secret = self .store - .try_get(name) + .try_get(key) .map_err(|e| SecretError::Internal(anyhow::anyhow!("secret lookup failed: {e}")))?; match secret { @@ -53,5 +50,26 @@ impl SecretStore for FastlySecretStore { } } +/// Multi-store provider backed by Fastly's SecretStore API. +/// +/// Opens the named store per call — `FastlyNamedStore::open` is cheap +/// (no network; just a handle) so there is no caching. +#[cfg(feature = "fastly")] +pub struct FastlySecretStore; + +#[cfg(feature = "fastly")] +#[async_trait(?Send)] +impl edgezero_core::secret_store::SecretStore for FastlySecretStore { + async fn get_bytes( + &self, + store_name: &str, + key: &str, + ) -> Result, edgezero_core::secret_store::SecretError> { + let store = FastlyNamedStore::open(store_name)?; + store.get_bytes_sync(key) + } +} + // TODO: integration tests require the Fastly compute environment. -// Test `FastlySecretStore` as part of the Fastly adapter E2E test suite. +// Test `FastlyNamedStore` and `FastlySecretStore` as part of the +// Fastly adapter E2E test suite. diff --git a/crates/edgezero-adapter-fastly/src/store_handles.rs b/crates/edgezero-adapter-fastly/src/store_handles.rs index 1f54fdb..afdbb1a 100644 --- a/crates/edgezero-adapter-fastly/src/store_handles.rs +++ b/crates/edgezero-adapter-fastly/src/store_handles.rs @@ -28,7 +28,7 @@ mod tests { use edgezero_core::body::Body; use edgezero_core::http::request_builder; use edgezero_core::key_value_store::{KvError, KvPage, KvStore}; - use edgezero_core::secret_store::{SecretError, SecretStore}; + use edgezero_core::secret_store::{NoopSecretStore, SecretHandle}; use std::sync::Arc; use std::time::Duration; @@ -70,15 +70,6 @@ mod tests { } } - struct DummySecretStore; - - #[async_trait(?Send)] - impl SecretStore for DummySecretStore { - async fn get_bytes(&self, _name: &str) -> Result, SecretError> { - Ok(None) - } - } - #[test] fn insert_store_handles_adds_present_handles() { let mut request = request_builder() @@ -86,7 +77,7 @@ mod tests { .body(Body::empty()) .expect("request"); let kv_handle = KvHandle::new(Arc::new(DummyKvStore)); - let secret_handle = SecretHandle::new(Arc::new(DummySecretStore)); + let secret_handle = SecretHandle::new(Arc::new(NoopSecretStore)); insert_store_handles( &mut request, diff --git a/crates/edgezero-adapter-fastly/tests/contract.rs b/crates/edgezero-adapter-fastly/tests/contract.rs index 5029a6d..c8e66d8 100644 --- a/crates/edgezero-adapter-fastly/tests/contract.rs +++ b/crates/edgezero-adapter-fastly/tests/contract.rs @@ -142,18 +142,13 @@ fn dispatch_passes_request_body_to_handlers() { assert_eq!(response.take_body_bytes(), b"echo"); } -// Secret store contract tests for Fastly require a running Fastly Compute -// environment and cannot be executed in CI. The FastlySecretStore type is -// verified at compile time here. #[cfg(all(feature = "fastly", target_arch = "wasm32"))] mod secret_store_compile_check { use edgezero_adapter_fastly::FastlySecretStore; use edgezero_core::secret_store::SecretStore; - // Compile-time check: FastlySecretStore implements SecretStore - fn _assert_impl() {} + fn _assert_provider_impl() {} fn _check() { - // This function is never called; it only verifies trait impl at compile time. - _assert_impl::(); + _assert_provider_impl::(); } } diff --git a/crates/edgezero-core/src/extractor.rs b/crates/edgezero-core/src/extractor.rs index 38a715c..8627dcd 100644 --- a/crates/edgezero-core/src/extractor.rs +++ b/crates/edgezero-core/src/extractor.rs @@ -456,7 +456,7 @@ impl Kv { /// ```ignore /// #[action] /// pub async fn handler(Secrets(secrets): Secrets) -> Result { -/// let key = secrets.require_str("API_KEY").await.map_err(EdgeError::from)?; +/// let key = secrets.require_str("api-keys", "API_KEY").await.map_err(EdgeError::from)?; /// // use key ... /// } /// ``` diff --git a/crates/edgezero-core/src/lib.rs b/crates/edgezero-core/src/lib.rs index 803100e..b4ab159 100644 --- a/crates/edgezero-core/src/lib.rs +++ b/crates/edgezero-core/src/lib.rs @@ -20,4 +20,6 @@ pub mod secret_store; pub use edgezero_macros::{action, app}; pub use key_value_store::{KvError, KvHandle, KvPage, KvStore}; +#[cfg(any(test, feature = "test-utils"))] +pub use secret_store::{InMemorySecretStore, NoopSecretStore}; pub use secret_store::{SecretError, SecretHandle, SecretStore}; diff --git a/crates/edgezero-core/src/secret_store.rs b/crates/edgezero-core/src/secret_store.rs index 6627a89..5ecd699 100644 --- a/crates/edgezero-core/src/secret_store.rs +++ b/crates/edgezero-core/src/secret_store.rs @@ -3,15 +3,15 @@ //! # Architecture //! //! ```text -//! Handler code SecretHandle (get_str / require_str) +//! Handler code SecretHandle (get_bytes / require_bytes / require_str) //! │ │ //! └── Secrets extractor ─►│ UTF-8 / bytes layer //! │ -//! Arc (object-safe, Bytes) +//! Arc //! │ //! ┌──────────────┼──────────────┐ //! ▼ ▼ ▼ -//! EnvSecretStore FastlySecretStore CloudflareSecretStore +//! EnvSecretStore FastlySecretStore CloudflareSecretStore //! ``` //! //! Secrets are read-only — this API only retrieves values, @@ -68,56 +68,54 @@ impl From for EdgeError { } // --------------------------------------------------------------------------- -// Trait +// Maximum name length // --------------------------------------------------------------------------- -/// Object-safe interface for secret store backends. -/// -/// All methods take `&self` — backends handle their own access model. +/// Maximum length in bytes for any secret name or store name. +pub const MAX_NAME_LEN: usize = 512; + +// --------------------------------------------------------------------------- +// Multi-store provider trait +// --------------------------------------------------------------------------- + +/// Access secrets across multiple named stores. /// -/// This trait is always called through [`SecretHandle`], which validates -/// inputs before delegating here. Implementations may therefore assume: -/// - Names are non-empty and within [`SecretHandle::MAX_NAME_LEN`] -/// - Names contain no control characters +/// Platforms with a single flat namespace (env vars, in-memory test stores) +/// implement this by keying on `"{store_name}/{key}"`. +/// Platforms with named stores (Fastly, Spin) open a store-specific handle +/// per `store_name`. #[async_trait(?Send)] pub trait SecretStore: Send + Sync { - /// Retrieve a secret as raw bytes. Returns `Ok(None)` if not found. - async fn get_bytes(&self, name: &str) -> Result, SecretError>; + /// Retrieve a secret from a named store. Returns `Ok(None)` if not found. + async fn get_bytes(&self, store_name: &str, key: &str) -> Result, SecretError>; } // --------------------------------------------------------------------------- -// Test-only no-op store +// No-op provider (test-utils) // --------------------------------------------------------------------------- -/// A no-op [`SecretStore`] for tests that only need a [`SecretHandle`] to exist. +/// A no-op [`SecretStore`] for tests that don't need secrets. /// /// All reads return `None`. -/// -/// Available in `#[cfg(test)]` builds and via the `test-utils` feature: -/// ```toml -/// [dev-dependencies] -/// edgezero-core = { path = "...", features = ["test-utils"] } -/// ``` #[cfg(any(test, feature = "test-utils"))] pub struct NoopSecretStore; #[cfg(any(test, feature = "test-utils"))] #[async_trait(?Send)] impl SecretStore for NoopSecretStore { - async fn get_bytes(&self, _name: &str) -> Result, SecretError> { + async fn get_bytes(&self, _store_name: &str, _key: &str) -> Result, SecretError> { Ok(None) } } // --------------------------------------------------------------------------- -// In-memory store (test-utils) +// In-memory provider (test-utils) // --------------------------------------------------------------------------- -/// An in-memory [`SecretStore`] pre-populated with known secrets. -/// -/// Useful for contract tests and unit tests that need deterministic secret values. +/// An in-memory [`SecretStore`] keyed by `"{store_name}/{key}"`. /// -/// Available in `#[cfg(test)]` builds and via the `test-utils` feature. +/// Useful for contract tests and unit tests that need deterministic values +/// across multiple named stores. #[cfg(any(test, feature = "test-utils"))] pub struct InMemorySecretStore { secrets: std::collections::HashMap, @@ -125,6 +123,7 @@ pub struct InMemorySecretStore { #[cfg(any(test, feature = "test-utils"))] impl InMemorySecretStore { + /// Build with entries of the form `("{store_name}/{key}", value)`. pub fn new(entries: impl IntoIterator, impl Into)>) -> Self { Self { secrets: entries @@ -138,22 +137,22 @@ impl InMemorySecretStore { #[cfg(any(test, feature = "test-utils"))] #[async_trait(?Send)] impl SecretStore for InMemorySecretStore { - async fn get_bytes(&self, name: &str) -> Result, SecretError> { - Ok(self.secrets.get(name).cloned()) + async fn get_bytes(&self, store_name: &str, key: &str) -> Result, SecretError> { + let compound = format!("{store_name}/{key}"); + Ok(self.secrets.get(&compound).cloned()) } } // --------------------------------------------------------------------------- -// Handle +// Provider handle // --------------------------------------------------------------------------- -/// A cloneable, ergonomic handle to a secret store. +/// A cloneable, ergonomic handle to a multi-store [`SecretStore`]. /// -/// Provides typed helpers (`get_str`, `require_bytes`, `require_str`) -/// while delegating to the object-safe `SecretStore` trait underneath. +/// Validates both `store_name` and `key` before delegating to the provider. #[derive(Clone)] pub struct SecretHandle { - store: Arc, + provider: Arc, } impl fmt::Debug for SecretHandle { @@ -163,81 +162,72 @@ impl fmt::Debug for SecretHandle { } impl SecretHandle { - /// Maximum secret name length in bytes. - pub const MAX_NAME_LEN: usize = 512; - - /// Create a new handle wrapping a secret store implementation. - pub fn new(store: Arc) -> Self { - Self { store } + /// Create a new handle wrapping a multi-store provider. + pub fn new(provider: Arc) -> Self { + Self { provider } } - fn validate_name(name: &str) -> Result<(), SecretError> { - if name.is_empty() { - return Err(SecretError::Validation( - "secret name cannot be empty".to_string(), - )); - } - if name.len() > Self::MAX_NAME_LEN { - return Err(SecretError::Validation(format!( - "secret name length {} exceeds limit of {} bytes", - name.len(), - Self::MAX_NAME_LEN - ))); - } - if name.chars().any(|c| c.is_control()) { - return Err(SecretError::Validation( - "secret name contains invalid control characters".to_string(), - )); - } - Ok(()) - } - - /// Retrieve a secret as raw bytes. Returns `Ok(None)` if not found. - pub async fn get_bytes(&self, name: &str) -> Result, SecretError> { - Self::validate_name(name)?; - self.store.get_bytes(name).await - } - - /// Retrieve a secret as a UTF-8 string. Returns `Ok(None)` if not found. - pub async fn get_str(&self, name: &str) -> Result, SecretError> { - let bytes = self.get_bytes(name).await?; - bytes - .map(|b| { - String::from_utf8(b.into()).map_err(|e| { - SecretError::Internal(anyhow::anyhow!( - "secret '{}' is not valid UTF-8: {e}", - name - )) - }) - }) - .transpose() + /// Retrieve a secret from a named store. Returns `Ok(None)` if not found. + pub async fn get_bytes( + &self, + store_name: &str, + key: &str, + ) -> Result, SecretError> { + validate_name(store_name)?; + validate_name(key)?; + self.provider.get_bytes(store_name, key).await } /// Retrieve a secret as raw bytes. Returns `SecretError::NotFound` if absent. - pub async fn require_bytes(&self, name: &str) -> Result { - self.get_bytes(name) + pub async fn require_bytes(&self, store_name: &str, key: &str) -> Result { + self.get_bytes(store_name, key) .await? .ok_or_else(|| SecretError::NotFound { - name: name.to_string(), + name: format!("{store_name}/{key}"), }) } /// Retrieve a secret as a UTF-8 string. Returns `SecretError::NotFound` if absent. - pub async fn require_str(&self, name: &str) -> Result { - let bytes = self.require_bytes(name).await?; - String::from_utf8(bytes.into()).map_err(|e| { - SecretError::Internal(anyhow::anyhow!("secret '{}' is not valid UTF-8: {e}", name)) - }) + pub async fn require_str(&self, store_name: &str, key: &str) -> Result { + let bytes = self.require_bytes(store_name, key).await?; + String::from_utf8(bytes.into()) + .map_err(|e| SecretError::Internal(anyhow::anyhow!("secret is not valid UTF-8: {e}"))) } } +// --------------------------------------------------------------------------- +// Shared validation +// --------------------------------------------------------------------------- + +pub(crate) fn validate_name(name: &str) -> Result<(), SecretError> { + if name.is_empty() { + return Err(SecretError::Validation( + "secret name cannot be empty".to_string(), + )); + } + if name.len() > MAX_NAME_LEN { + return Err(SecretError::Validation(format!( + "secret name length {} exceeds limit of {} bytes", + name.len(), + MAX_NAME_LEN + ))); + } + if name.chars().any(|c| c.is_control()) { + return Err(SecretError::Validation( + "secret name contains invalid control characters".to_string(), + )); + } + Ok(()) +} + // --------------------------------------------------------------------------- // Contract test macro // --------------------------------------------------------------------------- /// Generate a suite of contract tests for any [`SecretStore`] implementation. /// -/// The factory expression must produce a store pre-populated with: +/// The factory expression must produce a provider pre-populated with these +/// entries in the `"mystore"` store: /// - `"contract_key"` → `Bytes::from("contract_value")` /// - `"contract_key_2"` → `Bytes::from("another_value")` /// - `"missing_key"` must NOT be present. @@ -255,27 +245,42 @@ macro_rules! secret_store_contract_tests { #[test] fn contract_get_existing_returns_bytes() { - let store = $factory; + let provider = $factory; run(async { - let result = store.get_bytes("contract_key").await.unwrap(); + let result = provider.get_bytes("mystore", "contract_key").await.unwrap(); assert_eq!(result, Some(Bytes::from("contract_value"))); }); } #[test] fn contract_get_second_key_returns_bytes() { - let store = $factory; + let provider = $factory; run(async { - let result = store.get_bytes("contract_key_2").await.unwrap(); + let result = provider + .get_bytes("mystore", "contract_key_2") + .await + .unwrap(); assert_eq!(result, Some(Bytes::from("another_value"))); }); } #[test] fn contract_get_missing_returns_none() { - let store = $factory; + let provider = $factory; run(async { - let result = store.get_bytes("missing_key").await.unwrap(); + let result = provider.get_bytes("mystore", "missing_key").await.unwrap(); + assert!(result.is_none()); + }); + } + + #[test] + fn contract_wrong_store_returns_none() { + let provider = $factory; + run(async { + let result = provider + .get_bytes("other_store", "contract_key") + .await + .unwrap(); assert!(result.is_none()); }); } @@ -294,122 +299,138 @@ mod tests { use bytes::Bytes; use futures::executor::block_on; - // Test-only in-memory store - use std::collections::HashMap; - struct SimpleStore(HashMap); - #[async_trait(?Send)] - impl SecretStore for SimpleStore { - async fn get_bytes(&self, name: &str) -> Result, SecretError> { - Ok(self.0.get(name).cloned()) - } + // ----------------------------------------------------------------------- + // SecretStoreProvider tests + // ----------------------------------------------------------------------- + + #[test] + fn provider_in_memory_returns_value_for_existing_key() { + let provider = InMemorySecretStore::new([("store/key", Bytes::from("hello"))]); + block_on(async { + let result = provider.get_bytes("store", "key").await.unwrap(); + assert_eq!(result, Some(Bytes::from("hello"))); + }); } - fn store_with(entries: &[(&str, &str)]) -> SecretHandle { - let map: HashMap = entries - .iter() - .map(|(k, v)| (k.to_string(), Bytes::from(v.to_string()))) - .collect(); - SecretHandle::new(std::sync::Arc::new(SimpleStore(map))) + #[test] + fn provider_in_memory_returns_none_for_missing_key() { + let provider = InMemorySecretStore::new([("store/key", Bytes::from("hello"))]); + block_on(async { + let result = provider.get_bytes("store", "missing").await.unwrap(); + assert!(result.is_none()); + }); } - fn store_with_bytes(entries: &[(&str, &[u8])]) -> SecretHandle { - let map: HashMap = entries - .iter() - .map(|(k, v)| (k.to_string(), Bytes::copy_from_slice(v))) - .collect(); - SecretHandle::new(std::sync::Arc::new(SimpleStore(map))) + #[test] + fn provider_in_memory_returns_none_for_wrong_store() { + let provider = InMemorySecretStore::new([("store/key", Bytes::from("hello"))]); + block_on(async { + let result = provider.get_bytes("other", "key").await.unwrap(); + assert!(result.is_none()); + }); } #[test] - fn validate_name_rejects_empty() { + fn noop_provider_always_returns_none() { + let provider = NoopSecretStore; block_on(async { - let h = store_with(&[]); - let err = h.get_bytes("").await.unwrap_err(); - assert!(matches!(err, SecretError::Validation(_))); + let result = provider.get_bytes("any_store", "any_key").await.unwrap(); + assert!(result.is_none()); }); } + // ----------------------------------------------------------------------- + // SecretProviderHandle tests + // ----------------------------------------------------------------------- + + fn provider_handle_with(entries: &[(&str, &str)]) -> SecretHandle { + let provider = InMemorySecretStore::new( + entries + .iter() + .map(|(k, v)| (k.to_string(), Bytes::from(v.to_string()))), + ); + SecretHandle::new(std::sync::Arc::new(provider)) + } + #[test] - fn validate_name_rejects_control_chars() { + fn provider_handle_get_bytes_returns_value() { + let h = provider_handle_with(&[("signing-keys/current", "abc123")]); block_on(async { - let h = store_with(&[]); - let err = h.get_bytes("bad\x00name").await.unwrap_err(); - assert!(matches!(err, SecretError::Validation(_))); + let result = h.get_bytes("signing-keys", "current").await.unwrap(); + assert_eq!(result, Some(Bytes::from("abc123"))); }); } #[test] - fn validate_name_rejects_oversized() { + fn provider_handle_get_bytes_returns_none_for_missing() { + let h = provider_handle_with(&[]); block_on(async { - let h = store_with(&[]); - let name = "x".repeat(SecretHandle::MAX_NAME_LEN + 1); - let err = h.get_bytes(&name).await.unwrap_err(); - assert!(matches!(err, SecretError::Validation(_))); + let result = h.get_bytes("store", "missing").await.unwrap(); + assert!(result.is_none()); }); } #[test] - fn get_bytes_returns_none_for_missing() { + fn provider_handle_require_bytes_errors_for_missing() { + let h = provider_handle_with(&[]); block_on(async { - let h = store_with(&[]); - assert_eq!(h.get_bytes("missing").await.unwrap(), None); + let err = h.require_bytes("store", "missing").await.unwrap_err(); + assert!(matches!(err, SecretError::NotFound { .. })); }); } #[test] - fn get_bytes_returns_value_for_existing() { + fn provider_handle_require_str_returns_value() { + let h = provider_handle_with(&[("api-keys/prod", "secret_val")]); block_on(async { - let h = store_with(&[("api_key", "secret123")]); - assert_eq!( - h.get_bytes("api_key").await.unwrap(), - Some(Bytes::from("secret123")) - ); + let val = h.require_str("api-keys", "prod").await.unwrap(); + assert_eq!(val, "secret_val"); }); } #[test] - fn get_str_decodes_utf8() { + fn provider_handle_validates_empty_store_name() { + let h = provider_handle_with(&[]); block_on(async { - let h = store_with(&[("token", "bearer xyz")]); - assert_eq!( - h.get_str("token").await.unwrap(), - Some("bearer xyz".to_string()) - ); + let err = h.get_bytes("", "key").await.unwrap_err(); + assert!(matches!(err, SecretError::Validation(_))); }); } #[test] - fn require_bytes_fails_for_missing() { + fn provider_handle_validates_empty_key() { + let h = provider_handle_with(&[]); block_on(async { - let h = store_with(&[]); - let err = h.require_bytes("missing").await.unwrap_err(); - assert!(matches!(err, SecretError::NotFound { .. })); + let err = h.get_bytes("store", "").await.unwrap_err(); + assert!(matches!(err, SecretError::Validation(_))); }); } #[test] - fn require_str_returns_value() { + fn provider_handle_validates_control_chars_in_store_name() { + let h = provider_handle_with(&[]); block_on(async { - let h = store_with(&[("key", "value")]); - assert_eq!(h.require_str("key").await.unwrap(), "value"); + let err = h.get_bytes("bad\x00store", "key").await.unwrap_err(); + assert!(matches!(err, SecretError::Validation(_))); }); } #[test] - fn get_str_rejects_invalid_utf8() { + fn provider_handle_validates_control_chars_in_key() { + let h = provider_handle_with(&[]); block_on(async { - let h = store_with_bytes(&[("binary", &[0xff])]); - let err = h.get_str("binary").await.unwrap_err(); - assert!(matches!(err, SecretError::Internal(_))); + let err = h.get_bytes("store", "bad\x00key").await.unwrap_err(); + assert!(matches!(err, SecretError::Validation(_))); }); } #[test] - fn require_str_rejects_invalid_utf8() { + fn provider_handle_validates_oversized_name() { + let h = provider_handle_with(&[]); block_on(async { - let h = store_with_bytes(&[("binary", &[0xff])]); - let err = h.require_str("binary").await.unwrap_err(); - assert!(matches!(err, SecretError::Internal(_))); + let name = "x".repeat(MAX_NAME_LEN + 1); + let err = h.get_bytes(&name, "key").await.unwrap_err(); + assert!(matches!(err, SecretError::Validation(_))); }); } @@ -430,10 +451,10 @@ mod tests { assert!(!err.message().contains("bad")); } - secret_store_contract_tests!(in_memory_contract, { + secret_store_contract_tests!(in_memory_provider_contract, { InMemorySecretStore::new([ - ("contract_key", Bytes::from("contract_value")), - ("contract_key_2", Bytes::from("another_value")), + ("mystore/contract_key", Bytes::from("contract_value")), + ("mystore/contract_key_2", Bytes::from("another_value")), ]) }); } 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 22d310a..b7bb433 100644 --- a/examples/app-demo/crates/app-demo-core/src/handlers.rs +++ b/examples/app-demo/crates/app-demo-core/src/handlers.rs @@ -12,6 +12,7 @@ use futures::{stream, StreamExt}; const DEFAULT_PROXY_BASE: &str = "https://httpbin.org"; const SMOKE_SECRET_NAME: &str = "SMOKE_SECRET"; const SMOKE_SECRET_MISSING_NAME: &str = "SMOKE_SECRET_MISSING"; +const SECRET_STORE_NAME: &str = "EDGEZERO_SECRETS"; #[derive(serde::Deserialize)] pub(crate) struct EchoParams { @@ -218,7 +219,7 @@ pub(crate) async fn secrets_echo( } let value = store - .require_str(¶ms.name) + .require_str(SECRET_STORE_NAME, ¶ms.name) .await .map_err(EdgeError::from)?; Ok(Text::new(value)) @@ -555,12 +556,13 @@ mod tests { use edgezero_core::secret_store::{InMemorySecretStore, SecretHandle}; fn context_with_secrets(path: &str, query: &str, entries: &[(&str, &str)]) -> RequestContext { - let store = InMemorySecretStore::new( - entries - .iter() - .map(|(k, v)| (*k, bytes::Bytes::from(v.to_string()))), - ); - let handle = SecretHandle::new(std::sync::Arc::new(store)); + let provider = InMemorySecretStore::new(entries.iter().map(|(k, v)| { + ( + format!("{SECRET_STORE_NAME}/{k}"), + bytes::Bytes::from(v.to_string()), + ) + })); + let handle = SecretHandle::new(std::sync::Arc::new(provider)); let uri = format!("{}?{}", path, query); let mut request = request_builder() .method(Method::GET) From 44f39b36f674a0aff311dfbbbecc749692b5c6c5 Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Fri, 27 Mar 2026 14:43:10 +0530 Subject: [PATCH 18/18] Address PR review findings on secret-store branch - Consolidate dispatch: both adapters now resolve handles in run_app and call dispatch_with_handles directly; promotes resolve_kv_handle, resolve_secret_handle, dispatch_with_handles to pub(crate) - Remove dead _secret_binding param from Cloudflare dispatch_with_kv_and_secrets - Replace DummyKvStore with NoopKvStore in both adapter store_handles tests - Extract shared EnvOverride + env_guard into axum test_utils module - Export NoopKvStore under test-utils feature to match secret-store pattern - Add doc comments to RequestContext::kv_handle and secret_handle - Add GHAS safety comment to Secrets::from_request in extractor.rs - Document breaking-change on run_app for both Fastly and Cloudflare adapters --- .../edgezero-adapter-axum/src/dev_server.rs | 10 +++-- crates/edgezero-adapter-axum/src/lib.rs | 3 ++ .../edgezero-adapter-axum/src/secret_store.rs | 37 +-------------- crates/edgezero-adapter-axum/src/service.rs | 31 +------------ .../edgezero-adapter-axum/src/test_utils.rs | 43 ++++++++++++++++++ crates/edgezero-adapter-cloudflare/src/lib.rs | 26 ++++------- .../src/request.rs | 9 ++-- .../src/store_handles.rs | 45 +------------------ crates/edgezero-adapter-fastly/src/lib.rs | 13 +++--- crates/edgezero-adapter-fastly/src/request.rs | 6 +-- .../src/store_handles.rs | 45 +------------------ crates/edgezero-core/src/context.rs | 2 + crates/edgezero-core/src/extractor.rs | 3 ++ crates/edgezero-core/src/lib.rs | 2 + 14 files changed, 88 insertions(+), 187 deletions(-) create mode 100644 crates/edgezero-adapter-axum/src/test_utils.rs diff --git a/crates/edgezero-adapter-axum/src/dev_server.rs b/crates/edgezero-adapter-axum/src/dev_server.rs index 343da7c..788398b 100644 --- a/crates/edgezero-adapter-axum/src/dev_server.rs +++ b/crates/edgezero-adapter-axum/src/dev_server.rs @@ -180,10 +180,12 @@ async fn serve_with_listener( listener: tokio::net::TcpListener, enable_ctrl_c: bool, ) -> anyhow::Result<()> { - // No KV store is attached here — this path is used by `AxumDevServer::run()` - // which is the manifest-unaware embedding API. Callers that need KV should - // use `run_app()` (manifest-driven) or attach a `KvHandle` directly via - // `EdgeZeroAxumService::with_kv_handle`. + // No KV store or secret store is attached here — this path is used by + // `AxumDevServer::run()`, which is the manifest-unaware embedding API. + // Callers that need KV should use `run_app()` (manifest-driven) or attach + // a `KvHandle` directly via `EdgeZeroAxumService::with_kv_handle`. + // Callers that need secrets should use `run_app()` or attach a + // `SecretHandle` directly via `EdgeZeroAxumService::with_secret_handle`. serve_with_listener_and_kv_path(router, listener, enable_ctrl_c, None).await } diff --git a/crates/edgezero-adapter-axum/src/lib.rs b/crates/edgezero-adapter-axum/src/lib.rs index 0d97448..c94ca65 100644 --- a/crates/edgezero-adapter-axum/src/lib.rs +++ b/crates/edgezero-adapter-axum/src/lib.rs @@ -20,6 +20,9 @@ mod service; #[cfg(feature = "cli")] pub mod cli; +#[cfg(test)] +pub mod test_utils; + #[cfg(feature = "axum")] pub use context::AxumRequestContext; #[cfg(feature = "axum")] diff --git a/crates/edgezero-adapter-axum/src/secret_store.rs b/crates/edgezero-adapter-axum/src/secret_store.rs index 214f853..1d216c8 100644 --- a/crates/edgezero-adapter-axum/src/secret_store.rs +++ b/crates/edgezero-adapter-axum/src/secret_store.rs @@ -58,43 +58,10 @@ impl SecretStore for EnvSecretStore { #[cfg(test)] mod tests { use super::*; + use crate::test_utils::{env_guard, EnvOverride}; use bytes::Bytes; + #[cfg(unix)] use std::ffi::OsString; - use std::sync::OnceLock; - - fn env_guard() -> &'static tokio::sync::Mutex<()> { - static GUARD: OnceLock> = OnceLock::new(); - GUARD.get_or_init(|| tokio::sync::Mutex::new(())) - } - - struct EnvOverride { - key: &'static str, - original: Option, - } - - impl EnvOverride { - fn set(key: &'static str, value: impl AsRef) -> Self { - let original = std::env::var_os(key); - std::env::set_var(key, value); - Self { key, original } - } - - fn clear(key: &'static str) -> Self { - let original = std::env::var_os(key); - std::env::remove_var(key); - Self { key, original } - } - } - - impl Drop for EnvOverride { - fn drop(&mut self) { - if let Some(ref original) = self.original { - std::env::set_var(self.key, original); - } else { - std::env::remove_var(self.key); - } - } - } #[tokio::test(flavor = "current_thread")] async fn get_bytes_returns_none_when_var_not_set() { diff --git a/crates/edgezero-adapter-axum/src/service.rs b/crates/edgezero-adapter-axum/src/service.rs index d5c69c7..a0099a9 100644 --- a/crates/edgezero-adapter-axum/src/service.rs +++ b/crates/edgezero-adapter-axum/src/service.rs @@ -98,42 +98,13 @@ impl Service> for EdgeZeroAxumService { #[cfg(test)] mod tests { use super::*; + use crate::test_utils::{env_guard, EnvOverride}; use edgezero_core::body::Body; use edgezero_core::context::RequestContext; use edgezero_core::error::EdgeError; use edgezero_core::http::{response_builder, StatusCode}; - use std::ffi::OsString; - use std::sync::OnceLock; use tower::ServiceExt; - fn env_guard() -> &'static tokio::sync::Mutex<()> { - static GUARD: OnceLock> = OnceLock::new(); - GUARD.get_or_init(|| tokio::sync::Mutex::new(())) - } - - struct EnvOverride { - key: &'static str, - original: Option, - } - - impl EnvOverride { - fn set(key: &'static str, value: impl AsRef) -> Self { - let original = std::env::var_os(key); - std::env::set_var(key, value); - Self { key, original } - } - } - - impl Drop for EnvOverride { - fn drop(&mut self) { - if let Some(ref original) = self.original { - std::env::set_var(self.key, original); - } else { - std::env::remove_var(self.key); - } - } - } - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn forwards_request_to_router() { let router = RouterService::builder() diff --git a/crates/edgezero-adapter-axum/src/test_utils.rs b/crates/edgezero-adapter-axum/src/test_utils.rs new file mode 100644 index 0000000..ce4e39d --- /dev/null +++ b/crates/edgezero-adapter-axum/src/test_utils.rs @@ -0,0 +1,43 @@ +use std::ffi::OsString; +use std::sync::OnceLock; +use tokio::sync::Mutex; + +/// Returns a process-wide mutex used to serialize tests that mutate environment variables. +/// +/// Both `secret_store` and `service` tests share this lock to avoid data races across +/// test threads when setting or clearing environment variables. +pub fn env_guard() -> &'static Mutex<()> { + static GUARD: OnceLock> = OnceLock::new(); + GUARD.get_or_init(|| Mutex::new(())) +} + +/// RAII guard that sets an environment variable for the duration of a test and +/// restores the original value (or removes the variable) on drop. +pub struct EnvOverride { + key: &'static str, + original: Option, +} + +impl EnvOverride { + pub fn set(key: &'static str, value: impl AsRef) -> Self { + let original = std::env::var_os(key); + std::env::set_var(key, value); + Self { key, original } + } + + pub fn clear(key: &'static str) -> Self { + let original = std::env::var_os(key); + std::env::remove_var(key); + Self { key, original } + } +} + +impl Drop for EnvOverride { + fn drop(&mut self) { + if let Some(ref original) = self.original { + std::env::set_var(self.key, original); + } else { + std::env::remove_var(self.key); + } + } +} diff --git a/crates/edgezero-adapter-cloudflare/src/lib.rs b/crates/edgezero-adapter-cloudflare/src/lib.rs index 4de08c7..0752d38 100644 --- a/crates/edgezero-adapter-cloudflare/src/lib.rs +++ b/crates/edgezero-adapter-cloudflare/src/lib.rs @@ -65,6 +65,11 @@ impl AppExt for edgezero_core::app::App { } } +/// Entry point for a Cloudflare Workers application. +/// +/// **Breaking change (pre-1.0):** `manifest_src` is now a required parameter. +/// Callers previously using `run_app_with_manifest` can rename to `run_app` — +/// the signatures are identical. #[cfg(all(feature = "cloudflare", target_arch = "wasm32"))] pub async fn run_app( manifest_src: &str, @@ -77,26 +82,11 @@ pub async fn run_app( let manifest = manifest_loader.manifest(); let kv_binding = manifest.kv_store_name("cloudflare"); let kv_required = manifest.stores.kv.is_some(); - let secret_binding = manifest.secret_store_name("cloudflare"); let secrets_required = manifest.secret_store_enabled("cloudflare"); let app = A::build_app(); - if secrets_required && kv_required { - dispatch_with_kv_and_secrets( - &app, - req, - env, - ctx, - kv_binding, - kv_required, - secret_binding, - secrets_required, - ) - .await - } else if secrets_required { - dispatch_with_secrets(&app, req, env, ctx, secrets_required).await - } else { - dispatch_with_kv(&app, req, env, ctx, kv_binding, kv_required).await - } + let kv_handle = crate::request::resolve_kv_handle(&env, kv_binding, kv_required)?; + let secret_handle = crate::request::resolve_secret_handle(&env, secrets_required); + crate::request::dispatch_with_handles(&app, req, env, ctx, kv_handle, secret_handle).await } /// Deprecated: use [`run_app`] which now takes `manifest_src` directly. diff --git a/crates/edgezero-adapter-cloudflare/src/request.rs b/crates/edgezero-adapter-cloudflare/src/request.rs index 7a38c83..463fa28 100644 --- a/crates/edgezero-adapter-cloudflare/src/request.rs +++ b/crates/edgezero-adapter-cloudflare/src/request.rs @@ -102,6 +102,8 @@ pub async fn dispatch_with_secrets( } /// Dispatch a Cloudflare Worker request with both KV and secret stores attached. +/// +/// Note: Cloudflare secrets have no namespace concept, so no secret binding name is needed. pub async fn dispatch_with_kv_and_secrets( app: &App, req: CfRequest, @@ -109,7 +111,6 @@ pub async fn dispatch_with_kv_and_secrets( ctx: Context, kv_binding: &str, kv_required: bool, - _secret_binding: &str, // unused: CF secrets have no namespace concept secrets_required: bool, ) -> Result { let kv_handle = resolve_kv_handle(&env, kv_binding, kv_required)?; @@ -117,7 +118,7 @@ pub async fn dispatch_with_kv_and_secrets( dispatch_with_handles(app, req, env, ctx, kv_handle, secret_handle).await } -async fn dispatch_with_handles( +pub(crate) async fn dispatch_with_handles( app: &App, req: CfRequest, env: Env, @@ -143,7 +144,7 @@ async fn dispatch_core_request( from_core_response(response).map_err(edge_error_to_worker) } -fn resolve_kv_handle( +pub(crate) fn resolve_kv_handle( env: &Env, kv_binding: &str, kv_required: bool, @@ -163,7 +164,7 @@ fn resolve_kv_handle( } } -fn resolve_secret_handle(env: &Env, secrets_required: bool) -> Option { +pub(crate) fn resolve_secret_handle(env: &Env, secrets_required: bool) -> Option { if !secrets_required { return None; } diff --git a/crates/edgezero-adapter-cloudflare/src/store_handles.rs b/crates/edgezero-adapter-cloudflare/src/store_handles.rs index 5e50509..7a98af8 100644 --- a/crates/edgezero-adapter-cloudflare/src/store_handles.rs +++ b/crates/edgezero-adapter-cloudflare/src/store_handles.rs @@ -23,52 +23,11 @@ pub(crate) fn insert_store_handles( #[cfg(test)] mod tests { use super::*; - use async_trait::async_trait; - use bytes::Bytes; use edgezero_core::body::Body; use edgezero_core::http::request_builder; - use edgezero_core::key_value_store::{KvError, KvPage, KvStore}; + use edgezero_core::key_value_store::NoopKvStore; use edgezero_core::secret_store::{NoopSecretStore, SecretHandle}; use std::sync::Arc; - use std::time::Duration; - - struct DummyKvStore; - - #[async_trait(?Send)] - impl KvStore for DummyKvStore { - async fn get_bytes(&self, _key: &str) -> Result, KvError> { - Ok(None) - } - - async fn put_bytes(&self, _key: &str, _value: Bytes) -> Result<(), KvError> { - Ok(()) - } - - async fn put_bytes_with_ttl( - &self, - _key: &str, - _value: Bytes, - _ttl: Duration, - ) -> Result<(), KvError> { - Ok(()) - } - - async fn delete(&self, _key: &str) -> Result<(), KvError> { - Ok(()) - } - - async fn list_keys_page( - &self, - _prefix: &str, - _cursor: Option<&str>, - _limit: usize, - ) -> Result { - Ok(KvPage { - keys: Vec::new(), - cursor: None, - }) - } - } #[test] fn insert_store_handles_adds_present_handles() { @@ -76,7 +35,7 @@ mod tests { .uri("https://example.com") .body(Body::empty()) .expect("request"); - let kv_handle = KvHandle::new(Arc::new(DummyKvStore)); + let kv_handle = KvHandle::new(Arc::new(NoopKvStore)); let secret_handle = SecretHandle::new(Arc::new(NoopSecretStore)); insert_store_handles( diff --git a/crates/edgezero-adapter-fastly/src/lib.rs b/crates/edgezero-adapter-fastly/src/lib.rs index e315207..167937f 100644 --- a/crates/edgezero-adapter-fastly/src/lib.rs +++ b/crates/edgezero-adapter-fastly/src/lib.rs @@ -82,6 +82,9 @@ impl AppExt for edgezero_core::app::App { } } +/// Entry point for a Fastly Compute application. +/// +/// **Breaking change (pre-1.0):** `manifest_src` is now a required parameter. #[cfg(feature = "fastly")] pub fn run_app( manifest_src: &str, @@ -110,13 +113,9 @@ pub(crate) fn run_app_with_logging( } let app = A::build_app(); - if secrets_required && kv_required { - dispatch_with_kv_and_secrets(&app, req, kv_store_name, kv_required, secrets_required) - } else if secrets_required { - dispatch_with_secrets(&app, req, secrets_required) - } else { - dispatch_with_kv(&app, req, kv_store_name, kv_required) - } + let kv_handle = crate::request::resolve_kv_handle(kv_store_name, kv_required)?; + let secret_handle = crate::request::resolve_secret_handle(secrets_required); + crate::request::dispatch_with_handles(&app, req, kv_handle, secret_handle) } #[cfg(all(test, feature = "fastly"))] diff --git a/crates/edgezero-adapter-fastly/src/request.rs b/crates/edgezero-adapter-fastly/src/request.rs index d76df02..82969e4 100644 --- a/crates/edgezero-adapter-fastly/src/request.rs +++ b/crates/edgezero-adapter-fastly/src/request.rs @@ -115,7 +115,7 @@ pub fn dispatch_with_kv_and_secrets( dispatch_with_handles(app, req, kv_handle, secret_handle) } -fn dispatch_with_handles( +pub(crate) fn dispatch_with_handles( app: &App, req: FastlyRequest, kv_handle: Option, @@ -136,7 +136,7 @@ fn dispatch_core_request( from_core_response(response).map_err(map_edge_error) } -fn resolve_kv_handle( +pub(crate) fn resolve_kv_handle( kv_store_name: &str, kv_required: bool, ) -> Result, FastlyError> { @@ -155,7 +155,7 @@ fn resolve_kv_handle( } } -fn resolve_secret_handle(secrets_required: bool) -> Option { +pub(crate) fn resolve_secret_handle(secrets_required: bool) -> Option { if !secrets_required { return None; } diff --git a/crates/edgezero-adapter-fastly/src/store_handles.rs b/crates/edgezero-adapter-fastly/src/store_handles.rs index afdbb1a..3e93c5b 100644 --- a/crates/edgezero-adapter-fastly/src/store_handles.rs +++ b/crates/edgezero-adapter-fastly/src/store_handles.rs @@ -23,52 +23,11 @@ pub(crate) fn insert_store_handles( #[cfg(test)] mod tests { use super::*; - use async_trait::async_trait; - use bytes::Bytes; use edgezero_core::body::Body; use edgezero_core::http::request_builder; - use edgezero_core::key_value_store::{KvError, KvPage, KvStore}; + use edgezero_core::key_value_store::NoopKvStore; use edgezero_core::secret_store::{NoopSecretStore, SecretHandle}; use std::sync::Arc; - use std::time::Duration; - - struct DummyKvStore; - - #[async_trait(?Send)] - impl KvStore for DummyKvStore { - async fn get_bytes(&self, _key: &str) -> Result, KvError> { - Ok(None) - } - - async fn put_bytes(&self, _key: &str, _value: Bytes) -> Result<(), KvError> { - Ok(()) - } - - async fn put_bytes_with_ttl( - &self, - _key: &str, - _value: Bytes, - _ttl: Duration, - ) -> Result<(), KvError> { - Ok(()) - } - - async fn delete(&self, _key: &str) -> Result<(), KvError> { - Ok(()) - } - - async fn list_keys_page( - &self, - _prefix: &str, - _cursor: Option<&str>, - _limit: usize, - ) -> Result { - Ok(KvPage { - keys: Vec::new(), - cursor: None, - }) - } - } #[test] fn insert_store_handles_adds_present_handles() { @@ -76,7 +35,7 @@ mod tests { .uri("https://example.com") .body(Body::empty()) .expect("request"); - let kv_handle = KvHandle::new(Arc::new(DummyKvStore)); + let kv_handle = KvHandle::new(Arc::new(NoopKvStore)); let secret_handle = SecretHandle::new(Arc::new(NoopSecretStore)); insert_store_handles( diff --git a/crates/edgezero-core/src/context.rs b/crates/edgezero-core/src/context.rs index 48494c2..8103dff 100644 --- a/crates/edgezero-core/src/context.rs +++ b/crates/edgezero-core/src/context.rs @@ -86,10 +86,12 @@ impl RequestContext { self.request.extensions().get::().cloned() } + /// Returns the KV store handle if one was configured for this request. pub fn kv_handle(&self) -> Option { self.request.extensions().get::().cloned() } + /// Returns the secret store handle if one was configured for this request. pub fn secret_handle(&self) -> Option { self.request.extensions().get::().cloned() } diff --git a/crates/edgezero-core/src/extractor.rs b/crates/edgezero-core/src/extractor.rs index 8627dcd..0d9e156 100644 --- a/crates/edgezero-core/src/extractor.rs +++ b/crates/edgezero-core/src/extractor.rs @@ -466,6 +466,9 @@ pub struct Secrets(pub crate::secret_store::SecretHandle); #[async_trait(?Send)] impl FromRequest for Secrets { async fn from_request(ctx: &RequestContext) -> Result { + // ctx.secret_handle() returns a handle object, not secret bytes. + // The error message below contains only store configuration info — no secret values + // are included, so this is safe from a cleartext-logging perspective. ctx.secret_handle().map(Secrets).ok_or_else(|| { EdgeError::internal(anyhow::anyhow!( "no secret store configured -- check [stores.secrets] in edgezero.toml and platform bindings" diff --git a/crates/edgezero-core/src/lib.rs b/crates/edgezero-core/src/lib.rs index b4ab159..bb01dca 100644 --- a/crates/edgezero-core/src/lib.rs +++ b/crates/edgezero-core/src/lib.rs @@ -19,6 +19,8 @@ pub mod router; pub mod secret_store; pub use edgezero_macros::{action, app}; +#[cfg(any(test, feature = "test-utils"))] +pub use key_value_store::NoopKvStore; pub use key_value_store::{KvError, KvHandle, KvPage, KvStore}; #[cfg(any(test, feature = "test-utils"))] pub use secret_store::{InMemorySecretStore, NoopSecretStore};