Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
73 commits
Select commit Hold shift + click to select a range
b082c3c
Add design spec for pluggable introspection routes
aram356 Jul 2, 2026
2e04b26
Add implementation plan for introspection routes
aram356 Jul 2, 2026
98d225a
Revise introspection plan per review: enum serialization, imports, co…
aram356 Jul 2, 2026
7301bce
Revise plan (round 2): real body assertions, oneshot config tests, bo…
aram356 Jul 2, 2026
4254ed4
Revise plan (round 3): single-filter test commands, scoped doc greps,…
aram356 Jul 2, 2026
9011684
Plan: stage roadmap.md alongside routing.md in Task 7 commit
aram356 Jul 2, 2026
5ab3546
Make Manifest serializable with secret-value redaction
aram356 Jul 2, 2026
d09c068
Inject IntrospectionData at router dispatch chokepoint
aram356 Jul 2, 2026
beed0a4
Add edgezero_core::introspection handlers (manifest/config/routes)
aram356 Jul 2, 2026
83dd8c4
app! macro: bake manifest JSON into build_router via with_manifest_json
aram356 Jul 2, 2026
5ed4e8c
Use workspace deps for edgezero-macros proc-macro2/quote/syn
aram356 Jul 2, 2026
9b3b820
Sync app-demo Cargo.lock for edgezero-macros serde_json dep
aram356 Jul 2, 2026
2cd9b0c
Remove legacy route-listing machinery and /__edgezero/routes
aram356 Jul 2, 2026
6869389
Wire default introspection triggers into app-demo and generated apps
aram356 Jul 2, 2026
adbc94d
Docs: replace route-listing with introspection routes
aram356 Jul 2, 2026
8999623
Track manifest as build input; strengthen introspection tests; doc ca…
aram356 Jul 2, 2026
56cca34
Spec/plan: introspection access via independent extractors (no gating)
aram356 Jul 2, 2026
0feb194
Expose introspection via ManifestJson/RouteTable extractors
aram356 Jul 2, 2026
c9c6f34
Spec/plan: per-route gated introspection injection (macro auto-flags,…
aram356 Jul 2, 2026
692dd17
Spec/plan: #[action(introspection)] opt-in drives gated injection (ap…
aram356 Jul 2, 2026
9cb3b79
Docs cleanup: single gated architecture in spec; plan status banner +…
aram356 Jul 2, 2026
a9ad7bc
Spec/plan: fully-atomic introspection (IntrospectionNeeds, per-capabi…
aram356 Jul 2, 2026
e7f9d57
Plan: prove per-capability atomicity (3-case probe); atomic header li…
aram356 Jul 2, 2026
be2e9a4
Add IntrospectionNeeds + DynHandler::introspection_needs
aram356 Jul 2, 2026
8273395
#[action]: accept atomic (manifest|routes) params; emit capability-ca…
aram356 Jul 2, 2026
c30e1e5
Opt manifest/routes into introspection via #[action(manifest|routes)]
aram356 Jul 2, 2026
15949c8
Gate introspection injection per capability via IntrospectionNeeds
aram356 Jul 2, 2026
e68df70
Review fixes: combined-caps atomicity test, no-params fn assertion, s…
aram356 Jul 2, 2026
2efa2da
Add macro-level test for combined #[action(manifest, routes)] codegen
aram356 Jul 2, 2026
585cbc5
docs: add State<T> + nested #[secret] design spec
aram356 Jul 2, 2026
304e236
docs: fold second-pass review blockers into spec §8
aram356 Jul 2, 2026
051a9ad
Merge PR #300 (introspection routes) as base for State<T> + nested se…
aram356 Jul 3, 2026
c208524
docs: implementation plans for State<T> + nested secrets; reconcile s…
aram356 Jul 3, 2026
d51e8b9
feat(core): add State<T> extractor for app-owned shared state
aram356 Jul 3, 2026
4487355
feat(core): RouterBuilder::with_state injects app state into request …
aram356 Jul 3, 2026
21c1488
test(macros): prove #[action] composes State<T>; docs: sharing app state
aram356 Jul 3, 2026
a632669
chore: sync Cargo.lock for edgezero-macros futures dev-dep
aram356 Jul 3, 2026
6ebc29a
docs: fold review-round-4 plan fixes; reconcile spec §4.3 with fn sec…
aram356 Jul 3, 2026
44b32ad
refactor(secrets): owned path-qualified SecretField + AppConfigMeta::…
aram356 Jul 3, 2026
f8132bd
refactor(core): store app state in an Extensions bag, not closure ins…
aram356 Jul 3, 2026
d390874
feat(secrets): path-navigating secret_walk (nested objects, arrays, o…
aram356 Jul 3, 2026
fe7bd72
feat(secrets): nested/list-aware validate_excluding_secrets pruning
aram356 Jul 3, 2026
9351db1
feat(macros): #[app_config(nested)] recursion, Option<String> secrets…
aram356 Jul 3, 2026
e26529b
ci(secrets): allow nested AppConfig when field opts in via #[app_conf…
aram356 Jul 3, 2026
4ce2bc5
feat(cli): path-aware secret reflection in config validate/push/diff
aram356 Jul 3, 2026
aa8b67a
test(secrets): end-to-end nested + named-store resolution; docs: nest…
aram356 Jul 3, 2026
47c827f
fix(macros): hard-error empty/bare #[app_config]; refresh stale SECRE…
aram356 Jul 3, 2026
aaf6e28
test(cli): nested config push preserves key names; nested config diff…
aram356 Jul 3, 2026
8c2e8a7
docs: mark superseded StateInserter steps; correct derive empty-app_c…
aram356 Jul 3, 2026
af1e40c
docs: retire remaining stale SECRET_FIELDS / StateInserter wording in…
aram356 Jul 3, 2026
47a112c
docs: last stale SECRET_FIELDS in a config.rs test comment
aram356 Jul 3, 2026
65afbd3
docs: P0-C/P0-D spec (Fastly dispatch fidelity + macro app-state), ma…
aram356 Jul 4, 2026
c86c039
docs: implementation plans for P0-C (Fastly dispatch fidelity) + P0-D…
aram356 Jul 4, 2026
2174a54
docs(p0c plan): cover FastlyService::dispatch caller; add app! owns_l…
aram356 Jul 5, 2026
26f694c
docs(p0 plans): C3 handler-visible test; app-demo clippy (separate wo…
aram356 Jul 5, 2026
a8b6046
docs(p0c plan): correct run_app_with_config dispatch path (via Fastly…
aram356 Jul 5, 2026
b11c71e
fix(fastly): preserve multi-value response headers (Set-Cookie) in fr…
aram356 Jul 5, 2026
2332aeb
docs(p0c plan): correct fastly test invocation (wasm32-wasip1 + Vicer…
aram356 Jul 5, 2026
02a0292
fix(fastly): preserve multi-value headers in proxy response/request c…
aram356 Jul 5, 2026
fe5b675
feat: Hooks::owns_logging() opt-out gated in all four adapter run_app…
aram356 Jul 5, 2026
5795fe2
docs(p0c plan): targeted fastly Viceroy filters (blanket --lib aborts…
aram356 Jul 5, 2026
3518fc3
feat(macros): app!(owns_logging = <bool>) keyword argument + AppArgs …
aram356 Jul 5, 2026
44bd0d0
feat(fastly): run_app_with_request_extensions pre-dispatch hook for r…
aram356 Jul 5, 2026
77b001a
feat(macros): app!(state = <expr>) emits RouterBuilder::with_state fo…
aram356 Jul 5, 2026
6feed2a
docs(app-demo): app!(state = ...) + State<T> handler example with end…
aram356 Jul 5, 2026
c01c490
docs(p0 plans): reconcile with implementation (AppArgs Debug, mod tes…
aram356 Jul 5, 2026
d8f71a4
fix(ci): nested-AppConfig guard footer states the #[app_config(nested…
aram356 Jul 5, 2026
bac9f8a
docs: document app!(state=)/owns_logging + run_app_with_request_exten…
aram356 Jul 5, 2026
8fc67f0
docs: correct state=<expr> guidance, JA4 snippet, and P0-D plan app_s…
aram356 Jul 6, 2026
222cb3a
docs: align config-push + Fastly guides with the blob/secret-metadata…
aram356 Jul 6, 2026
2fc3082
docs: fix Axum local-config shape + remaining #[secret]-stripping claims
aram356 Jul 6, 2026
ead34fe
docs: fix bundled-push examples + blob-key precision
aram356 Jul 7, 2026
306bd4b
docs: document config diff + missing config push flags; clarify __KEY…
aram356 Jul 7, 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
3 changes: 3 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ sha2 = "0.10"
similar = "2"
simple_logger = "5"
proc-macro2 = { version = "1", features = ["span-locations"] }
quote = "1"
syn = { version = "2", features = ["full", "extra-traits", "visit"] }
subtle = "2"
# Pinned to the `~6.0` range (allows 6.0.x, blocks 6.1+) so a minor
Expand Down
26 changes: 15 additions & 11 deletions crates/edgezero-adapter-axum/src/config_store.rs
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
//! Axum adapter config store: reads from a per-id local JSON file.
//!
//! Each declared `[stores.config].ids` id maps to a file at
//! `.edgezero/local-config-<id>.json`. The file holds a flat object of
//! `string -> string` pairs — the same shape `edgezero config push
//! --adapter axum` writes.
//! `.edgezero/local-config-<id>.json`. The file holds a JSON object of
//! `string -> string` pairs. Typed `config push --adapter axum` writes ONE
//! entry — the selected config key (defaults to the logical store id,
//! overridable with `--key`) keyed to a JSON-encoded `BlobEnvelope` string,
//! which the runtime `AppConfig<C>` extractor parses; hand-seeded flat
//! key/value files also work for raw `get`.
//!
//! If the file is absent the store is empty (`get` returns `Ok(None)` for
//! every key). This keeps `edgezero serve --adapter axum` permissive when
Expand Down Expand Up @@ -69,20 +72,21 @@ impl AxumConfigStore {
/// by `config push --adapter axum` to a tempdir, without
/// changing the process CWD.
///
/// The file must contain a flat JSON object of `string -> string`
/// pairs, matching what `config push --adapter axum` writes:
/// The file must be a JSON object of `string -> string` pairs.
/// Typed `config push --adapter axum` writes ONE entry — the selected
/// config key (defaults to the logical store id, overridable with
/// `--key`) keyed to a JSON-encoded `BlobEnvelope` string:
///
/// ```json
/// {
/// "greeting": "hello",
/// "feature.new_checkout": "false",
/// "service.timeout_ms": "1500"
/// "app_config": "{\"version\":1,\"generated_at\":\"…\",\"sha256\":\"…\",\"data\":{}}"
/// }
/// ```
///
/// Dotted keys are stored verbatim (no nesting): the runtime
/// extractors look up the dotted form as a single key. Non-string
/// values (`{"x": 42}`, nested objects, arrays) are rejected.
/// The runtime `AppConfig<C>` extractor parses that envelope string;
/// hand-seeded flat key/value files also work for raw `get`. Values
/// must be strings — non-string values (`{"x": 42}`, nested objects,
/// arrays) are rejected.
///
/// Behaviour matches `from_local_file`: a missing file yields
/// an empty store; a present-but-malformed file yields
Expand Down
4 changes: 3 additions & 1 deletion crates/edgezero-adapter-axum/src/dev_server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -340,7 +340,9 @@ pub fn run_app<A: Hooks>() -> anyhow::Result<()> {
.and_then(|raw| LevelFilter::from_str(raw).ok())
.unwrap_or(LevelFilter::Info);

let _logger_init = SimpleLogger::new().with_level(level).init();
if !A::owns_logging() {
let _logger_init = SimpleLogger::new().with_level(level).init();
}

let resolution = resolve_addr(&env);
for warning in &resolution.warnings {
Expand Down
7 changes: 5 additions & 2 deletions crates/edgezero-adapter-cloudflare/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -101,8 +101,11 @@ pub async fn run_app<A: Hooks>(
ctx: Context,
) -> Result<Response, WorkerError> {
// Best-effort: if a logger is already installed, ignore the error rather
// than panicking — every Worker request re-enters this function.
drop(init_logger());
// than panicking — every Worker request re-enters this function. Skipped
// entirely when the app owns logging.
if !A::owns_logging() {
drop(init_logger());
}
let stores = A::stores();
let env_config = env_config_from_worker(&env, stores);
let app = A::build_app();
Expand Down
37 changes: 34 additions & 3 deletions crates/edgezero-adapter-fastly/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ use edgezero_core::app::{Hooks, StoresMetadata};
#[cfg(feature = "fastly")]
use edgezero_core::env_config::EnvConfig;
#[cfg(feature = "fastly")]
use edgezero_core::http::Extensions;
#[cfg(feature = "fastly")]
use edgezero_core::manifest::ResolvedLoggingConfig;
#[cfg(feature = "fastly")]
#[derive(Debug, Clone)]
Expand Down Expand Up @@ -111,15 +113,44 @@ fn logging_from_env(env: &EnvConfig) -> FastlyLogging {
#[cfg(feature = "fastly")]
#[inline]
pub fn run_app<A: Hooks>(req: fastly::Request) -> Result<fastly::Response, fastly::Error> {
run_app_with_request_extensions::<A, _>(req, |_req, _extensions| {})
}

/// Like [`run_app`], but runs `extend` against a scratch
/// [`Extensions`](edgezero_core::http::Extensions) populated from the raw
/// `fastly::Request` (TLS JA4, H2 fingerprint, client IP, …) before the request
/// is converted; the scratch values are merged into the core request's
/// extensions and are visible to middleware and the `State`/extractor layer.
///
/// # Errors
/// Returns an error if logger setup fails or any required store cannot be opened.
#[cfg(feature = "fastly")]
#[inline]
pub fn run_app_with_request_extensions<A, F>(
req: fastly::Request,
extend: F,
) -> Result<fastly::Response, fastly::Error>
where
A: Hooks,
F: FnOnce(&fastly::Request, &mut Extensions),
{
let stores = A::stores();
let env = env_config_from_runtime_dictionary(stores);
let logging = logging_from_env(&env);
if logging.use_fastly_logger {
if logging.use_fastly_logger && !A::owns_logging() {
let endpoint = logging.endpoint.as_deref().unwrap_or("stdout");
init_logger(endpoint, logging.level, logging.echo_stdout)?;
}
let app = A::build_app();
request::dispatch_with_registries(&app, req, stores.config, stores.kv, stores.secrets, &env)
request::dispatch_with_registries(
&app,
req,
stores.config,
stores.kv,
stores.secrets,
&env,
extend,
)
}

/// Build an [`EnvConfig`] from the optional `edgezero_runtime_env`
Expand Down Expand Up @@ -202,7 +233,7 @@ pub fn run_app_with_config<A: Hooks>(
req: fastly::Request,
config_store_name: Option<&str>,
) -> Result<fastly::Response, fastly::Error> {
if logging.use_fastly_logger {
if logging.use_fastly_logger && !A::owns_logging() {
let endpoint = logging.endpoint.as_deref().unwrap_or("stdout");
init_logger(endpoint, logging.level, logging.echo_stdout)?;
}
Expand Down
31 changes: 27 additions & 4 deletions crates/edgezero-adapter-fastly/src/proxy.rs
Original file line number Diff line number Diff line change
Expand Up @@ -46,11 +46,13 @@ fn build_fastly_request(method: Method, uri: &Uri, headers: &HeaderMap) -> Fastl
let mut fastly_request = FastlyRequest::new(method.clone(), uri.to_string());
fastly_request.set_method(method);

// Append (not set) so a multi-value client header survives; `Host` below is
// set explicitly as a single value.
for (name, value) in headers {
if name.as_str().eq_ignore_ascii_case("host") {
continue;
}
fastly_request.set_header(name.as_str(), value.clone());
fastly_request.append_header(name.as_str(), value.clone());
}

if let Some(host) = uri.host() {
Expand All @@ -64,9 +66,13 @@ fn convert_response(fastly_response: &mut FastlyResponse) -> ProxyResponse {
let status = fastly_response.get_status();
let mut proxy_response = ProxyResponse::new(status, Body::empty());

for header in fastly_response.get_header_names() {
if let Some(value) = fastly_response.get_header(header) {
proxy_response.headers_mut().insert(header, value.clone());
// Preserve multi-value ORIGIN response headers (e.g. Set-Cookie): read ALL
// values per name and append, instead of first-value + insert (which
// replaced). `get_header_names()` yields `&HeaderName`, usable for both
// `get_header_all` and `append`.
for name in fastly_response.get_header_names() {
for value in fastly_response.get_header_all(name) {
proxy_response.headers_mut().append(name, value.clone());
}
}

Expand Down Expand Up @@ -212,6 +218,23 @@ mod tests {
}
}

#[test]
fn convert_response_preserves_multi_value_set_cookie() {
let mut fastly_response = FastlyResponse::from_status(200);
fastly_response.append_header("set-cookie", "a=1");
fastly_response.append_header("set-cookie", "b=2");

let proxy_response = convert_response(&mut fastly_response);

let cookies: Vec<String> = proxy_response
.headers()
.get_all("set-cookie")
.into_iter()
.map(|value| value.to_str().expect("utf8").to_owned())
.collect();
assert_eq!(cookies, vec!["a=1".to_owned(), "b=2".to_owned()]);
}

#[test]
fn stream_handles_brotli() {
let mut compressed = Vec::new();
Expand Down
96 changes: 90 additions & 6 deletions crates/edgezero-adapter-fastly/src/request.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ use edgezero_core::body::Body;
use edgezero_core::config_store::ConfigStoreHandle;
use edgezero_core::env_config::EnvConfig;
use edgezero_core::error::EdgeError;
use edgezero_core::http::{request_builder, Request};
use edgezero_core::http::{request_builder, Extensions, Request};
use edgezero_core::key_value_store::KvHandle;
use edgezero_core::proxy::ProxyHandle;
use edgezero_core::secret_store::SecretHandle;
Expand Down Expand Up @@ -151,6 +151,7 @@ impl<'app> FastlyService<'app> {
secrets,
..Default::default()
},
|_req, _extensions| {},
)
}

Expand Down Expand Up @@ -276,12 +277,31 @@ fn dispatch_core_request(
from_core_response(response).map_err(|err| map_edge_error(&err))
}

fn dispatch_with_handles(
/// Run an app-provided closure against a scratch `Extensions` populated from the
/// RAW `fastly::Request` (JA4 / H2 / etc.), BEFORE `into_core_request` consumes
/// the request. Returns the scratch bag to be `extend`ed into the core request.
fn apply_request_extend<F>(req: &FastlyRequest, extend: F) -> Extensions
where
F: FnOnce(&FastlyRequest, &mut Extensions),
{
let mut scratch = Extensions::default();
extend(req, &mut scratch);
scratch
}

fn dispatch_with_handles<F>(
app: &App,
req: FastlyRequest,
stores: Stores,
) -> Result<FastlyResponse, FastlyError> {
let core_request = into_core_request(req).map_err(|err| map_edge_error(&err))?;
extend: F,
) -> Result<FastlyResponse, FastlyError>
where
F: FnOnce(&FastlyRequest, &mut Extensions),
{
// Read raw-request signals into a scratch bag BEFORE conversion consumes `req`.
let scratch = apply_request_extend(&req, extend);
let mut core_request = into_core_request(req).map_err(|err| map_edge_error(&err))?;
core_request.extensions_mut().extend(scratch);
dispatch_core_request(app, core_request, stores)
}

Expand All @@ -292,14 +312,18 @@ fn dispatch_with_handles(
/// id default). KV failures escalate via [`resolve_kv_handle`]'s
/// `kv_required=true` path; missing config / secret stores degrade silently
/// with a one-time warning.
pub(crate) fn dispatch_with_registries(
pub(crate) fn dispatch_with_registries<F>(
app: &App,
req: FastlyRequest,
config_meta: Option<StoreMetadata>,
kv_meta: Option<StoreMetadata>,
secret_meta: Option<StoreMetadata>,
env: &EnvConfig,
) -> Result<FastlyResponse, FastlyError> {
extend: F,
) -> Result<FastlyResponse, FastlyError>
where
F: FnOnce(&FastlyRequest, &mut Extensions),
{
let kv_registry = build_kv_registry(kv_meta, env)?;
let config_registry = build_config_registry(config_meta, env);
let secret_registry = build_secret_registry(secret_meta, env);
Expand All @@ -312,6 +336,7 @@ pub(crate) fn dispatch_with_registries(
secret_registry,
..Default::default()
},
extend,
)
}

Expand Down Expand Up @@ -573,6 +598,65 @@ mod synthesis_tests {
SecretHandle::new(Arc::new(NoopSecretStore))
}

#[test]
fn apply_request_extend_populates_scratch_from_raw_request() {
use edgezero_core::http::Method;

#[derive(Clone, Debug, PartialEq)]
struct Ja4(String);

let raw = FastlyRequest::new(Method::GET, "http://example.test/");
let scratch = apply_request_extend(&raw, |req, extensions| {
// A real closure would call req.get_tls_ja4(); deriving from the URL
// keeps the assertion deterministic under Viceroy.
let marker = req.get_url_str().to_owned();
extensions.insert(Ja4(marker));
});

assert_eq!(
scratch.get::<Ja4>(),
Some(&Ja4("http://example.test/".to_owned()))
);
}

#[test]
fn extended_request_extensions_are_visible_to_handler() {
use edgezero_core::body::Body;
use edgezero_core::context::RequestContext;
use edgezero_core::http::{request_builder, Method, StatusCode};
use edgezero_core::router::RouterService;
use futures::executor::block_on;

#[derive(Clone)]
struct Ja4(String);

async fn handler(ctx: RequestContext) -> Result<String, EdgeError> {
let ja4 = ctx
.request()
.extensions()
.get::<Ja4>()
.map_or_else(|| "missing".to_owned(), |value| value.0.clone());
Ok(ja4)
}

// Mirror what `dispatch_with_handles` does: a scratch bag built from the
// raw request is `extend`ed into the core request before dispatch.
let mut scratch = Extensions::default();
scratch.insert(Ja4("t13d1516h2".to_owned()));

let mut core_request = request_builder()
.method(Method::GET)
.uri("/ja4")
.body(Body::empty())
.expect("request");
core_request.extensions_mut().extend(scratch);

let service = RouterService::builder().get("/ja4", handler).build();
let response = block_on(service.oneshot(core_request)).expect("response");
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(response.body().as_bytes().expect("buffered"), b"t13d1516h2");
}

#[test]
fn synthesis_wraps_bare_kv_handle_under_default_when_no_registry() {
let stores = Stores {
Expand Down
Loading