Skip to main content

cratefield_core/
sidecar.rs

1//! Sidecar mounts (ADR 0009): a module served by its own Worker, mounted at
2//! the same `/v1/<name>` as an in-process module and indistinguishable to a
3//! caller.
4//!
5//! The mount table is **runtime configuration**, never a builder call. A
6//! `.sidecar()` in `src/harness.rs` would bake a customer-specific mount into
7//! the artifact, so the artifact would stop being a function of the module set
8//! and could no longer be shared between ventures (#59).
9
10use std::collections::BTreeMap;
11use std::sync::Arc;
12
13use axum::Router;
14use axum::extract::{OriginalUri, State};
15use axum::http::{HeaderName, HeaderValue, Method, StatusCode};
16use axum::middleware::Next;
17use axum::response::{IntoResponse, Response};
18use bytes::Bytes;
19
20use crate::admin::require_admin;
21use crate::config::Config;
22use crate::events::EventBus;
23use crate::http::{Json, MAX_BODY_BYTES, X_REQUEST_ID};
24use crate::module::HARNESS_API;
25use crate::ports::{Clock, Dispatcher, Kid, Payload, RateLimiter, Signer};
26use crate::problem::Problem;
27use crate::problems::SLUGS;
28use crate::rate_limit::{
29    RateLimit, RateLimitFailure, check_rate_limit, client_ip, rate_limit_keys,
30};
31use crate::scope::Scope;
32use crate::signer::{HmacSigner, KeyRing, MIN_SECRET_BYTES, TokenPolicy};
33
34/// Config key holding the mount table, a JSON object of
35/// `{"<module name>": "<service binding>"}`.
36pub const HARNESS_SIDECARS: &str = "HARNESS_SIDECARS";
37
38/// Contract version stamped on every harness response, checked by the host on
39/// every forwarded response. Stamping beats a cold-start handshake because an
40/// isolate outlives a sidecar redeploy (ADR 0009).
41pub const X_HARNESS_API: &str = "x-harness-api";
42/// Module name stamped alongside [`X_HARNESS_API`].
43pub const X_HARNESS_MODULE: &str = "x-harness-module";
44
45/// Shared secret between the host and a sidecar that enforces the gateway
46/// (issue #131). The host mints short-lived per-request tokens with it; the
47/// sidecar verifies them on `/v1/*` and `/__surface`. It is its own secret —
48/// `HARNESS_SECRET` stays venture-local (ADR 0009) — and must appear on
49/// both ends of a mounted pair.
50pub const SIDECAR_GATEWAY_SECRET: &str = "SIDECAR_GATEWAY_SECRET";
51/// Sidecar-side switch: truthy means every `/v1/*` and `/__surface` request
52/// must carry a valid gateway token minted with [`SIDECAR_GATEWAY_SECRET`].
53/// Requiring it without the secret is a configuration error and fails
54/// closed, not open (issue #131).
55pub const SIDECAR_REQUIRE_GATEWAY: &str = "SIDECAR_REQUIRE_GATEWAY";
56/// Truthy declares "this venture deploys as one Worker": mounting a sidecar
57/// then contradicts the shape, and [`SidecarMounts::from_config`] rejects
58/// the mount table rather than starting a deployment that cannot reach it
59/// (issue #131).
60pub const HARNESS_ONE_WORKER: &str = "HARNESS_ONE_WORKER";
61
62/// Gateway token header, minted by the host per forwarded request and
63/// verified by a sidecar that set [`SIDECAR_REQUIRE_GATEWAY`] (issue #131).
64pub const X_HARNESS_GATEWAY: &str = "x-harness-gateway";
65
66/// The token purpose bound into the gateway MAC. A token minted for any
67/// other purpose — a confirm link, an admin session — cannot pass
68/// verification, and vice versa (issue #137's rule, applied to #131).
69pub const GATEWAY_PURPOSE: &str = "sidecar-gateway";
70
71/// The purpose the host mints **only** after its own [`require_admin`]
72/// has passed for an admin path (issue #131). It is what lets a sidecar
73/// re-materialize its own admin token: a token proving nothing more than
74/// "this came through the host" must not open the admin plane, or any
75/// captured forwarded stamp would be an admin credential for its whole
76/// lifetime.
77pub const GATEWAY_ADMIN_PURPOSE: &str = "sidecar-gateway-admin";
78
79/// Gateway token lifetime: a forwarded request completes in one hop, so a
80/// minute is generous. Short TTLs shrink the replay window a stolen token
81/// buys an attacker (issue #131).
82pub const GATEWAY_TOKEN_TTL_SECS: i64 = 120;
83
84/// Request headers forwarded verbatim, everything else dropped (issue
85/// #131). Notably never `authorization` or `cookie`: the venture's admin
86/// token is the host's alone, and the sidecar answers to its own
87/// credentials.
88const FORWARDED_REQUEST_HEADERS: &[&str] = &[
89    "content-type",
90    "content-length",
91    "accept",
92    "accept-language",
93    "user-agent",
94];
95
96/// Response headers copied back to the caller (issue #131). Everything
97/// else stops at the host — most importantly `set-cookie`, which would
98/// let a sidecar plant cookies on the venture's origin.
99const FORWARDED_RESPONSE_HEADERS: &[&str] = &[
100    "content-type",
101    "location",
102    "cache-control",
103    "etag",
104    "last-modified",
105    "vary",
106    "retry-after",
107    "content-disposition",
108    X_HARNESS_API,
109    X_HARNESS_MODULE,
110    X_REQUEST_ID,
111];
112
113/// `true` when `key` is set to one of `1`, `true`, `yes` or `on`
114/// (case-insensitive) — the same reading `ModuleConfig::get_bool` gives,
115/// applied to harness-level keys.
116#[must_use]
117pub(crate) fn truthy(config: &dyn Config, key: &str) -> bool {
118    config.get(key).is_some_and(|raw| {
119        matches!(
120            raw.to_ascii_lowercase().as_str(),
121            "1" | "true" | "yes" | "on"
122        )
123    })
124}
125
126/// The gateway signer for this deployment, or `None` when
127/// [`SIDECAR_GATEWAY_SECRET`] is unset. The host role uses it to mint
128/// per-request tokens; a sidecar that set [`SIDECAR_REQUIRE_GATEWAY`]
129/// uses the same construction as its verification key. The secret joins
130/// its own bounded ring (issue #137) — venture secrets never cross a
131/// Worker boundary (ADR 0009) — and the ring holds only the current key:
132/// a gateway token lives 120 s, so rotation has no grace window to
133/// serve. The host's venture/env labels do not either: host and sidecar
134/// are different deployments and must not have to match them.
135pub(crate) fn gateway_signer(
136    config: &dyn Config,
137    clock: Arc<dyn Clock>,
138) -> Option<Arc<HmacSigner>> {
139    let secret = config
140        .get(SIDECAR_GATEWAY_SECRET)
141        .filter(|value| !value.trim().is_empty())?;
142    let mut ring = KeyRing::new();
143    match ring.rotate_signing(Kid::Cur, secret.into_bytes()) {
144        Ok(_) => {}
145        Err(crate::signer::SignerError::SecretTooShort) => {
146            tracing::error!("{SIDECAR_GATEWAY_SECRET} must be at least {MIN_SECRET_BYTES} bytes");
147            return None;
148        }
149        Err(err) => {
150            tracing::error!(error = %err, "{SIDECAR_GATEWAY_SECRET} could not enter the gateway ring");
151            return None;
152        }
153    }
154    let policy = TokenPolicy::default()
155        .with_max(GATEWAY_PURPOSE, Some(GATEWAY_TOKEN_TTL_SECS))
156        .with_max(GATEWAY_ADMIN_PURPOSE, Some(GATEWAY_TOKEN_TTL_SECS));
157    Some(Arc::new(
158        HmacSigner::from_ring(ring)
159            .with_clock(clock)
160            .with_policy(policy),
161    ))
162}
163
164/// A gateway token for one forwarded request. `exp` is left unset: the
165/// policy ceiling is the lifetime. `admin` is the host's assertion that
166/// [`require_admin`] already passed for this request, and it is bound
167/// into the MAC as the purpose — a caller cannot promote a plain stamp.
168pub(crate) fn mint_gateway(signer: &HmacSigner, mount: &str, admin: bool) -> String {
169    let purpose = if admin {
170        GATEWAY_ADMIN_PURPOSE
171    } else {
172        GATEWAY_PURPOSE
173    };
174    signer.sign(&Payload {
175        purpose: purpose.to_owned(),
176        subject: mount.to_owned(),
177        exp: None,
178        kid: Kid::Cur,
179    })
180}
181
182/// What a presented gateway token proves: `Some(true)` when the host
183/// minted it after authorizing an admin request, `Some(false)` for an
184/// ordinary forwarded request, `None` when it is not this deployment's
185/// token or has expired. The subject (the mounted module name) is
186/// carried for forensics, not checked: a token is valid at any sidecar
187/// sharing the gateway secret, and the alternative — the sidecar
188/// guessing under what name the host mounted it — cannot work for
189/// `/__surface`.
190pub(crate) fn gateway_grant(signer: &HmacSigner, token: &str) -> Option<bool> {
191    if signer.verify(token, GATEWAY_ADMIN_PURPOSE).is_some() {
192        return Some(true);
193    }
194    signer.verify(token, GATEWAY_PURPOSE).map(|_| false)
195}
196
197/// One mounted sidecar.
198#[derive(Debug, Clone, PartialEq, Eq)]
199pub struct SidecarMount {
200    /// Module name; mounted at `/v1/<name>`.
201    pub name: String,
202    /// Service binding the runtime resolves to reach it.
203    pub binding: String,
204}
205
206/// The mount table, parsed from configuration.
207#[derive(Debug, Clone, Default, PartialEq, Eq)]
208pub struct SidecarMounts(Vec<SidecarMount>);
209
210impl SidecarMounts {
211    /// Reads and validates [`HARNESS_SIDECARS`]. Absent or empty is not an
212    /// error: most ventures mount no sidecars.
213    ///
214    /// # Errors
215    ///
216    /// Every malformed entry, reported together so one deploy surfaces them
217    /// all rather than one per attempt.
218    pub fn from_config(config: &dyn Config) -> Result<Self, Vec<String>> {
219        let Some(raw) = config
220            .get(HARNESS_SIDECARS)
221            .filter(|v| !v.trim().is_empty())
222        else {
223            return Ok(Self::default());
224        };
225        if truthy(config, HARNESS_ONE_WORKER) {
226            return Err(vec![format!(
227                "{HARNESS_SIDECARS} names a sidecar while {HARNESS_ONE_WORKER} is set: a one-Worker \
228                 deployment serves every module in-process and can mount nothing (issue #131)"
229            )]);
230        }
231        let parsed: BTreeMap<String, String> = serde_json::from_str(&raw).map_err(|err| {
232            vec![format!(
233                "{HARNESS_SIDECARS} must be a JSON object of {{\"module-name\": \"BINDING\"}}: {err}"
234            )]
235        })?;
236
237        let mut errors = Vec::new();
238        let mut mounts = Vec::new();
239        for (name, binding) in parsed {
240            if !is_kebab(&name) {
241                errors.push(format!(
242                    "sidecar name `{name}` must be kebab-case ([a-z0-9]+ separated by '-')"
243                ));
244            }
245            if binding.trim().is_empty() {
246                errors.push(format!("sidecar `{name}` has an empty service binding"));
247            }
248            mounts.push(SidecarMount { name, binding });
249        }
250        if errors.is_empty() {
251            Ok(Self(mounts))
252        } else {
253            Err(errors)
254        }
255    }
256
257    #[must_use]
258    pub fn is_empty(&self) -> bool {
259        self.0.is_empty()
260    }
261
262    pub fn iter(&self) -> impl Iterator<Item = &SidecarMount> {
263        self.0.iter()
264    }
265
266    /// Names that collide with an in-process module. Both sides are known
267    /// without an `Env`, so this is the one sidecar check that can run early.
268    #[must_use]
269    pub fn collisions(&self, module_names: &[&str]) -> Vec<String> {
270        self.0
271            .iter()
272            .filter(|m| module_names.contains(&m.name.as_str()))
273            .map(|m| {
274                format!(
275                    "sidecar `{}` claims `/v1/{}`, already served in-process",
276                    m.name, m.name
277                )
278            })
279            .collect()
280    }
281}
282
283fn is_kebab(s: &str) -> bool {
284    !s.is_empty()
285        && !s.starts_with('-')
286        && !s.ends_with('-')
287        && !s.contains("--")
288        && s.chars()
289            .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
290}
291
292struct SidecarState {
293    mount: SidecarMount,
294    dispatcher: Option<Arc<dyn Dispatcher>>,
295    config: Arc<dyn Config>,
296    rate_limiter: Option<Arc<dyn RateLimiter>>,
297    gateway: Option<Arc<HmacSigner>>,
298}
299
300/// The router for one sidecar prefix: a fallback that forwards everything
301/// the trust boundary lets through (issue #131).
302pub(crate) fn router(
303    mount: SidecarMount,
304    dispatcher: Option<Arc<dyn Dispatcher>>,
305    config: Arc<dyn Config>,
306    rate_limiter: Option<Arc<dyn RateLimiter>>,
307    gateway: Option<Arc<HmacSigner>>,
308) -> Router {
309    Router::new()
310        .fallback(forward)
311        .with_state(Arc::new(SidecarState {
312            mount,
313            dispatcher,
314            config,
315            rate_limiter,
316            gateway,
317        }))
318}
319
320async fn forward(
321    State(state): State<Arc<SidecarState>>,
322    scope: Scope,
323    // `nest` strips the mount prefix from `parts.uri`, but a sidecar is a
324    // whole harness serving its module at `/v1/<name>`: it must be given the
325    // path the caller used, or every forwarded request 404s at the far end.
326    OriginalUri(uri): OriginalUri,
327    request: axum::extract::Request,
328) -> Response {
329    let unavailable = |detail: String| -> Response {
330        Problem::new(&SLUGS.sidecar_unavailable)
331            .with_detail(detail)
332            .instance(&scope.request_id)
333            .into_response()
334    };
335
336    let Some(dispatcher) = state.dispatcher.clone() else {
337        tracing::warn!(
338            module = state.mount.name,
339            "sidecar mounted but the runtime provides no dispatcher"
340        );
341        return unavailable(format!(
342            "`{}` is mounted as a sidecar but this runtime cannot dispatch",
343            state.mount.name
344        ));
345    };
346    if !dispatcher.has(&state.mount.binding) {
347        tracing::warn!(
348            module = state.mount.name,
349            binding = state.mount.binding,
350            "sidecar binding is not present in this deployment"
351        );
352        return unavailable(format!(
353            "`{}` is mounted on binding `{}`, which this deployment does not have",
354            state.mount.name, state.mount.binding
355        ));
356    }
357
358    let (parts, body) = request.into_parts();
359    let Ok(body) = axum::body::to_bytes(body, MAX_BODY_BYTES).await else {
360        return Problem::new(&SLUGS.request_too_large)
361            .instance(&scope.request_id)
362            .into_response();
363    };
364
365    // The host owns the venture's admin plane: `ADMIN_TOKEN` never
366    // crosses the boundary, so admin paths are authorized here, against
367    // the host's own configuration, before anything is forwarded
368    // (issue #131). The verdict is what the gateway stamp then asserts.
369    let admin = is_admin_path(uri.path());
370    if admin && let Err(problem) = require_admin(state.config.as_ref(), &parts.headers) {
371        return problem.instance(&scope.request_id).into_response();
372    }
373
374    // The host's abuse controls run for every mounted request: the sidecar
375    // cannot resolve the caller's address (the header allowlist above is
376    // the only channel it has), and a limiter failure fails closed — a
377    // forwarded write has no captcha-or-cooldown backstop behind it.
378    // Reads are exempt: throttling a `GET` budgets would punish health
379    // polls and link landing pages for one noisy neighbor.
380    if matches!(
381        parts.method,
382        Method::POST | Method::PUT | Method::PATCH | Method::DELETE
383    ) {
384        let keys = rate_limit_keys(client_ip(&parts.headers).as_deref(), None);
385        if let RateLimit::Denied { retry_after } = check_rate_limit(
386            state.rate_limiter.as_ref(),
387            &keys,
388            RateLimitFailure::FailClosed,
389        )
390        .await
391        {
392            return crate::http::rate_limited(retry_after);
393        }
394    }
395
396    let mut outbound = http::Request::builder()
397        .method(parts.method.clone())
398        .uri(uri);
399    if let Some(headers) = outbound.headers_mut() {
400        write_forwarded_headers(headers, &state, &scope, &parts.headers, admin);
401    }
402    let outbound = match outbound.body(body) {
403        Ok(req) => req,
404        Err(err) => return unavailable(format!("could not build the forwarded request: {err}")),
405    };
406
407    match dispatcher.dispatch(&state.mount.binding, outbound).await {
408        Ok(response) => match contract_of(&response) {
409            Some(api) if api != HARNESS_API => {
410                tracing::warn!(
411                    module = state.mount.name,
412                    sidecar_api = api,
413                    host_api = HARNESS_API,
414                    "sidecar contract mismatch"
415                );
416                Problem::new(&SLUGS.sidecar_contract_mismatch)
417                    .with_detail(format!(
418                        "`{}` answers contract {api}; this harness speaks {HARNESS_API}",
419                        state.mount.name
420                    ))
421                    .instance(&scope.request_id)
422                    .into_response()
423            }
424            _ => into_axum(response),
425        },
426        Err(err) => {
427            tracing::warn!(module = state.mount.name, error = %err, "sidecar dispatch failed");
428            unavailable(err.to_string())
429        }
430    }
431}
432
433/// The contract a response claims, if it claims one. A sidecar that stamps
434/// nothing is not rejected here: it may predate the header, and the mismatch
435/// that matters is a *wrong* number, not a missing one.
436/// Builds the forwarded request's headers: the allowlist, the shared
437/// request id, the host-resolved caller address and the gateway stamp
438/// (issue #131).
439fn write_forwarded_headers(
440    headers: &mut http::HeaderMap,
441    state: &SidecarState,
442    scope: &Scope,
443    inbound: &http::HeaderMap,
444    admin: bool,
445) {
446    // An allowlist, not a denylist: `authorization`, `cookie`, and every
447    // unregistered header stop at the host. The caller's credentials
448    // belong to this Worker alone.
449    for name in FORWARDED_REQUEST_HEADERS {
450        let name = HeaderName::from_static(name);
451        if let Some(value) = inbound.get(&name) {
452            headers.append(name, value.clone());
453        }
454    }
455    // One trail across both Workers. `insert`, so a client-supplied id
456    // cannot arrive twice.
457    if let Ok(value) = scope.request_id.parse() {
458        headers.insert(X_REQUEST_ID, value);
459    }
460    // The caller's address travels exactly once, and it is the address
461    // the *host* resolved: a client-forged `cf-connecting-ip` is
462    // replaced here, never copied.
463    if let Some(ip) = client_ip(inbound)
464        && let Ok(value) = HeaderValue::from_str(&ip)
465    {
466        headers.insert(HeaderName::from_static("cf-connecting-ip"), value);
467    }
468    // The gateway stamp: proof to the sidecar that this request came
469    // through the host, and — only when the host authorized it — that
470    // the caller cleared the admin gate. No secret, no stamp, and a
471    // sidecar that *requires* the gateway then refuses every request,
472    // loudly, rather than quietly serving the open internet.
473    if let Some(signer) = state.gateway.as_ref() {
474        let token = mint_gateway(signer, &state.mount.name, admin);
475        if let Ok(value) = HeaderValue::from_str(&token) {
476            headers.insert(HeaderName::from_static(X_HARNESS_GATEWAY), value);
477        }
478    }
479}
480
481fn contract_of(response: &http::Response<Bytes>) -> Option<u32> {
482    response
483        .headers()
484        .get(X_HARNESS_API)
485        .and_then(|v| v.to_str().ok())
486        .and_then(|v| v.parse().ok())
487}
488
489/// Copy the sidecar's answer back to the caller header-by-header: the
490/// response allowlist is the boundary (issue #131). `set-cookie` stops
491/// here — a sidecar that can plant cookies on the venture's origin owns
492/// the caller's session on a surface it does not serve.
493fn into_axum(response: http::Response<Bytes>) -> Response {
494    let (parts, body) = response.into_parts();
495    let mut out = Response::new(axum::body::Body::from(body));
496    *out.status_mut() = parts.status;
497    *out.version_mut() = parts.version;
498    for name in FORWARDED_RESPONSE_HEADERS {
499        let name = HeaderName::from_static(name);
500        if let Some(value) = parts.headers.get(&name) {
501            out.headers_mut().append(name.clone(), value.clone());
502        }
503    }
504    out
505}
506
507// ------------------------------------------------------------- events (#62)
508
509/// The wire shape of the host's `POST /__events` body. One event, one
510/// payload — the same values `EventBus::emit_in` was given, serialized once
511/// and forwarded verbatim (issue #62).
512#[derive(serde::Deserialize)]
513pub(crate) struct EventEnvelope {
514    event: String,
515    payload: serde_json::Value,
516}
517
518/// Carries every event a harness emits to the sidecars it has mounted
519/// (ADR 0017). Built per router — per `Harness::router(ports)` — because
520/// only there are the `Dispatcher` and the mount table resolved; the bus
521/// itself is built in `Harness::build`, which has no `Env` (ADR 0009).
522pub struct EventForwarder {
523    mounts: Vec<SidecarMount>,
524    dispatcher: Arc<dyn Dispatcher>,
525    gateway: Option<Arc<HmacSigner>>,
526}
527
528impl EventForwarder {
529    pub(crate) fn new(
530        mounts: Vec<SidecarMount>,
531        dispatcher: Arc<dyn Dispatcher>,
532        gateway: Option<Arc<HmacSigner>>,
533    ) -> Self {
534        Self {
535            mounts,
536            dispatcher,
537            gateway,
538        }
539    }
540
541    /// Nothing to forward to: a harness with no mounted sidecars.
542    #[must_use]
543    pub fn is_empty(&self) -> bool {
544        self.mounts.is_empty()
545    }
546
547    /// Delivers one event to every mounted sidecar, at most once each
548    /// (ADR 0017). A sidecar that does not answer, answers with an error
549    /// or does not recognize the event is logged and otherwise forgotten:
550    /// the in-process bus never promised delivery, and forwarding must not
551    /// promise more than the bus it extends. Never retries — a retry would
552    /// be the durable-queue design this issue explicitly rules out.
553    pub(crate) async fn forward(&self, request_id: &str, name: &str, payload: &serde_json::Value) {
554        let body = match serde_json::to_vec(&serde_json::json!({
555            "event": name,
556            "payload": payload,
557        })) {
558            Ok(body) => body,
559            Err(err) => {
560                tracing::warn!(event = %name, error = %err, "event payload did not serialize");
561                return;
562            }
563        };
564        for mount in &self.mounts {
565            if !self.dispatcher.has(&mount.binding) {
566                continue;
567            }
568            let mut builder = http::Request::builder()
569                .method(Method::POST)
570                .uri("/__events")
571                .header(HeaderName::from_static("content-type"), "application/json");
572            // One trail across both Workers, as for a forwarded request.
573            if let Ok(value) = HeaderValue::from_str(request_id) {
574                builder = builder.header(HeaderName::from_static(X_REQUEST_ID), value);
575            }
576            if let Some(signer) = self.gateway.as_ref() {
577                // An event forward authorizes nothing: the plain purpose.
578                builder = builder.header(
579                    HeaderName::from_static(X_HARNESS_GATEWAY),
580                    mint_gateway(signer, &mount.name, false),
581                );
582            }
583            let request = match builder.body(Bytes::from(body.clone())) {
584                Ok(request) => request,
585                Err(err) => {
586                    tracing::warn!(event = %name, error = %err, "event forward could not be built");
587                    continue;
588                }
589            };
590            match self.dispatcher.dispatch(&mount.binding, request).await {
591                Ok(response) if response.status().is_success() => {}
592                Ok(response) => {
593                    let detail = format!(
594                        "event forward to `{}` answered {}",
595                        mount.name,
596                        response.status()
597                    );
598                    tracing::warn!(event = %name, module = %mount.name, detail, "event forward was not accepted");
599                    crate::logging::forward_internal_error(&detail);
600                }
601                Err(err) => {
602                    let detail = format!(
603                        "event forward to `{mount}` failed: {err}",
604                        mount = mount.name
605                    );
606                    tracing::warn!(event = %name, module = %mount.name, error = %err, "event forward failed");
607                    crate::logging::forward_internal_error(&detail);
608                }
609            }
610        }
611    }
612}
613
614/// The `POST /__events` route's state: the bus to deliver into and the key
615/// that decides whether the caller may trigger it at all. The route is only
616/// mounted when a gateway signer exists — without the shared secret there
617/// is no way to tell the host's forward from anyone else's `POST`, and an
618/// unauthenticated event trigger would let a stranger forge the payloads
619/// in-process handlers act on.
620pub(crate) struct InboundEvents {
621    pub(crate) bus: EventBus,
622    pub(crate) gateway: Option<Arc<HmacSigner>>,
623}
624
625/// The sidecar half of event forwarding (ADR 0017): accept the host's
626/// delivery, answer `202` immediately, and run any local handlers in this
627/// deployment's **own** `wait_until`. Running them here — not before
628/// answering — is the whole point: a slow subscriber must not hold the
629/// host's deferred future open, only its own.
630pub(crate) async fn events_inbound(
631    State(state): State<Arc<InboundEvents>>,
632    scope: Scope,
633    headers: axum::http::HeaderMap,
634    body: bytes::Bytes,
635) -> Response {
636    // The host always stamps its forwards; anything unstamped is not the
637    // host, and a stamp that does not verify gets the same answer.
638    if let Some(signer) = state.gateway.as_ref() {
639        let presented = headers
640            .get(X_HARNESS_GATEWAY)
641            .and_then(|value| value.to_str().ok());
642        if presented
643            .and_then(|token| gateway_grant(signer, token))
644            .is_none()
645        {
646            return Problem::new(&SLUGS.sidecar_unauthorized)
647                .instance(&scope.request_id)
648                .into_response();
649        }
650    }
651    let Ok(envelope) = serde_json::from_slice::<EventEnvelope>(&body) else {
652        return Problem::new(&SLUGS.validation_failed)
653            .with_detail("body must be `{\"event\": \"<name>\", \"payload\": …}`")
654            .instance(&scope.request_id)
655            .into_response();
656    };
657    let handled = state
658        .bus
659        .deliver_inbound(&scope, &envelope.event, &envelope.payload);
660    if handled == 0 {
661        // The amendment to #62: an event nobody hears is exactly the
662        // silent failure this issue exists to prevent, so the inbound
663        // half refuses to be silent too.
664        let detail = format!(
665            "event `{}` arrived over the boundary with no subscriber",
666            envelope.event
667        );
668        tracing::warn!(event = %envelope.event, "{detail}");
669        crate::logging::forward_internal_error(&detail);
670    }
671    // 202, not 200: the handlers have *not* run when this answers. They
672    // run in this deployment's own `wait_until`, after the response, and
673    // a status that claimed the work was done would be the one lie this
674    // route cannot afford - the host reads it to decide whether the
675    // forward was accepted, never whether it succeeded (ADR 0017).
676    (
677        StatusCode::ACCEPTED,
678        Json(serde_json::json!({ "accepted": handled > 0, "handlers": handled })),
679    )
680        .into_response()
681}
682
683/// Admin paths as the host sees them: a sidecar's own `/admin` plane
684/// arrives under its mount, so both spellings are authorized here, before
685/// the header that proves the caller is an admin is dropped by the
686/// forward allowlist (issue #131). This mirrors the renderer's gating
687/// rule from #130.
688fn is_admin_path(path: &str) -> bool {
689    path == "/admin" || path.ends_with("/admin") || path.contains("/admin/")
690}
691
692/// Sidecar-role gateway enforcement state, built once at startup from
693/// [`SIDECAR_REQUIRE_GATEWAY`] and [`SIDECAR_GATEWAY_SECRET`] (issue #131).
694#[derive(Clone)]
695pub(crate) struct GatewayGuard {
696    /// Whether the gate is closed at all.
697    pub require: bool,
698    /// The verification key; `require` without it fails closed.
699    pub signer: Option<Arc<HmacSigner>>,
700    /// The sidecar's own admin token, used only to re-assert host-verified
701    /// admin requests to the module routes behind the gate (issue #131).
702    pub admin_token: Option<String>,
703}
704
705/// The boundary a sidecar enforces from its own side: with
706/// [`SIDECAR_REQUIRE_GATEWAY`] set, every `/v1/*`, `/__surface` and
707/// `/__events` request must carry a gateway token this deployment's secret
708/// minted and has not outlived (issues #131 and #62). `/__health`, `/ui`
709/// and well-known routes stay
710/// open — probes must probe, and the page is the deployment's own
711/// surface. A require without a usable secret is a broken deploy, and the
712/// answer is a loud 503 on every guarded request, never a quiet 200.
713pub(crate) async fn gateway_guard(
714    State(guard): State<GatewayGuard>,
715    request: axum::extract::Request,
716    next: Next,
717) -> Response {
718    let path = request.uri().path().to_owned();
719    // `/__events` joins the guarded set with issue #62: the host's event
720    // forward carries a stamp like any other forwarded request, and an
721    // event trigger a stranger can reach is a payload a handler trusts.
722    if !guard.require || !(path.starts_with("/v1/") || path == "/__surface" || path == "/__events")
723    {
724        return next.run(request).await;
725    }
726    let instance = request
727        .extensions()
728        .get::<Scope>()
729        .map(|scope| scope.request_id.clone());
730    let refused = |problem: Problem| match &instance {
731        Some(id) => problem.instance(id),
732        None => problem,
733    };
734    let Some(signer) = guard.signer.as_ref() else {
735        tracing::error!(
736            "{SIDECAR_REQUIRE_GATEWAY} is set but {SIDECAR_GATEWAY_SECRET} is \
737             missing; refusing every guarded request"
738        );
739        return refused(Problem::new(&SLUGS.sidecar_unavailable).with_detail(
740            "this sidecar requires a gateway token but no gateway secret is configured",
741        ))
742        .into_response();
743    };
744    let presented = request
745        .headers()
746        .get(X_HARNESS_GATEWAY)
747        .and_then(|value| value.to_str().ok());
748    let Some(host_authorized_admin) = presented.and_then(|token| gateway_grant(signer, token))
749    else {
750        return refused(Problem::new(&SLUGS.sidecar_unauthorized)).into_response();
751    };
752    // A gateway token minted under the *admin* purpose is the host's
753    // assertion that this request already passed the host's admin
754    // authorization (issue #131). The caller's bearer stopped at the
755    // host, so the sidecar's own token — read from the sidecar's own
756    // config, never crossing the boundary — is re-materialized for the
757    // module routes behind the gate. An ordinary forwarded stamp buys
758    // nothing here: every proxied request carries one, so treating it as
759    // proof of admin would make a captured stamp an admin credential for
760    // its whole lifetime. A request that presented a bearer anyway keeps
761    // it: the comparison is constant-time either way.
762    let mut request = request;
763    if host_authorized_admin
764        && is_admin_path(&path)
765        && !request.headers().contains_key("authorization")
766        && let Some(token) = &guard.admin_token
767        && let Ok(value) = HeaderValue::from_str(&format!("Bearer {token}"))
768    {
769        request
770            .headers_mut()
771            .insert(HeaderName::from_static("authorization"), value);
772    }
773    next.run(request).await
774}