Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
2e9a547
chore: add .worktrees/ to .gitignore
Mar 24, 2026
e3250bb
feat(core): add SecretStore trait, SecretHandle, and contract test macro
Mar 24, 2026
766ed91
refactor(core): use Bytes::into() for zero-copy UTF-8 conversion in S…
Mar 24, 2026
6c8cc08
feat(core): add secret_handle() to RequestContext and Secrets extractor
Mar 24, 2026
6aadb54
feat(core): add [stores.secrets] manifest schema and secret_store_name()
Mar 24, 2026
514f164
feat(fastly): add FastlySecretStore adapter and dispatch_with_secrets
Mar 24, 2026
d79d67e
style: apply rustfmt to Task 4 changes
Mar 24, 2026
75bdc67
feat(cloudflare): add CloudflareSecretStore adapter and dispatch_with…
Mar 24, 2026
ccbfa67
feat(cloudflare): add CloudflareSecretStore adapter and dispatch_with…
Mar 24, 2026
8b705f9
feat(axum): add EnvSecretStore for local dev and wire into service/de…
Mar 24, 2026
000710b
feat(cli): log secret store binding info during edgezero build
Mar 24, 2026
d030304
test: add secret store contract tests across all adapters
Mar 24, 2026
9e7b8de
fix(secret-store): address security findings from review
Mar 24, 2026
aa53635
test(axum): add TCP integration tests for secret store wiring
Mar 24, 2026
a9ac734
feat(demo): add secrets_echo handler and [stores.secrets] manifest co…
Mar 24, 2026
9c6746f
docs(guide): document runtime secret store configuration
Mar 24, 2026
3da648b
refactor(secret-store): collapse two-trait design into single SecretS…
Mar 26, 2026
44f39b3
Address PR review findings on secret-store branch
Mar 27, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,12 @@ target/
# OS
.DS_Store

# Worktrees
.worktrees/

# Superpowers plans
docs/superpowers/

# Editors
.claude/*
!.claude/settings.json
Expand Down
1 change: 1 addition & 0 deletions crates/edgezero-adapter-axum/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"] }
151 changes: 144 additions & 7 deletions crates/edgezero-adapter-axum/src/dev_server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand All @@ -196,19 +198,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<edgezero_core::key_value_store::KvHandle>,
secret_handle: Option<edgezero_core::secret_store::SecretHandle>,
) -> 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| {
Expand Down Expand Up @@ -243,6 +249,7 @@ pub fn run_app<A: Hooks>(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 has_secret_store = manifest.secret_store_enabled("axum");

let level: LevelFilter = logging.level.into();
let level = if logging.echo_stdout.unwrap_or(true) {
Expand Down Expand Up @@ -294,7 +301,22 @@ pub fn run_app<A: Hooks>(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");
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
})
}

Expand Down Expand Up @@ -427,8 +449,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};

Expand Down Expand Up @@ -781,4 +805,117 @@ 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<edgezero_core::secret_store::SecretHandle>,
) -> 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,
}
}

#[action]
async fn secret_value_handler(Secrets(store): Secrets) -> Result<String, EdgeError> {
store
.require_str("test-store", "API_KEY")
.await
.map_err(EdgeError::from)
}

// -----------------------------------------------------------------------
// 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;

let router = RouterService::builder()
.get("/secret", secret_value_handler)
.build();
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;

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;

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;

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
);
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() {
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
);
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();
}
}
7 changes: 7 additions & 0 deletions crates/edgezero-adapter-axum/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,16 @@ mod request;
#[cfg(feature = "axum")]
mod response;
#[cfg(feature = "axum")]
pub mod secret_store;
#[cfg(feature = "axum")]
mod service;

#[cfg(feature = "cli")]
pub mod cli;

#[cfg(test)]
pub mod test_utils;

#[cfg(feature = "axum")]
pub use context::AxumRequestContext;
#[cfg(feature = "axum")]
Expand All @@ -31,4 +36,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;
119 changes: 119 additions & 0 deletions crates/edgezero-adapter-axum/src/secret_store.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
//! Environment variable secret store for local development.
//!
//! Reads secrets from the process environment. 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, _store_name: &str, key: &str) -> Result<Option<Bytes>, SecretError> {
#[cfg(unix)]
{
use std::os::unix::ffi::OsStringExt;

match std::env::var_os(key) {
Some(value) => Ok(Some(Bytes::from(value.into_vec()))),
None => Ok(None),
}
}

#[cfg(not(unix))]
{
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(
anyhow::anyhow!("secret store returned an invalid Unicode value"),
)),
}
}
}
}

#[cfg(test)]
mod tests {
use super::*;
use crate::test_utils::{env_guard, EnvOverride};
use bytes::Bytes;
#[cfg(unix)]
use std::ffi::OsString;

#[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 = store
.get_bytes("env", "__EDGEZERO_TEST_MISSING_VAR_XYZ__")
.await
.unwrap();
assert!(result.is_none());
}

#[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 = store
.get_bytes("env", "__EDGEZERO_TEST_SECRET__")
.await
.unwrap();
assert_eq!(result, Some(Bytes::from("test_value_123")));
}

#[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("env", "__EDGEZERO_TEST_BINARY_SECRET__")
.await
.unwrap();
assert_eq!(result, Some(Bytes::from_static(&[0xff, 0x61])));
}

// 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_contract_tests;

secret_store_contract_tests!(env_secret_contract, {
edgezero_core::InMemorySecretStore::new([
("mystore/contract_key", Bytes::from("contract_value")),
("mystore/contract_key_2", Bytes::from("another_value")),
])
});
}
Loading
Loading