Skip to main content

cratefield_core/
harness.rs

1//! The `Harness` builder and axum router assembly (issue #2, architecture
2//! section 4).
3//!
4//! `Harness::build()` collects **all** configuration problems and reports
5//! them together; `Harness::router(ports)` mounts every module under
6//! `/v1/<name>` and adds `GET /__health` and `GET /__ready` plus the
7//! middleware stack from architecture section 6.
8
9use std::collections::BTreeSet;
10use std::collections::HashMap;
11use std::sync::Arc;
12use std::sync::atomic::{AtomicBool, Ordering};
13use std::time::Duration;
14
15use axum::Router;
16use axum::extract::{DefaultBodyLimit, State};
17use axum::http::{HeaderMap, StatusCode, header};
18use axum::middleware::{Next, from_fn_with_state};
19use axum::response::{IntoResponse, Response};
20use axum::routing::{get, post};
21use bytes::Bytes;
22use serde_json::json;
23use tracing::error;
24
25use crate::admin::require_admin;
26use crate::config::Config;
27use crate::config::ConfigError;
28use crate::events::EventBus;
29use crate::http::{
30    Json, MAX_BODY_BYTES, ScopeState, cors_layer, rate_limited, scope_layer,
31    security_headers_layer, token_response_layer,
32};
33use crate::module::{HARNESS_API, Module, ModuleContext, harness_api_mismatch};
34use crate::ports::Dispatcher;
35use crate::ports::{
36    Clock, Database, Port, Ports, RateLimiter, Statement, SystemClock, warn_undeclared_ports,
37};
38use crate::problem::Problem;
39use crate::problems::SLUGS;
40use crate::rate_limit::{RateLimit, RateLimitFailure};
41use crate::route_policy::deployed_env;
42use crate::scheduled::ScheduledBudget;
43use crate::scope::Scope;
44use crate::sidecar::{
45    GatewayGuard, SIDECAR_REQUIRE_GATEWAY, SidecarMount, X_HARNESS_GATEWAY, gateway_guard,
46    gateway_signer, mint_gateway, truthy,
47};
48use crate::signer::HmacSigner;
49use crate::surface::{
50    MAX_SIDECAR_SURFACE_BYTES, RenderedSurface, SurfaceDocument, SurfaceSource, UiContext, UiMount,
51    sanitize_sidecar_document, strip_unguarded_captcha_actions,
52};
53use crate::template::{Template, TemplateRegistry};
54use crate::venture::{Venture, VentureEnv};
55
56/// The two checks about a module's tables: that nobody else claimed one, and
57/// that its personal-data declarations can mean something.
58///
59/// Extracted from `HarnessBuilder::build`, which sat one line under the
60/// workspace's function-length lint before this. Collecting one more category
61/// of problem should not be what makes that function too long to follow, and
62/// the two checks read better together anyway: both are about the tables the
63/// module just claimed, and the second is only meaningful against the first.
64fn check_tables(
65    module: &dyn Module,
66    tables: &mut HashMap<&'static str, &'static str>,
67    errors: &mut ConfigError,
68) {
69    let name = module.name();
70    for table in module.tables() {
71        match tables.get(table) {
72            Some(owner) => errors.push(format!(
73                "duplicate table `{table}` claimed by modules `{owner}` and `{name}`"
74            )),
75            None => {
76                tables.insert(table, name);
77            }
78        }
79    }
80
81    // Against the tables the module just claimed, so a rename that misses a
82    // declaration is a build error rather than a row that quietly stops being
83    // exported.
84    let owns = module.tables();
85    let mut declared: Vec<&'static str> = Vec::new();
86    for set in module.personal_data() {
87        for problem in set.validate(name, owns) {
88            errors.push(problem);
89        }
90        if declared.contains(&set.table) {
91            errors.push(format!(
92                "module `{name}` declares table `{}` twice in personal_data()",
93                set.table
94            ));
95        }
96        declared.push(set.table);
97    }
98}
99
100/// A runtime resolves environment bindings into [`Ports`] and declares
101/// statically which ports it can provide, so `Harness::build` can reject a
102/// module that requires something the runtime will never hand it
103/// (ADR 0002). Reference implementation: `cratefield-runtime-cloudflare`.
104pub trait Runtime: Send + Sync + 'static {
105    fn provides(&self) -> Vec<Port>;
106    /// Whether the named port is not merely present but usable for its
107    /// production duty (issue #133): a captcha adapter that reports
108    /// itself unbound cannot protect a `HumanForm` route, so the runtime
109    /// that wraps it says "no" here even though `provides()` lists the
110    /// port. Default: presence in `provides()`.
111    fn effectively_configured(&self, port: Port) -> bool {
112        self.provides().contains(&port)
113    }
114}
115
116/// A built harness: immutable after `build()`.
117pub struct Harness {
118    venture: Arc<Venture>,
119    modules: Vec<Arc<dyn Module>>,
120    templates: Arc<TemplateRegistry>,
121    events: EventBus,
122    runtime: Option<Arc<dyn Runtime>>,
123    /// The single module-provided `/.well-known` router, if any
124    /// (issue #46); nested at the root by `router()`.
125    well_known: Option<Router>,
126    /// The composed UI surface (ADR 0010), rendered once for
127    /// `GET /__surface`: the admin variant and the public subset.
128    surface: Arc<SurfaceVariants>,
129    /// The renderer mounted at `/ui`, if the venture chose one.
130    ui: Option<Arc<dyn UiMount>>,
131    /// Every module's personal-data declarations, composed once at build so a
132    /// request does not walk the module list to answer an export.
133    personal_data: Arc<crate::PersonalDataCatalog>,
134    /// Whether the boot has already recorded the operator's
135    /// `HARNESS_ALLOW_UNPROTECTED_WRITES` acceptance (issue #143), and the
136    /// `HARNESS_ALLOW_UNLIMITED_PUBLIC_ROUTES` one (issue #437). Workers
137    /// rebuild the router per request, so the recording has to gate itself:
138    /// one line per waiver, the first time the gate serves on it — not one
139    /// per request. Per-`Harness` rather than a process static, which is the
140    /// same span in every real deployment (the harness is built once per
141    /// isolate) and keeps tests and multi-venture hosts from suppressing
142    /// each other's records.
143    unprotected_acceptance_recorded: AtomicBool,
144    unlimited_acceptance_recorded: AtomicBool,
145}
146
147struct SurfaceVariants {
148    document: Arc<SurfaceDocument>,
149    full: RenderedSurface,
150    public: RenderedSurface,
151}
152
153impl SurfaceVariants {
154    fn compose(
155        venture: &Venture,
156        modules: &[Arc<dyn Module>],
157        ui: Option<&Arc<dyn UiMount>>,
158    ) -> Self {
159        let mut document = SurfaceDocument::compose(venture, modules);
160        document.ui = ui.and_then(|ui| ui.describe());
161        Self {
162            full: RenderedSurface::render(&document),
163            public: RenderedSurface::render(&document.public()),
164            document: Arc::new(document),
165        }
166    }
167}
168
169impl std::fmt::Debug for Harness {
170    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
171        let modules: Vec<&str> = self.modules.iter().map(|m| m.name()).collect();
172        f.debug_struct("Harness")
173            .field("venture", &self.venture.name)
174            .field("modules", &modules)
175            .finish_non_exhaustive()
176    }
177}
178
179impl Harness {
180    /// Cheap shared handle: composition is `Arc` fields, so the only
181    /// sensible clone is the `Arc` itself. The reconciler hands one
182    /// `Arc` to every tenant task it fans out.
183    #[must_use]
184    pub fn clone_arc(self: &std::sync::Arc<Self>) -> std::sync::Arc<Self> {
185        std::sync::Arc::clone(self)
186    }
187    pub fn builder() -> HarnessBuilder {
188        HarnessBuilder::default()
189    }
190
191    pub fn venture(&self) -> &Arc<Venture> {
192        &self.venture
193    }
194
195    pub fn modules(&self) -> &[Arc<dyn Module>] {
196        &self.modules
197    }
198
199    /// The largest body a runtime may buffer for the route at `path`,
200    /// answerable before any byte of it has been read (issue #440).
201    ///
202    /// `DefaultBodyLimit` inside `router()` is the precise per-route
203    /// enforcer, but it fires only once the body is already resident — too
204    /// late for a runtime that must buffer into a fixed memory ceiling (a
205    /// Workers isolate). This is the coarse pre-buffer ceiling that runtime
206    /// consults instead: for a path of the form `/v1/<name>` or
207    /// `/v1/<name>/...` it is the named module's [`Module::max_body_bytes`],
208    /// and for everything else — an unknown module, `/ui/*`, `/__events`,
209    /// `/.well-known/*`, or any path outside `/v1` — [`MAX_BODY_BYTES`].
210    ///
211    /// The result is floored at [`MAX_BODY_BYTES`] even for the module
212    /// routes: a module may raise the ceiling for a route (LinkedIn's image
213    /// upload), but the runtime guard sits in front of the router, and a
214    /// module that tightened it below what the router itself accepts would
215    /// 413 working requests.
216    ///
217    /// `path` is the URL path only — what `url.path()` returns. A query
218    /// string is tolerated and ignored, and empty segments from a leading,
219    /// trailing or doubled slash are collapsed; `/v1` with no module
220    /// segment is simply the default ceiling.
221    pub fn max_body_bytes(&self, path: &str, cfg: &dyn Config) -> usize {
222        // Cut at the first `?` or `#`; a caller passing a whole URL must
223        // not have the module misidentified from its tail.
224        let path = match path.split_once(['?', '#']) {
225            Some((path, _)) => path,
226            None => path,
227        };
228        let mut segments = path.split('/').filter(|segment| !segment.is_empty());
229        if segments.next() != Some("v1") {
230            return MAX_BODY_BYTES;
231        }
232        let Some(name) = segments.next() else {
233            return MAX_BODY_BYTES;
234        };
235        self.modules
236            .iter()
237            .find(|module| module.name() == name)
238            .map_or(MAX_BODY_BYTES, |module| {
239                MAX_BODY_BYTES.max(module.max_body_bytes(cfg))
240            })
241    }
242
243    pub fn templates(&self) -> &Arc<TemplateRegistry> {
244        &self.templates
245    }
246
247    pub fn events(&self) -> &EventBus {
248        &self.events
249    }
250
251    /// Builds the context a module sees: its declared ports (view), the
252    /// config, the shared bus, templates and venture. `router()` uses this
253    /// per module; `cratefield-runtime-cloudflare` uses it for scheduled
254    /// fan-out.
255    pub fn module_context(&self, module: &dyn Module, ports: &Ports) -> ModuleContext {
256        // `venture.env` here is the environment the **deployment**
257        // declares, not the compiled default (issue #143).
258        //
259        // #143 corrected the boot gate and the surface merge and left
260        // this path alone, which is the one that decides per request. A
261        // module reading `ctx.venture.env` was getting `Development` on a
262        // Worker serving production — and `module-waitlist` reads exactly
263        // that to decide whether a missing `Captcha` port fails closed.
264        // So the rule that says "no captcha port in production is a
265        // refusal" never fired for the venture it was written for.
266        //
267        // Corrected here rather than at the call site so the field cannot
268        // lie: leaving `ctx.venture.env` looking right and being wrong is
269        // how this survived the fix that was meant to remove it.
270        let venture = self.venture_as_deployed(ports.config.as_ref());
271        ModuleContext {
272            config: Arc::clone(&ports.config),
273            ports: ports.view_for(module),
274            events: self.events_for(ports),
275            templates: Arc::clone(&self.templates),
276            venture,
277            unprotected_writes_accepted: crate::route_policy::unprotected_writes_override(
278                ports.config.as_ref(),
279            )
280            .is_some(),
281            personal_data: Arc::clone(&self.personal_data),
282            ui_mounted: self.ui.is_some(),
283            // Unbounded: the core has no invocation limits to spend. The
284            // runtimes replace this per module during scheduled fan-out
285            // (issue #537); request handlers keep this one.
286            scheduled: Arc::new(ScheduledBudget::unbounded()),
287        }
288    }
289
290    /// The sidecar mounts that apply: read from configuration, not from
291    /// the composition (ADR 0009), so the same artifact serves ventures
292    /// with and without them. A malformed table mounts nothing and is
293    /// logged; a mount that collides with an in-process module is
294    /// dropped and logged. Neither takes down the in-process modules.
295    /// This deployment's sidecar-role enforcement state (issue #131):
296    /// whether the gate is closed, the key that verifies a stamp, and the
297    /// sidecar's own admin token — which never crosses the boundary and is
298    /// only ever re-asserted for a request the host already authorized.
299    fn gateway_guard_state(ports: &Ports, gateway: Option<&Arc<HmacSigner>>) -> GatewayGuard {
300        GatewayGuard {
301            require: truthy(ports.config.as_ref(), SIDECAR_REQUIRE_GATEWAY),
302            signer: gateway.map(Arc::clone),
303            admin_token: ports
304                .config
305                .get("ADMIN_TOKEN")
306                .filter(|token| !token.is_empty()),
307        }
308    }
309
310    /// The surface this deployment serves: the composed one, plus any
311    /// mounted sidecar's public part merged in per request (issue #131).
312    fn merged_surface(
313        &self,
314        mounts: Vec<SidecarMount>,
315        ports: &Ports,
316        gateway: Option<Arc<HmacSigner>>,
317        env: VentureEnv,
318    ) -> Arc<dyn SurfaceSource> {
319        Arc::new(MergedSurface {
320            base: Arc::clone(&self.surface),
321            mounts,
322            dispatcher: ports.dispatcher.clone(),
323            gateway,
324            env,
325            captcha_configured: ports.captcha.is_some(),
326        })
327    }
328
329    /// The venture descriptor with the environment the **deployment**
330    /// declares (issue #143), which is what anything reporting or
331    /// enforcing on it must read. Returns the original `Arc` untouched
332    /// when the two already agree, which is every venture that sets its
333    /// environment honestly in code.
334    fn venture_as_deployed(&self, config: &dyn Config) -> Arc<Venture> {
335        let env = deployed_env(self.venture.env, config);
336        if env == self.venture.env {
337            return Arc::clone(&self.venture);
338        }
339        let mut deployed = (*self.venture).clone();
340        deployed.env = env;
341        Arc::new(deployed)
342    }
343
344    /// Nests each in-process module under its `/v1/<name>` prefix.
345    /// Split out of [`Harness::router`] so that method stays readable.
346    /// Layered in [`Harness::router`], not here: a layer applied inside
347    /// this function would wrap only the module routes, and the sidecar
348    /// prefixes nested after it would sit outside the admin floor.
349    fn nest_modules(&self, ports: &Ports) -> Router {
350        let mut api = Router::new();
351        for module in &self.modules {
352            let ctx = self.module_context(module.as_ref(), ports);
353            api = api.nest(&format!("/v1/{}", module.name()), module.router(ctx));
354        }
355        api
356    }
357
358    /// The production readiness verdict for the environment this
359    /// deployment actually runs in (issue #143).
360    ///
361    /// `HarnessBuilder::build` already ran this against the **compiled**
362    /// `Venture::env`. When the deployment declares a stricter one that
363    /// check was made against the wrong environment, so it is re-made
364    /// here against the same runtime report — the answer must not depend
365    /// on whether anyone remembered to call `.env()`.
366    ///
367    /// The limiter leg is decided here against the **resolved** port, not
368    /// the runtime's advertisement (issue #437): a Cloudflare binding that
369    /// fails to resolve degrades to `ports.rate_limiter == None`, and this
370    /// is the check that catches it. The signer leg likewise (issue #478):
371    /// a harness configuration that fails to parse leaves `ports.signer ==
372    /// None` behind an advertised `Port::Signer`.
373    fn production_readiness_now(
374        &self,
375        env: VentureEnv,
376        config: &dyn Config,
377        rate_limiter_ready: bool,
378        signer_ready: bool,
379    ) -> Vec<String> {
380        if let Some(note) = crate::route_policy::env_disagreement(self.venture.env, env) {
381            tracing::warn!("{note}");
382            crate::logging::forward_control_event(crate::logging::ControlLevel::Warn, &note);
383        }
384        // Each escape hatch is read here — in the serving path — and
385        // passed to its own leg, so a waiver covers exactly the control it
386        // names and refuses alongside whatever remains unwaived.
387        let unprotected = crate::route_policy::unprotected_writes_override(config);
388        let unlimited = crate::route_policy::unlimited_public_routes_override(config);
389        let guards = crate::route_policy::WriteGuards::collect(&self.modules);
390        let mut problems = crate::route_policy::production_readiness(
391            env,
392            &guards,
393            self.runtime.as_ref(),
394            rate_limiter_ready,
395            signer_ready,
396            unprotected.as_deref(),
397            unlimited.as_deref(),
398        );
399        // The webhook-secret leg is boot-time-only (issue #533): it reads
400        // the deployment config, which the build-time gate does not have.
401        // It has no waiver, so it is held out of the loop below — that loop
402        // names the escape hatch, which clears nothing here.
403        let hmac_problems = crate::route_policy::webhook_secret_readiness(env, &guards, config);
404        if problems.is_empty() && hmac_problems.is_empty() {
405            // An operator may accept this deployment's gaps explicitly,
406            // and the acceptance is recorded rather than discarded
407            // (issue #143) — once, even though Workers rebuild this
408            // router per request (issue #437).
409            // What the gate WOULD have refused, asked for again with no
410            // waivers: the waived text is never pushed, so this is the only
411            // way to name the problems the operator accepted — and #441's
412            // record is only accountable if it carries them.
413            let accepted = crate::route_policy::production_readiness(
414                env,
415                &guards,
416                self.runtime.as_ref(),
417                rate_limiter_ready,
418                signer_ready,
419                None,
420                None,
421            );
422            self.record_acceptances_once(
423                &guards,
424                rate_limiter_ready,
425                unprotected.as_deref(),
426                unlimited.as_deref(),
427                &accepted,
428            );
429            return problems;
430        }
431        // No blanket escape hatch here any more. Each waiver is passed
432        // into `production_readiness` above and applied to the leg it
433        // names, so what comes back is already only the UNWAIVED problems
434        // — clearing them all on `unprotected_writes_override` would let a
435        // waiver for one control excuse another, which is exactly what
436        // `a_refusing_deployment_records_no_acceptance_at_all` forbids: a
437        // captcha waiver must not serve a venture with no rate limiter.
438        for problem in &problems {
439            let detail = format!(
440                "refusing guarded routes: {problem} — set {} to a reason to accept this \
441                 explicitly while the port is wired",
442                crate::route_policy::ALLOW_UNPROTECTED_WRITES
443            );
444            error!("{detail}");
445            crate::logging::forward_control_event(crate::logging::ControlLevel::Error, &detail);
446        }
447        problems.extend(hmac_problems);
448        problems
449    }
450
451    /// Records an operator's escape-hatch acceptance exactly once, next to
452    /// the boot decision that served on it (issue #143, #437). Each guard
453    /// below is the readiness leg it mirrors minus the waiver clause, so a
454    /// waiver is recorded only when it actually removed a refusal — not
455    /// when the port was fine all along.
456    fn record_acceptances_once(
457        &self,
458        guards: &crate::route_policy::WriteGuards,
459        rate_limiter_ready: bool,
460        unprotected: Option<&str>,
461        unlimited: Option<&str>,
462        accepted: &[String],
463    ) {
464        if guards.needs_captcha()
465            && !crate::route_policy::captcha_effective(self.runtime.as_ref())
466            && let Some(reason) = unprotected
467            && !self
468                .unprotected_acceptance_recorded
469                .swap(true, Ordering::Relaxed)
470        {
471            let detail = format!(
472                "serving guarded routes unprotected on an operator's recorded acceptance \
473                 (reason: {reason}; problems: {})",
474                accepted.join("; ")
475            );
476            tracing::warn!(
477                control = "production-readiness",
478                acceptance = crate::route_policy::ALLOW_UNPROTECTED_WRITES,
479                reason,
480                "serving captcha-guarded routes unprotected on an operator's recorded acceptance"
481            );
482            // Forwarded, not just traced (issue #441): on wasm the tracing
483            // event goes nowhere, and an acceptance nobody can read is an
484            // acceptance nobody answers for.
485            crate::logging::forward_control_event(crate::logging::ControlLevel::Warn, &detail);
486        }
487        if guards.needs_rate_limiter()
488            && !rate_limiter_ready
489            && let Some(reason) = unlimited
490            && !self
491                .unlimited_acceptance_recorded
492                .swap(true, Ordering::Relaxed)
493        {
494            let detail = format!(
495                "serving public writes and admin routes without a rate limiter on an \
496                 operator's recorded acceptance (reason: {reason}; problems: {})",
497                accepted.join("; ")
498            );
499            tracing::warn!(
500                control = "production-readiness",
501                acceptance = crate::route_policy::ALLOW_UNLIMITED_PUBLIC_ROUTES,
502                reason,
503                "serving public writes and admin routes without a rate limiter on an \
504                 operator's recorded acceptance"
505            );
506            // Forwarded for the same reason as the leg above (issue #441).
507            crate::logging::forward_control_event(crate::logging::ControlLevel::Warn, &detail);
508        }
509    }
510
511    /// Nests one forwarding router per mounted sidecar (issue #131).
512    /// Split out of [`Harness::router`] so that method stays readable.
513    fn nest_sidecars(
514        mut api: Router,
515        mounted: &[SidecarMount],
516        ports: &Ports,
517        gateway: Option<&Arc<HmacSigner>>,
518    ) -> Router {
519        for mount in mounted {
520            api = api.nest(
521                &format!("/v1/{}", mount.name),
522                crate::sidecar::router(
523                    mount.clone(),
524                    ports.dispatcher.clone(),
525                    Arc::clone(&ports.config),
526                    ports.rate_limiter.clone(),
527                    gateway.map(Arc::clone),
528                ),
529            );
530        }
531        api
532    }
533
534    fn sidecar_mounts(&self, ports: &Ports) -> Vec<SidecarMount> {
535        let mounts = match crate::sidecar::SidecarMounts::from_config(ports.config.as_ref()) {
536            Ok(mounts) => mounts,
537            Err(errors) => {
538                for error in errors {
539                    let detail = format!("ignoring the sidecar mount table: {error}");
540                    tracing::error!(error, "ignoring the sidecar mount table");
541                    // A dropped mount quietly unmounts a service (issue
542                    // #441): the operator must be able to read why.
543                    crate::logging::forward_control_event(
544                        crate::logging::ControlLevel::Error,
545                        &detail,
546                    );
547                }
548                crate::sidecar::SidecarMounts::default()
549            }
550        };
551        let module_names: Vec<&str> = self.modules.iter().map(|m| m.name()).collect();
552        for collision in mounts.collisions(&module_names) {
553            let detail = format!("ignoring the colliding sidecar mount: {collision}");
554            tracing::error!(error = collision, "ignoring the colliding sidecar mount");
555            crate::logging::forward_control_event(crate::logging::ControlLevel::Error, &detail);
556        }
557        mounts
558            .iter()
559            .filter(|m| !module_names.contains(&m.name.as_str()))
560            .cloned()
561            .collect()
562    }
563
564    /// [`Harness::sidecar_mounts`] without the logging: `events_for` runs
565    /// per module per router, and re-logging the same malformed table once
566    /// per module would turn one operator error into a page of noise.
567    fn resolved_mounts(&self, ports: &Ports) -> Vec<SidecarMount> {
568        let mounts =
569            crate::sidecar::SidecarMounts::from_config(ports.config.as_ref()).unwrap_or_default();
570        let module_names: Vec<&str> = self.modules.iter().map(|m| m.name()).collect();
571        mounts
572            .iter()
573            .filter(|m| !module_names.contains(&m.name.as_str()))
574            .cloned()
575            .collect()
576    }
577
578    /// The bus a module sees: the handlers collected at `build`, plus —
579    /// when this deployment mounts sidecars — the forwarder that carries
580    /// every emission across the boundary (issue #62, ADR 0017). Built
581    /// here rather than in `build` because only `router(ports)` has the
582    /// resolved `Dispatcher` and mount table; that is also the choice that
583    /// keeps `Scope` and every handler signature untouched, so the
584    /// contract version does not move.
585    fn events_for(&self, ports: &Ports) -> EventBus {
586        let mounts = self.resolved_mounts(ports);
587        if mounts.is_empty() {
588            return self.events.clone();
589        }
590        let Some(dispatcher) = ports.dispatcher.clone() else {
591            return self.events.clone();
592        };
593        let gateway = gateway_signer(
594            ports.config.as_ref(),
595            ports.clock.clone().unwrap_or_else(|| Arc::new(SystemClock)),
596        );
597        self.events
598            .clone()
599            .forwarding_to(crate::sidecar::EventForwarder::new(
600                mounts, dispatcher, gateway,
601            ))
602    }
603
604    /// The venture's UI plane, if it declared one. Lifted out of
605    /// [`Harness::router`] only because adding `/__events` to that function
606    /// put it over the line limit, and a mechanical extraction is a better
607    /// answer than an `allow` on the lint that noticed.
608    fn ui_router(
609        &self,
610        api: &Router,
611        ports: &Ports,
612        surface: &Arc<dyn SurfaceSource>,
613    ) -> Option<Router> {
614        self.ui.as_ref().map(|ui| {
615            ui.router(UiContext {
616                surface: Arc::clone(surface),
617                api: api.clone(),
618                config: Arc::clone(&ports.config),
619                venture: Arc::clone(&self.venture),
620                captcha_configured: ports.captcha.is_some(),
621                signer: ports.signer.clone(),
622                rate_limiter: ports.rate_limiter.clone(),
623            })
624            .layer(DefaultBodyLimit::max(MAX_BODY_BYTES))
625        })
626    }
627
628    /// The sidecar half of event forwarding (issue #62, ADR 0017):
629    /// `POST /__events`, delivering into this deployment's **own** bus.
630    ///
631    /// Deliberately not the forwarding bus from [`Harness::events_for`]. A
632    /// deployment can be both a host and, to someone above it, a sidecar;
633    /// delivering an inbound event through a bus that forwards would re-post
634    /// it to this deployment's own mounts, and two deployments that mount
635    /// each other would loop with nothing able to detect it.
636    ///
637    /// `None` without a gateway secret: absent is safer than open. Without
638    /// the shared secret there is no way to tell the host's forward from
639    /// anyone else's `POST`, and an unauthenticated event trigger would let
640    /// a stranger forge the payloads in-process handlers act on.
641    fn inbound_events_route(&self, gateway: Option<Arc<HmacSigner>>) -> Option<Router> {
642        gateway.as_ref()?;
643        Some(
644            Router::new()
645                .route(
646                    "/__events",
647                    post(crate::sidecar::events_inbound)
648                        .layer(DefaultBodyLimit::max(MAX_BODY_BYTES)),
649                )
650                .with_state(Arc::new(crate::sidecar::InboundEvents {
651                    bus: self.events.clone(),
652                    gateway,
653                })),
654        )
655    }
656
657    /// The runtime this harness was validated against, if one was supplied.
658    pub fn runtime(&self) -> Option<&Arc<dyn Runtime>> {
659        self.runtime.as_ref()
660    }
661
662    /// The composed UI surface, admin actions included, for tooling
663    /// (`fz`, the control plane). `GET /__surface` serves the same
664    /// document, public subset unless the admin bearer is presented.
665    #[must_use]
666    pub fn surface(&self) -> &SurfaceDocument {
667        &self.surface.document
668    }
669
670    /// Assembles the full router: each module nested under `/v1/<name>`,
671    /// the one `/.well-known` router (if any) nested at the root,
672    /// `GET /__health`, `GET /__ready`, `GET /__surface`, the UI renderer
673    /// at `/ui` when one is mounted, and the shared middleware
674    /// (request-id/Scope, CORS allowlist, 64 KiB body limit, `/v1/*`
675    /// security headers, no-store headers for any request carrying a
676    /// `token` query parameter — issue #135). Nothing but `/.well-known`,
677    /// `/ui` and the `/__*` probes is ever mounted at the root.
678    /// What `/__health` answers from.
679    ///
680    /// Extracted from [`Harness::router`] only because that function is
681    /// at the line limit; it is one expression and it belongs to the
682    /// router's assembly.
683    fn health_state(&self, ports: &Ports, sidecars: Vec<SidecarMount>) -> HealthState {
684        HealthState {
685            // The deployment's environment, not the compiled default:
686            // `cratefield-waitlist` answered `"env":"development"` while
687            // serving production, which is the same lie #143 removed from
688            // the gates and left here.
689            venture: self.venture_as_deployed(ports.config.as_ref()),
690            modules: self.modules.clone(),
691            harness_build: ports.config.get("HARNESS_BUILD").filter(|b| !b.is_empty()),
692            mailer_configured: ports.mailer.is_some(),
693            captcha_configured: ports.captcha.is_some(),
694            sidecars,
695            dispatcher: ports.dispatcher.clone(),
696            clock: ports.clock.clone().unwrap_or_else(|| Arc::new(SystemClock)),
697            probe_cache: new_probe_cache(),
698        }
699    }
700
701    pub fn router(&self, ports: Ports) -> Router {
702        // The environment the *deployment* declares, not only the one
703        // compiled in (issue #143). The limiter and signer legs read the
704        // resolved ports, not the runtime's advertisement (issues #437,
705        // #478) — captured before `ports` is destructured below.
706        let env = deployed_env(self.venture.env, ports.config.as_ref());
707        let (limiter, signer) = (ports.rate_limiter.is_some(), ports.signer.is_some());
708        let readiness = self.production_readiness_now(env, ports.config.as_ref(), limiter, signer);
709        let mut api = self.nest_modules(&ports);
710        // The gateway signer is this deployment's half of the sidecar
711        // trust boundary (issue #131): absent secret, absent capability.
712        let gateway = gateway_signer(
713            ports.config.as_ref(),
714            ports.clock.clone().unwrap_or_else(|| Arc::new(SystemClock)),
715        );
716        let mounted = self.sidecar_mounts(&ports);
717        api = Self::nest_sidecars(api, &mounted, &ports, gateway.as_ref());
718        let gateway_state = Self::gateway_guard_state(&ports, gateway.as_ref());
719        let mounts_for_health = mounted.clone();
720        // Captured before `ports` is destructured below.
721        let tenant_layer = Arc::new(TenantLayer {
722            routing: ports.tenants.clone(),
723            fallback: ports.db.clone(),
724        });
725        // The harness is the floor of the admin plane's abuse control
726        // (issue #437): a module that forgets to limit its own `/admin`
727        // routes still gets the budget, namespaced under `admin:` so a
728        // guess at the admin bearer cannot share — or exhaust — a public
729        // route's. Modules that limit as well are double-limited, which
730        // is the floor's cost and not a bug. Applied here — after the
731        // module *and* sidecar nests — because a layer only wraps the
732        // routes present when it is applied: applied inside either nest,
733        // the other's `/admin/*` paths would sit outside the floor.
734        // Innermost of the four, so it spends budget only on requests
735        // that survived tenant resolution.
736        // Resolution last before the module routes, and on them only
737        // (TENANT-ROUTING.md §3): `/__health`, `/__ready`, `/.well-known`
738        // and `/ui` have no tenant, and resolving there would 404 every
739        // liveness probe in production.
740        let api = api
741            .layer(from_fn_with_state(
742                ports.rate_limiter.clone(),
743                admin_rate_limit_layer,
744            ))
745            .layer(axum::middleware::from_fn_with_state(
746                tenant_layer,
747                resolve_tenant_layer,
748            ))
749            .layer(axum::middleware::from_fn(security_headers_layer))
750            .layer(DefaultBodyLimit::max(MAX_BODY_BYTES));
751
752        let surface_source = self.merged_surface(mounted, &ports, gateway.clone(), env);
753
754        let ui = self.ui_router(&api, &ports, &surface_source);
755
756        let health_state = self.health_state(&ports, mounts_for_health);
757        let Ports {
758            config,
759            db,
760            auth: _,
761            mailer: _,
762            captcha: _,
763            rate_limiter: _,
764            signer: _,
765            kv: _,
766            blob: _,
767            push: _,
768            payments: _,
769            tracker: _,
770            realtime: _,
771            text_model: _,
772            classifier: _,
773            http: _,
774            clock,
775            id_gen,
776            defer,
777            dispatcher: _,
778            tenants: _,
779        } = ports;
780
781        let scope_state = ScopeState {
782            defer: defer.unwrap_or_else(|| Arc::new(crate::ports::NoopDefer)),
783            id_gen: id_gen.unwrap_or_else(|| Arc::new(crate::ports::UlidIdGen)),
784            // The identity stamp names a module only where there is exactly
785            // one to name: the sidecar role (issue #61).
786            module: (self.modules.len() == 1).then(|| self.modules[0].name()),
787        };
788        let ready_state = ReadyState {
789            db,
790            clock: clock.unwrap_or_else(|| Arc::new(SystemClock)),
791        };
792
793        let surface_state = SurfaceState {
794            config,
795            source: surface_source,
796        };
797
798        let root = Router::new()
799            .route("/__health", get(health_handler))
800            .with_state(health_state)
801            .route("/__ready", get(ready_handler))
802            .with_state(ready_state)
803            .route("/__surface", get(surface_handler))
804            .with_state(surface_state)
805            // Empty without a gateway secret, and merging an empty router
806            // adds no route — see `inbound_events_route` for why absent.
807            .merge(
808                self.inbound_events_route(gateway.clone())
809                    .unwrap_or_default(),
810            )
811            .merge(api);
812        let root = match &self.well_known {
813            Some(well_known) => root.nest(
814                "/.well-known",
815                well_known
816                    .clone()
817                    .layer(DefaultBodyLimit::max(MAX_BODY_BYTES)),
818            ),
819            None => root,
820        };
821        let root = match ui {
822            Some(ui) => root.nest("/ui", ui),
823            None => root,
824        };
825
826        // Innermost layer: the gate runs after `Scope`, so refusals carry
827        // the request id, and it wraps every route — nested `/v1/*`, `/ui`
828        // and `/__surface` alike (issue #131). Tower order: the layer
829        // applied first is the innermost, so this line must come before
830        // `scope_layer` in the chain.
831        // Innermost of all: a deployment that declares production and
832        // cannot satisfy its own declared abuse controls refuses the
833        // guarded routes rather than serving them unprotected (#143).
834        root.layer(from_fn_with_state(
835            Arc::new(readiness),
836            production_readiness_guard,
837        ))
838        .layer(from_fn_with_state(gateway_state, gateway_guard))
839        .layer(from_fn_with_state(scope_state, scope_layer))
840        .layer(axum::middleware::from_fn(token_response_layer))
841        .layer(cors_layer(&self.venture.cors_origins))
842    }
843}
844
845/// What one `/__health` sidecar fan-out produced: when it ran (epoch ms)
846/// and one entry per mount.
847type ProbeCache = Option<(i64, Vec<serde_json::Value>)>;
848
849/// The fresh cache every router starts with. The shared mutex lives in one
850/// place so the scoped allow for it has one justification to point at.
851#[allow(clippy::disallowed_types)]
852fn new_probe_cache() -> Arc<std::sync::Mutex<ProbeCache>> {
853    Arc::new(std::sync::Mutex::new(None))
854}
855
856#[derive(Clone)]
857struct HealthState {
858    venture: Arc<Venture>,
859    modules: Vec<Arc<dyn Module>>,
860    /// Git sha injected as a var by the deploy workflow (issue #14).
861    harness_build: Option<String>,
862    mailer_configured: bool,
863    captcha_configured: bool,
864    sidecars: Vec<SidecarMount>,
865    dispatcher: Option<Arc<dyn Dispatcher>>,
866    clock: Arc<dyn Clock>,
867    /// The last sidecar probe, kept for one [`SIDECAR_PROBE_TTL_SECS`]
868    /// window. The stamp on each forwarded response is the real contract
869    /// check (issue #61); this probe exists only so `/__health` can show
870    /// each sidecar's state, and caching it keeps a polling dashboard from
871    /// turning every health check into a fan-out of subrequests.
872    /// Deployment-scoped, not request state: it outlives no request, so
873    /// ADR 0007's ban on ambient request state does not apply.
874    #[allow(clippy::disallowed_types)]
875    probe_cache: Arc<std::sync::Mutex<ProbeCache>>,
876}
877
878/// How long a `/__health` sidecar listing stays fresh (issue #61). Short on
879/// purpose: the point of the listing is to notice a redeploy.
880const SIDECAR_PROBE_TTL_SECS: i64 = 30;
881
882/// Per-sidecar probe budget, same generosity the readiness probe grants
883/// the database.
884const SIDECAR_PROBE_TIMEOUT: Duration = Duration::from_secs(2);
885
886async fn health_handler(State(state): State<HealthState>) -> impl IntoResponse {
887    let modules: Vec<serde_json::Value> = state
888        .modules
889        .iter()
890        .map(|module| {
891            json!({
892                "name": module.name(),
893                "version": module.version(),
894                "requires": module.requires().iter().map(Port::name).collect::<Vec<_>>(),
895                "optional": module.optional().iter().map(Port::name).collect::<Vec<_>>(),
896                "tables": module.tables(),
897                "emits": module.emits(),
898            })
899        })
900        .collect();
901    let sidecars = probe_sidecars(&state).await;
902    Json(json!({
903        "venture": state.venture.name,
904        "env": state.venture.env.as_str(),
905        "harness_api": HARNESS_API,
906        "harness_build": state.harness_build,
907        // Port presence: the Mailer/Captcha traits carry no probe, so a
908        // NotConfigured adapter still reports its port as configured.
909        "mailer": if state.mailer_configured { "configured" } else { "not_configured" },
910        "captcha": if state.captcha_configured { "configured" } else { "absent" },
911        "modules": modules,
912        "sidecars": sidecars,
913    }))
914}
915
916/// One mount, probed. `probe` is the verdict (`ok`, `mismatch`,
917/// `unreachable`); `contract`, `module` and `version` are what the sidecar
918/// said about itself, `null` when it said nothing. A sidecar that answers
919/// a wrong contract still reports here — the operator reading `/__health`
920/// needs both numbers, and the per-request refusal is a separate story.
921async fn probe_sidecars(state: &HealthState) -> Vec<serde_json::Value> {
922    if state.sidecars.is_empty() {
923        return Vec::new();
924    }
925    let now_ms =
926        i64::try_from(state.clock.now().unix_timestamp_nanos() / 1_000_000).unwrap_or(i64::MAX);
927    if let Some((at_ms, entries)) = state.probe_cache.lock().unwrap().as_ref()
928        && now_ms - at_ms < SIDECAR_PROBE_TTL_SECS * 1000
929    {
930        return entries.clone();
931    }
932
933    let mut entries = Vec::new();
934    for mount in &state.sidecars {
935        entries.push(probe_one_sidecar(state, mount).await);
936    }
937    *state.probe_cache.lock().unwrap() = Some((now_ms, entries.clone()));
938    entries
939}
940
941async fn probe_one_sidecar(state: &HealthState, mount: &SidecarMount) -> serde_json::Value {
942    let mut missing = json!({
943        "name": mount.name,
944        "binding": mount.binding,
945        "probe": "unreachable",
946        "contract": serde_json::Value::Null,
947        "module": serde_json::Value::Null,
948        "version": serde_json::Value::Null,
949    });
950    let Some(dispatcher) = state.dispatcher.as_ref() else {
951        return missing;
952    };
953    let request = http::Request::builder()
954        .method(http::Method::GET)
955        .uri("/__health")
956        .body(Bytes::new());
957    let Ok(request) = request else {
958        return missing;
959    };
960    // `/__health` stays open on the sidecar side of the gateway (probes
961    // must probe), so no stamp is needed to reach it. The future is owned
962    // because the clock's timeout demands `'static`; the binding outlives
963    // the call only as a clone.
964    let dispatcher = Arc::clone(dispatcher);
965    let binding = mount.binding.clone();
966    let probe = async move { dispatcher.dispatch(&binding, request).await };
967    let Some(Ok(response)) =
968        crate::ports::timeout(&*state.clock, probe, SIDECAR_PROBE_TIMEOUT).await
969    else {
970        return missing;
971    };
972    let contract = response
973        .headers()
974        .get(crate::sidecar::X_HARNESS_API)
975        .and_then(|v| v.to_str().ok())
976        .and_then(|v| v.parse::<u32>().ok());
977    let body = serde_json::from_slice::<serde_json::Value>(response.body()).ok();
978    let module = response
979        .headers()
980        .get(crate::sidecar::X_HARNESS_MODULE)
981        .and_then(|v| v.to_str().ok())
982        .map(str::to_owned)
983        .or_else(|| first_module_string(body.as_ref(), "name"));
984    let version = first_module_string(body.as_ref(), "version");
985    let tables = declared_tables(body.as_ref());
986    let contract = contract.or_else(|| {
987        body.as_ref()
988            .and_then(|b| b.get("harness_api"))
989            .and_then(serde_json::Value::as_u64)
990            .and_then(|api| u32::try_from(api).ok())
991    });
992    missing["contract"] = contract.map_or(serde_json::Value::Null, |api| json!(api));
993    missing["module"] = module.map_or(serde_json::Value::Null, |name| json!(name));
994    missing["version"] = version.map_or(serde_json::Value::Null, |version| json!(version));
995    missing["tables"] = json!(tables);
996    match contract {
997        Some(api) if api != HARNESS_API => missing["probe"] = json!("mismatch"),
998        Some(_) => missing["probe"] = json!("ok"),
999        None => missing["probe"] = json!("unreachable"),
1000    }
1001    // A sidecar owns tables in the *same* database as its host, and the
1002    // build-time duplicate check (`check_tables`) cannot see it: it walks
1003    // `harness.modules()`, which a mount is not in. So the clash is caught
1004    // here, the first time the host hears what the sidecar claims, and it
1005    // is reported rather than inferred - `CREATE TABLE IF NOT EXISTS`
1006    // would otherwise have the two modules quietly sharing one table
1007    // (issue #66).
1008    //
1009    // Only a *positive* observation sets this. An unreachable sidecar
1010    // declares nothing, and "declared nothing" must never read as "clean":
1011    // that is the difference between a check and a coin flip.
1012    let clashes = collisions_with(&tables, state, &mount.name);
1013    if !clashes.is_empty() {
1014        let detail = format!(
1015            "sidecar `{}` claims table(s) {} already claimed in this deployment",
1016            mount.name,
1017            clashes.join(", ")
1018        );
1019        tracing::error!(sidecar = %mount.name, tables = %clashes.join(","), "{detail}");
1020        crate::logging::forward_internal_error(&detail);
1021        missing["probe"] = json!("table-collision");
1022        missing["table_collisions"] = json!(clashes);
1023    }
1024    missing
1025}
1026
1027/// Every table named by `modules[*].tables` in a sidecar's `/__health`
1028/// body. Absent or malformed reads as an empty set, never as "no clash" -
1029/// see the caller.
1030fn declared_tables(body: Option<&serde_json::Value>) -> Vec<String> {
1031    let Some(modules) = body
1032        .and_then(|b| b.get("modules"))
1033        .and_then(|m| m.as_array())
1034    else {
1035        return Vec::new();
1036    };
1037    let mut tables: Vec<String> = modules
1038        .iter()
1039        .filter_map(|module| module.get("tables")?.as_array())
1040        .flatten()
1041        .filter_map(|table| table.as_str().map(str::to_owned))
1042        .collect();
1043    tables.sort_unstable();
1044    tables.dedup();
1045    tables
1046}
1047
1048/// The tables `claimed` shares with a compiled-in module, reported with the
1049/// owner so the operator knows which side to rename.
1050fn collisions_with(claimed: &[String], state: &HealthState, mount: &str) -> Vec<String> {
1051    let mut clashes: Vec<String> = Vec::new();
1052    for module in &state.modules {
1053        if module.name() == mount {
1054            continue;
1055        }
1056        for table in module.tables() {
1057            if claimed.iter().any(|claimed| claimed == table)
1058                && !clashes.iter().any(|seen| seen == table)
1059            {
1060                clashes.push(format!("`{table}` (also module `{}`)", module.name()));
1061            }
1062        }
1063    }
1064    clashes
1065}
1066
1067/// A sidecar's `/__health` body names its module and version at
1068/// `modules[0]`; a stamp header takes precedence, so this is the fallback.
1069fn first_module_string(body: Option<&serde_json::Value>, key: &str) -> Option<String> {
1070    body?
1071        .get("modules")?
1072        .get(0)?
1073        .get(key)?
1074        .as_str()
1075        .map(str::to_owned)
1076}
1077
1078/// What the resolution layer needs: the deployment's tenant plane, and
1079/// the handle to fall back to when it has none.
1080struct TenantLayer {
1081    routing: Option<Arc<dyn crate::tenant::TenantRouting>>,
1082    /// The single database of a deployment without a registry. `None`
1083    /// when no database port is configured at all, which is a legitimate
1084    /// harness (a venture of pure-HTTP modules) — the tenant still
1085    /// resolves, and a handler that asks for a `TenantConn` is the thing
1086    /// that fails.
1087    fallback: Option<Arc<dyn Database>>,
1088}
1089
1090/// Resolves the request's tenant and puts it, with its database handle,
1091/// into the extensions for the `TenantConn` extractor (issue #32).
1092///
1093/// Refusals carry the request id because this runs inside `scope_layer` —
1094/// a refusal from outside the harness carries no `instance`, which is why
1095/// #143's readiness guard was pushed inside rather than left at the edge.
1096async fn resolve_tenant_layer(
1097    State(layer): State<Arc<TenantLayer>>,
1098    scope: Scope,
1099    mut request: axum::extract::Request,
1100    next: Next,
1101) -> Response {
1102    let host = request
1103        .headers()
1104        .get(header::HOST)
1105        .and_then(|value| value.to_str().ok())
1106        .unwrap_or_default()
1107        .to_owned();
1108
1109    // No tenant plane: the *no registry* deployment. Every host is the one
1110    // venture (TENANT-ROUTING.md §6), so a module is written once against
1111    // the stricter shape and the path most ventures run in production is
1112    // not the one without the isolation.
1113    let resolution = match layer.routing.as_ref() {
1114        Some(routing) => routing.resolve(&host),
1115        None => <crate::tenant::ImplicitTenant as crate::tenant::ResolveTenant>::resolve(
1116            &crate::tenant::ImplicitTenant,
1117            &host,
1118        ),
1119    };
1120
1121    let tenant = match resolution.admit() {
1122        Ok(tenant) => tenant,
1123        Err(problem) => {
1124            return Problem::new(problem)
1125                .instance(&scope.request_id)
1126                .into_response();
1127        }
1128    };
1129
1130    let db = match layer.routing.as_ref() {
1131        Some(routing) => match routing.database(&tenant).await {
1132            Ok(db) => Some(db),
1133            Err(err) => {
1134                // The registry is the only thing that knows a DSN, and
1135                // this error carries the tenant id and nothing else.
1136                tracing::error!(tenant = %tenant.id(), "{err}");
1137                crate::logging::forward_internal_error(&err.to_string());
1138                return Problem::new(&SLUGS.tenant_degraded)
1139                    .instance(&scope.request_id)
1140                    .into_response();
1141            }
1142        },
1143        None => layer.fallback.clone(),
1144    };
1145
1146    if let Some(db) = db {
1147        // Which shape resolved this tenant, carried through to the
1148        // handler: a membership rule must be able to tell a registry
1149        // deployment from a no-registry one, because the two disagree
1150        // about who a verified caller is (#385).
1151        let tenancy = if layer.routing.is_some() {
1152            crate::tenant::Tenancy::FromRegistry
1153        } else {
1154            crate::tenant::Tenancy::Sole
1155        };
1156        request
1157            .extensions_mut()
1158            .insert(crate::tenant_conn::ResolvedTenant {
1159                tenant,
1160                tenancy,
1161                db,
1162            });
1163    }
1164    next.run(request).await
1165}
1166
1167#[derive(Clone)]
1168struct ReadyState {
1169    db: Option<Arc<dyn Database>>,
1170    clock: Arc<dyn Clock>,
1171}
1172
1173/// `GET /__ready`: `SELECT 1` through the `Database` port with a 2 s
1174/// timeout supplied by the runtime's clock; 503 problem on failure
1175/// (architecture section 6).
1176async fn ready_handler(State(state): State<ReadyState>) -> impl IntoResponse {
1177    let Some(db) = state.db else {
1178        return Problem::not_ready("database port is not configured").into_response();
1179    };
1180    let stmt = Statement::new("SELECT 1");
1181    let query = async move { db.query(&stmt).await };
1182    match crate::ports::timeout(&*state.clock, query, Duration::from_secs(2)).await {
1183        Some(Ok(_rows)) => Json(json!({ "ok": true })).into_response(),
1184        Some(Err(err)) => {
1185            error!(error = %err, "readiness probe query failed");
1186            crate::logging::forward_internal_error(&format!("readiness probe query failed: {err}"));
1187            Problem::not_ready("database query failed").into_response()
1188        }
1189        None => Problem::not_ready("database did not answer within 2 s").into_response(),
1190    }
1191}
1192
1193#[derive(Clone)]
1194struct SurfaceState {
1195    config: Arc<dyn Config>,
1196    source: Arc<dyn SurfaceSource>,
1197}
1198
1199/// The build-time surface plus whatever the mounted sidecars answer
1200/// (issue #76). Sidecar surfaces are fetched on every call: a sidecar's
1201/// own `/__surface` is prerendered, the service binding runs on the same
1202/// thread (ADR 0009), and a cache here would hide a redeploy. Only the
1203/// public part of a sidecar merges: its admin routes take its own token,
1204/// which this host does not hold.
1205struct MergedSurface {
1206    base: Arc<SurfaceVariants>,
1207    mounts: Vec<SidecarMount>,
1208    dispatcher: Option<Arc<dyn Dispatcher>>,
1209    /// Mints the gateway stamp for `/__surface` fetches: the host is not
1210    /// exempt from the boundary it enforces on others (issue #131).
1211    gateway: Option<Arc<HmacSigner>>,
1212    env: VentureEnv,
1213    captcha_configured: bool,
1214}
1215
1216impl MergedSurface {
1217    /// What each mounted sidecar contributes, in mount order.
1218    async fn sidecar_modules(&self) -> Vec<crate::surface::ModuleSurface> {
1219        let mut extra = Vec::new();
1220        let Some(dispatcher) = &self.dispatcher else {
1221            return extra;
1222        };
1223        for mount in &self.mounts {
1224            if !dispatcher.has(&mount.binding) {
1225                continue;
1226            }
1227            let mut builder = axum::http::Request::builder()
1228                .method(axum::http::Method::GET)
1229                .uri("/__surface")
1230                .header(header::ACCEPT, "application/json");
1231            if let Some(signer) = &self.gateway {
1232                // A surface fetch is not an admin request: the plain purpose.
1233                builder =
1234                    builder.header(X_HARNESS_GATEWAY, mint_gateway(signer, &mount.name, false));
1235            }
1236            let request = builder
1237                .body(bytes::Bytes::new())
1238                .expect("static request builds");
1239            let answer = match dispatcher.dispatch(&mount.binding, request).await {
1240                Ok(response) if response.status().is_success() => response,
1241                Ok(response) => {
1242                    tracing::warn!(module = mount.name, status = %response.status(), "sidecar surface not available");
1243                    continue;
1244                }
1245                Err(err) => {
1246                    tracing::warn!(module = mount.name, error = %err, "sidecar surface fetch failed");
1247                    continue;
1248                }
1249            };
1250            // Cap before parsing: an unbounded document turns a merge into
1251            // a memory attack on this Worker (issue #131).
1252            if answer.body().len() > MAX_SIDECAR_SURFACE_BYTES {
1253                tracing::warn!(
1254                    module = mount.name,
1255                    "sidecar surface exceeds {MAX_SIDECAR_SURFACE_BYTES} bytes and was not merged"
1256                );
1257                continue;
1258            }
1259            match serde_json::from_slice::<SurfaceDocument>(answer.body()) {
1260                Ok(document) => match sanitize_sidecar_document(&document, &mount.name) {
1261                    Ok(entries) => extra.extend(entries.into_iter().filter_map(|mut entry| {
1262                        // A production host with no Captcha port cannot
1263                        // render the widget a merged action demands, so the
1264                        // honest surface drops it (issue #131).
1265                        if self.env == VentureEnv::Production
1266                            && !self.captcha_configured
1267                            && strip_unguarded_captcha_actions(&mut entry.surface) > 0
1268                        {
1269                            tracing::warn!(
1270                                module = entry.name,
1271                                "merged sidecar actions requiring a captcha were dropped: no Captcha port here"
1272                            );
1273                        }
1274                        (!entry.surface.is_empty()).then_some(entry)
1275                    })),
1276                    Err(problems) => {
1277                        for problem in problems {
1278                            tracing::warn!(module = mount.name, error = problem, "sidecar surface rejected at merge");
1279                        }
1280                    }
1281                },
1282                Err(err) => {
1283                    tracing::warn!(module = mount.name, error = %err, "sidecar surface is not a surface document");
1284                }
1285            }
1286        }
1287        extra
1288    }
1289}
1290
1291#[async_trait::async_trait]
1292impl SurfaceSource for MergedSurface {
1293    async fn current(&self) -> Arc<SurfaceDocument> {
1294        if self.mounts.is_empty() {
1295            return Arc::clone(&self.base.document);
1296        }
1297        let mut document = (*self.base.document).clone();
1298        document.modules.extend(self.sidecar_modules().await);
1299        Arc::new(document)
1300    }
1301
1302    fn built(&self) -> Arc<SurfaceDocument> {
1303        Arc::clone(&self.base.document)
1304    }
1305
1306    fn rendered(&self, admin: bool) -> Option<&RenderedSurface> {
1307        Some(if admin {
1308            &self.base.full
1309        } else {
1310            &self.base.public
1311        })
1312    }
1313}
1314
1315/// `GET /__surface` (ADR 0010): the composed surface, public subset by
1316/// default, admin actions included when `Authorization: Bearer
1317/// <ADMIN_TOKEN>` is valid. A wrong or stale bearer is not an error here,
1318/// it just gets the public document: this route exists to be read by
1319/// renderers and tooling, and a `403` would leak whether admin is on.
1320/// Strong `ETag` per variant; `If-None-Match` answers `304`.
1321///
1322/// Both variants share this URL, so caching is split per variant (issue
1323/// #130): the public document stays `no-cache` (shared caches may store
1324/// it but must revalidate — nothing in it is a secret), while the
1325/// authenticated document is `private, no-store` on the `200` *and* on
1326/// the `304`, because a `304` refreshes what a cache already holds.
1327/// `Vary: Authorization` alone is not enough: an intermediary that
1328/// ignores `Vary` could otherwise store the admin variant and expose it
1329/// to an unauthenticated caller.
1330async fn surface_handler(
1331    State(state): State<SurfaceState>,
1332    headers: HeaderMap,
1333) -> impl IntoResponse {
1334    let admin = require_admin(&*state.config, &headers).is_ok();
1335    // With no sidecar the source hands back the build-time Arc, and the
1336    // prerendered variants are reused; with sidecars the merged document
1337    // is rendered per request (a hash, microseconds).
1338    let current = state.source.current().await;
1339    let built = state.source.built();
1340    let prerendered = Arc::ptr_eq(&current, &built).then(|| state.source.rendered(admin));
1341    let fresh;
1342    let rendered: &RenderedSurface = if let Some(rendered) = prerendered.flatten() {
1343        rendered
1344    } else {
1345        fresh = if admin {
1346            RenderedSurface::render(&current)
1347        } else {
1348            RenderedSurface::render(&current.public())
1349        };
1350        &fresh
1351    };
1352    let matches = headers
1353        .get(header::IF_NONE_MATCH)
1354        .and_then(|value| value.to_str().ok())
1355        .is_some_and(|value| {
1356            value
1357                .split(',')
1358                .map(str::trim)
1359                .any(|tag| tag == "*" || tag == rendered.etag)
1360        });
1361    let mut response = if matches {
1362        StatusCode::NOT_MODIFIED.into_response()
1363    } else {
1364        (
1365            [(header::CONTENT_TYPE, "application/json")],
1366            rendered.json.clone(),
1367        )
1368            .into_response()
1369    };
1370    let response_headers = response.headers_mut();
1371    response_headers.insert(
1372        header::ETAG,
1373        header::HeaderValue::from_str(&rendered.etag).expect("hex etag is a valid header"),
1374    );
1375    response_headers.insert(
1376        header::CACHE_CONTROL,
1377        if admin {
1378            header::HeaderValue::from_static("private, no-store")
1379        } else {
1380            header::HeaderValue::from_static("no-cache")
1381        },
1382    );
1383    response_headers.insert(
1384        header::VARY,
1385        header::HeaderValue::from_static("Authorization"),
1386    );
1387    response
1388}
1389
1390/// Builder: `.venture(..)`, `.module(..)`, `.runtime(..)`, `.template(..)`,
1391/// then `.build()`.
1392#[derive(Default)]
1393pub struct HarnessBuilder {
1394    venture: Option<Venture>,
1395    modules: Vec<Arc<dyn Module>>,
1396    provides: Vec<Port>,
1397    runtime: Option<Arc<dyn Runtime>>,
1398    module_templates: Vec<(String, Box<dyn Template>)>,
1399    overrides: Vec<(String, Box<dyn Template>)>,
1400    ui: Option<Arc<dyn UiMount>>,
1401}
1402
1403impl HarnessBuilder {
1404    #[must_use]
1405    pub fn venture(mut self, venture: Venture) -> Self {
1406        self.venture = Some(venture);
1407        self
1408    }
1409
1410    /// Adds a module. Composition is compile-time: the wasm binary contains
1411    /// exactly the modules listed here (ADR 0003).
1412    #[must_use]
1413    pub fn module(mut self, module: impl Module) -> Self {
1414        self.modules.push(Arc::new(module));
1415        self
1416    }
1417
1418    /// Adds an already-shared module (`cratefield-testing` keeps handles to
1419    /// apply migrations and run conformance).
1420    #[must_use]
1421    pub fn module_arc(mut self, module: Arc<dyn Module>) -> Self {
1422        self.modules.push(module);
1423        self
1424    }
1425
1426    /// Declares the runtime: its `provides()` set drives build-time
1427    /// checking of every module's `requires()`. The runtime is kept on the
1428    /// built harness for tooling (`fz doctor`, scheduled fan-out).
1429    #[must_use]
1430    pub fn runtime(mut self, runtime: impl Runtime) -> Self {
1431        let runtime: Arc<dyn Runtime> = Arc::new(runtime);
1432        self.provides = runtime.provides();
1433        self.runtime = Some(runtime);
1434        self
1435    }
1436
1437    /// Registers module default templates (`<module>/<template>` ids).
1438    /// Call before overrides; see `template.rs` for the convention.
1439    #[must_use]
1440    pub fn templates(
1441        mut self,
1442        templates: impl IntoIterator<Item = (String, Box<dyn Template>)>,
1443    ) -> Self {
1444        self.module_templates.extend(templates);
1445        self
1446    }
1447
1448    /// Mounts a UI renderer at `/ui` (ADR 0010): `cratefield_ui::Ui`. Off
1449    /// unless called, so a venture without a UI serves nothing there.
1450    #[must_use]
1451    pub fn ui(mut self, ui: impl UiMount) -> Self {
1452        self.ui = Some(Arc::new(ui));
1453        self
1454    }
1455
1456    /// Venture template override. Wins over any module default with the
1457    /// same id; the id's module part must name a registered module.
1458    #[must_use]
1459    pub fn template(mut self, id: impl Into<String>, template: Box<dyn Template>) -> Self {
1460        self.overrides.push((id.into(), template));
1461        self
1462    }
1463
1464    /// Validates everything, collecting **all** problems before failing
1465    /// (issue #2).
1466    ///
1467    /// # Errors
1468    ///
1469    /// `Err` whose `Display` lists every problem: invalid venture, unknown
1470    /// or duplicated port declarations, duplicate module names, tables or
1471    /// `/.well-known` routers, `harness_api` mismatches, invalid UI
1472    /// surfaces, unprovided required ports, and template ids naming
1473    /// unregistered modules.
1474    pub fn build(self) -> Result<Harness, ConfigError> {
1475        let mut errors = ConfigError::default();
1476
1477        let venture = if let Some(venture) = self.venture {
1478            venture.validate(&mut errors);
1479            venture
1480        } else {
1481            errors.push("missing venture: call .venture(Venture::new(..)) before .build()");
1482            Venture::new("invalid", "invalid.invalid")
1483        };
1484
1485        let mut names: HashMap<&'static str, usize> = HashMap::new();
1486        let mut tables: HashMap<&'static str, &'static str> = HashMap::new();
1487
1488        for module in &self.modules {
1489            if module.harness_api() != HARNESS_API {
1490                errors.push(harness_api_mismatch(module.as_ref()));
1491            }
1492
1493            let name = module.name();
1494            if name.is_empty() || !is_module_name(name) {
1495                errors.push(format!(
1496                    "module name `{name}` must be kebab-case ([a-z0-9]+ separated by '-')"
1497                ));
1498            }
1499            if names.insert(name, 1).is_some() {
1500                errors.push(format!("duplicate module name `{name}`"));
1501            }
1502
1503            for port in module.requires().iter().chain(module.optional()) {
1504                if !Port::ALL.contains(port) {
1505                    errors.push(format!(
1506                        "module `{name}` declares unknown port {}",
1507                        port.name()
1508                    ));
1509                }
1510            }
1511            for port in module.requires() {
1512                if module.optional().contains(port) {
1513                    errors.push(format!(
1514                        "module `{name}` lists port {} in both requires() and optional()",
1515                        port.name()
1516                    ));
1517                }
1518            }
1519
1520            module.surface().validate(name, &mut errors);
1521
1522            check_tables(module.as_ref(), &mut tables, &mut errors);
1523        }
1524
1525        let well_known = collect_well_known(&self.modules, &mut errors);
1526
1527        for module in &self.modules {
1528            for port in module.requires() {
1529                if !self.provides.contains(port) {
1530                    errors.push(format!(
1531                        "module `{}` requires port {} which the runtime does not provide",
1532                        module.name(),
1533                        port.name()
1534                    ));
1535                }
1536            }
1537            warn_undeclared_ports(module.as_ref(), &self.provides);
1538        }
1539
1540        let ordered = resolve_dependency_order(&self.modules, &mut errors);
1541
1542        append_production_readiness(&venture, &self.modules, self.runtime.as_ref(), &mut errors);
1543
1544        check_template_ids(
1545            self.overrides.iter().chain(self.module_templates.iter()),
1546            &names,
1547            &mut errors,
1548        );
1549
1550        let surface = Arc::new(SurfaceVariants::compose(
1551            &venture,
1552            &self.modules,
1553            self.ui.as_ref(),
1554        ));
1555        if let Some(ui) = &self.ui {
1556            ui.validate(&surface.document, &mut errors);
1557        }
1558
1559        errors.into_result()?;
1560
1561        let mut registry = TemplateRegistry::new();
1562        registry.register_all(self.module_templates);
1563        registry.register_all(self.overrides);
1564
1565        let mut events = EventBus::new();
1566        for module in &self.modules {
1567            for (name, handler) in module.events() {
1568                events = events.on(name, handler);
1569            }
1570        }
1571
1572        Ok(Harness {
1573            venture: Arc::new(venture),
1574            unprotected_acceptance_recorded: AtomicBool::new(false),
1575            unlimited_acceptance_recorded: AtomicBool::new(false),
1576            // Dependency order, which is composition order until a module
1577            // declares something (RECONCILIATION.md §2).
1578            // Composed from the resolved order, so the catalog reads the way
1579            // the schema applies: a reader tracing an export down the list
1580            // meets a table before the tables that point at it.
1581            personal_data: Arc::new(crate::PersonalDataCatalog::compose(
1582                ordered.iter().map(|m| (m.name(), m.personal_data())),
1583            )),
1584            modules: ordered,
1585            templates: Arc::new(registry),
1586            events,
1587            runtime: self.runtime,
1588            well_known,
1589            surface,
1590            ui: self.ui,
1591        })
1592    }
1593}
1594
1595/// A template id's module part must name a registered module.
1596/// Orders modules so that every module follows the ones it declares in
1597/// [`Module::depends_on`] (RECONCILIATION.md §2), reporting an unknown
1598/// dependency or a cycle as a build error rather than a boot one.
1599///
1600/// The sort is **stable**: modules with no dependency between them keep
1601/// composition order. With nothing declared anywhere — which is every
1602/// venture today — the result is the input, so adopting this changes no
1603/// ordering that already exists.
1604///
1605/// Returns composition order when the graph is unusable, so `build`
1606/// collects the rest of its errors instead of stopping at the first.
1607fn resolve_dependency_order(
1608    modules: &[Arc<dyn Module>],
1609    errors: &mut ConfigError,
1610) -> Vec<Arc<dyn Module>> {
1611    let names: BTreeSet<&str> = modules.iter().map(|module| module.name()).collect();
1612    let mut unknown = false;
1613    for module in modules {
1614        for needed in module.depends_on() {
1615            if !names.contains(needed) {
1616                unknown = true;
1617                errors.push(format!(
1618                    "module `{}` depends on `{needed}`, which this venture does not compose: \
1619                     add the module or drop the dependency",
1620                    module.name()
1621                ));
1622            }
1623        }
1624    }
1625    if unknown {
1626        return modules.to_vec();
1627    }
1628
1629    // Kahn's algorithm over composition order, which is what makes the
1630    // result stable: at each step the earliest-composed ready module is
1631    // taken, so an undeclared pair never reorders.
1632    let mut remaining: Vec<Option<Arc<dyn Module>>> = modules
1633        .iter()
1634        .map(|module| Some(Arc::clone(module)))
1635        .collect();
1636    let mut placed: BTreeSet<&str> = BTreeSet::new();
1637    let mut ordered: Vec<Arc<dyn Module>> = Vec::with_capacity(modules.len());
1638
1639    while ordered.len() < modules.len() {
1640        let mut progressed = false;
1641        for slot in &mut remaining {
1642            let ready = slot.as_ref().is_some_and(|module| {
1643                module
1644                    .depends_on()
1645                    .iter()
1646                    .all(|needed| placed.contains(needed))
1647            });
1648            if ready {
1649                let module = slot.take().expect("checked above");
1650                placed.insert(module.name());
1651                ordered.push(module);
1652                progressed = true;
1653            }
1654        }
1655        if !progressed {
1656            // Everything left is waiting on something else left: a cycle.
1657            let stuck: Vec<&str> = remaining
1658                .iter()
1659                .filter_map(|slot| slot.as_ref().map(|module| module.name()))
1660                .collect();
1661            errors.push(format!(
1662                "modules [{}] depend on each other in a cycle: migrations cannot be ordered so \
1663                 that every module follows the ones it sits on top of",
1664                stuck.join(", ")
1665            ));
1666            return modules.to_vec();
1667        }
1668    }
1669    ordered
1670}
1671
1672fn check_template_ids<'a>(
1673    ids: impl Iterator<Item = &'a (String, Box<dyn Template>)>,
1674    names: &HashMap<&'static str, usize>,
1675    errors: &mut ConfigError,
1676) {
1677    for (id, _) in ids {
1678        let Some(module_name) = id.split('/').next() else {
1679            continue;
1680        };
1681        if !names.contains_key(module_name) {
1682            errors.push(format!(
1683                "template `{id}` names module `{module_name}` which is not registered"
1684            ));
1685        }
1686    }
1687}
1688
1689fn is_module_name(name: &str) -> bool {
1690    name.split('-').all(|part| {
1691        !part.is_empty()
1692            && part
1693                .chars()
1694                .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit())
1695    })
1696}
1697
1698/// Refuses every guarded route when the deployment declares production
1699/// and cannot satisfy the abuse controls its own routes declare
1700/// (issue #143).
1701///
1702/// Fail *closed*, not fatal: a panicking Worker is an outage with no
1703/// diagnosis, so the probes and the UI stay up to say why while `/v1/*`
1704/// answers `503 not-production-ready`. `Harness::build` still refuses the
1705/// same composition outright when the venture declares its environment
1706/// honestly; this catches the deployment that did not.
1707async fn production_readiness_guard(
1708    State(problems): State<Arc<Vec<String>>>,
1709    request: axum::extract::Request,
1710    next: Next,
1711) -> Response {
1712    if problems.is_empty() || !request.uri().path().starts_with("/v1/") {
1713        return next.run(request).await;
1714    }
1715    let problem = Problem::new(&SLUGS.not_production_ready).with_detail(problems.join("; "));
1716    match request.extensions().get::<Scope>() {
1717        Some(scope) => problem.instance(&scope.request_id.clone()),
1718        None => problem,
1719    }
1720    .into_response()
1721}
1722
1723/// Limits every `/admin/*` route by client IP at the harness layer
1724/// (issue #437), whether or not the module limits its own. The keys are
1725/// prefixed `admin:` so a guess at the admin bearer draws from its own
1726/// budget, the way the auth crates namespace theirs (`auth-password:<key>`),
1727/// and no email key joins them — the caller is anonymous until it
1728/// authenticates. A limiter transport error denies: the admin token has no
1729/// captcha or cooldown behind it, so there is nothing to fail open into
1730/// (the sidecar forward reaches the same verdict for the same reason).
1731async fn admin_rate_limit_layer(
1732    State(limiter): State<Option<Arc<dyn RateLimiter>>>,
1733    request: axum::extract::Request,
1734    next: Next,
1735) -> Response {
1736    if !crate::sidecar::is_admin_path(request.uri().path()) {
1737        return next.run(request).await;
1738    }
1739    let keys: Vec<String> = crate::rate_limit::rate_limit_keys(
1740        crate::rate_limit::client_ip(request.headers()).as_deref(),
1741        None,
1742    )
1743    .into_iter()
1744    .map(|key| format!("admin:{key}"))
1745    .collect();
1746    if let RateLimit::Denied { decision } =
1747        crate::rate_limit::check_rate_limit(limiter.as_ref(), &keys, RateLimitFailure::FailClosed)
1748            .await
1749    {
1750        return rate_limited(&decision);
1751    }
1752    next.run(request).await
1753}
1754
1755/// Collects the modules' `/.well-known` routers (issue #46): at most one
1756/// module may provide one — `/.well-known` is a singleton discovery
1757/// namespace — and more is a build error naming every provider.
1758/// Production abuse controls are an initialization rule, not a doctor
1759/// suggestion (issue #133): a venture that boots in production cannot
1760/// rely on captcha or payment verification it does not actually have.
1761/// The overrides are deliberately `None` here — the operator's recorded
1762/// acceptances belong to the boot gate, which reads them from the
1763/// deployment config against the resolved ports. The webhook-secret leg
1764/// (`webhook_secret_readiness`, issue #533) is skipped for the same
1765/// reason: it reads the deployment config, which a build has none of.
1766fn append_production_readiness(
1767    venture: &Venture,
1768    modules: &[Arc<dyn Module>],
1769    runtime: Option<&Arc<dyn Runtime>>,
1770    errors: &mut ConfigError,
1771) {
1772    for error in crate::route_policy::production_readiness(
1773        venture.env,
1774        &crate::route_policy::WriteGuards::collect(modules),
1775        runtime,
1776        // Build time has no resolved ports, so these are the runtime's own
1777        // answers — provisionally optimistic for a binding that is named
1778        // but fails to resolve, or a secret that fails to parse. The boot
1779        // gate re-checks against the ports the runtime actually handed
1780        // over, and refuses there.
1781        crate::route_policy::rate_limiter_effective(runtime),
1782        crate::route_policy::signer_effective(runtime),
1783        None,
1784        None,
1785    ) {
1786        errors.push(error);
1787    }
1788}
1789
1790fn collect_well_known(modules: &[Arc<dyn Module>], errors: &mut ConfigError) -> Option<Router> {
1791    let mut providers: Vec<&'static str> = Vec::new();
1792    let mut well_known = None;
1793    for module in modules {
1794        if let Some(router) = module.well_known() {
1795            providers.push(module.name());
1796            well_known = Some(router);
1797        }
1798    }
1799    if providers.len() > 1 {
1800        let listed = providers
1801            .iter()
1802            .map(|name| format!("`{name}`"))
1803            .collect::<Vec<_>>()
1804            .join(", ");
1805        errors.push(format!(
1806            "modules {listed} all provide a well-known router; at most one module may occupy /.well-known"
1807        ));
1808    }
1809    well_known
1810}
1811
1812#[cfg(test)]
1813mod acceptance_recording {
1814    //! The once-per-deployment recording of an operator's escape-hatch
1815    //! acceptance (issue #143, #437). In-file because the guards are
1816    //! private `AtomicBool`s on `Harness`, and per-`Harness` rather than a
1817    //! process static so each test's harness carries its own record.
1818
1819    use super::*;
1820    use crate::Migrations;
1821    use crate::config::MapConfig;
1822
1823    /// A surface-less public writer — the conservative-fallback shape,
1824    /// which the captcha leg satisfies through the runtime below.
1825    struct PubWriter;
1826
1827    impl Module for PubWriter {
1828        fn name(&self) -> &'static str {
1829            "writer"
1830        }
1831        fn version(&self) -> &'static str {
1832            "0.0.0-test"
1833        }
1834        fn requires(&self) -> &'static [Port] {
1835            &[]
1836        }
1837        fn public_writes(&self) -> bool {
1838            true
1839        }
1840        fn migrations(&self) -> Migrations {
1841            Migrations::default()
1842        }
1843        fn validate_config(&self, _: &dyn Config) -> Result<(), ConfigError> {
1844            Ok(())
1845        }
1846        fn router(&self, _: ModuleContext) -> Router {
1847            Router::new()
1848        }
1849    }
1850
1851    /// Advertises every port, so the captcha leg is satisfied and each
1852    /// test below is about the limiter leg alone.
1853    struct AllPorts;
1854
1855    impl Runtime for AllPorts {
1856        fn provides(&self) -> Vec<Port> {
1857            Port::ALL.to_vec()
1858        }
1859    }
1860
1861    fn harness() -> Harness {
1862        Harness::builder()
1863            .venture(
1864                Venture::new("test-venture", "test.example").cors_origins(["https://test.example"]),
1865            )
1866            .module(PubWriter)
1867            .runtime(AllPorts)
1868            .build()
1869            .expect("a development venture builds")
1870    }
1871
1872    fn deployed(unlimited: Option<&str>) -> MapConfig {
1873        let pairs = [("ENV", "production")].into_iter().chain(
1874            unlimited.map(|reason| (crate::route_policy::ALLOW_UNLIMITED_PUBLIC_ROUTES, reason)),
1875        );
1876        MapConfig::from_pairs(pairs)
1877    }
1878
1879    #[test]
1880    fn the_waiver_is_recorded_once_and_only_when_it_waived() {
1881        let harness = harness();
1882        let config = deployed(Some("issue #437: limiter binding pending"));
1883        assert!(
1884            harness
1885                .production_readiness_now(VentureEnv::Production, &config, false, true)
1886                .is_empty(),
1887            "the recorded acceptance serves"
1888        );
1889        assert!(
1890            harness
1891                .unlimited_acceptance_recorded
1892                .load(Ordering::Relaxed)
1893        );
1894
1895        // A later router build — on Workers there is one per request —
1896        // neither refuses nor re-records.
1897        assert!(
1898            harness
1899                .production_readiness_now(VentureEnv::Production, &config, false, true)
1900                .is_empty()
1901        );
1902    }
1903
1904    #[test]
1905    fn nothing_is_recorded_when_the_port_was_fine_all_along() {
1906        let harness = harness();
1907        let config = deployed(Some("unused: the limiter resolved"));
1908        assert!(
1909            harness
1910                .production_readiness_now(VentureEnv::Production, &config, true, true)
1911                .is_empty()
1912        );
1913        assert!(
1914            !harness
1915                .unlimited_acceptance_recorded
1916                .load(Ordering::Relaxed),
1917            "a waiver that removed no refusal is not a recorded acceptance"
1918        );
1919    }
1920
1921    #[test]
1922    fn a_refusing_deployment_records_no_acceptance_at_all() {
1923        let harness = harness();
1924        // The captcha waiver does not reach the limiter leg, so the gate
1925        // refuses — and an acceptance that left the deployment refusing
1926        // recorded nothing, for either key.
1927        let config = MapConfig::from_pairs([
1928            ("ENV", "production"),
1929            (
1930                crate::route_policy::ALLOW_UNPROTECTED_WRITES,
1931                "captcha pending",
1932            ),
1933        ]);
1934        assert_eq!(
1935            harness
1936                .production_readiness_now(VentureEnv::Production, &config, false, true)
1937                .len(),
1938            1
1939        );
1940        assert!(
1941            !harness
1942                .unprotected_acceptance_recorded
1943                .load(Ordering::Relaxed)
1944        );
1945        assert!(
1946            !harness
1947                .unlimited_acceptance_recorded
1948                .load(Ordering::Relaxed)
1949        );
1950    }
1951
1952    /// A webhook receiver that verifies its deliveries with the core HMAC
1953    /// scheme (issue #533): a signature writer that asks nothing of the
1954    /// Payments port — its secret key is the whole production gate.
1955    struct HmacWriter;
1956
1957    impl Module for HmacWriter {
1958        fn name(&self) -> &'static str {
1959            "webhooks"
1960        }
1961        fn version(&self) -> &'static str {
1962            "0.0.0-test"
1963        }
1964        fn requires(&self) -> &'static [Port] {
1965            &[]
1966        }
1967        fn public_writes(&self) -> bool {
1968            true
1969        }
1970        fn public_write_policy(&self) -> crate::route_policy::RoutePolicy {
1971            crate::route_policy::RoutePolicy::Signature
1972        }
1973        fn signature_verification(&self) -> crate::route_policy::SignatureVerification {
1974            crate::route_policy::SignatureVerification::Hmac {
1975                secret: "WEBHOOK_SECRET",
1976            }
1977        }
1978        fn migrations(&self) -> Migrations {
1979            Migrations::default()
1980        }
1981        fn validate_config(&self, _: &dyn Config) -> Result<(), ConfigError> {
1982            Ok(())
1983        }
1984        fn router(&self, _: ModuleContext) -> Router {
1985            Router::new()
1986        }
1987    }
1988
1989    #[test]
1990    fn a_non_payments_signature_venture_boots_on_its_webhook_secret_alone() {
1991        let harness = Harness::builder()
1992            .venture(
1993                Venture::new("test-venture", "test.example").cors_origins(["https://test.example"]),
1994            )
1995            .module(HmacWriter)
1996            .runtime(AllPorts)
1997            .build()
1998            .expect("a development venture builds");
1999        let deployed = MapConfig::from_pairs([("ENV", "production")]);
2000        let problems =
2001            harness.production_readiness_now(VentureEnv::Production, &deployed, true, true);
2002        assert_eq!(problems.len(), 1, "{problems:?}");
2003        assert!(
2004            problems[0].contains("WEBHOOKS_WEBHOOK_SECRET"),
2005            "{}",
2006            problems[0]
2007        );
2008        assert!(
2009            !problems[0].contains("Payments"),
2010            "a module verifying through the HMAC scheme is not the Payments leg's business: {}",
2011            problems[0]
2012        );
2013
2014        // The secret configured: the same venture is ready to serve.
2015        let ready = MapConfig::from_pairs([
2016            ("ENV", "production"),
2017            ("WEBHOOKS_WEBHOOK_SECRET", "whsec-test-dummy"),
2018        ]);
2019        assert!(
2020            harness
2021                .production_readiness_now(VentureEnv::Production, &ready, true, true)
2022                .is_empty()
2023        );
2024    }
2025}