Skip to main content

cratefield_core/
route_policy.rs

1//! Route protection policies and the production abuse gate (issue #133).
2//!
3//! Every declared write route carries an explicit [`RoutePolicy`]: it is
4//! open, protected by a human-form CAPTCHA, or protected by a machine
5//! signature (a provider webhook). The old shape — a bare `captcha: bool`
6//! plus the module-level `public_writes()` flag consulted only by
7//! `fz doctor` — let a webhook satisfy "has captcha" while simultaneously
8//! getting a captcha widget rendered on it, and left production booting
9//! with no verification at all as long as nobody ran the doctor.
10//!
11//! [`WriteGuards::collect`] reads the composed modules and says what the
12//! venture demands; [`production_readiness`] turns that plus what the
13//! runtime can actually do into build errors. `HarnessBuilder::build`
14//! calls it — enforcement moved from a CLI report to runtime
15//! initialization — and `fz doctor` keeps calling the same function as an
16//! advisory re-check, the way it re-checks `harness_api` (issue #17).
17
18use std::sync::Arc;
19
20use serde::{Deserialize, Serialize};
21
22use crate::harness::Runtime;
23use crate::module::Module;
24use crate::ports::{Captcha, Port};
25use crate::problem::Problem;
26use crate::problems::SLUGS;
27use crate::surface::{Action, Audience, Surface};
28use crate::venture::VentureEnv;
29
30/// How a request to a declared route proves it is legitimate.
31///
32/// The variants are mutually exclusive by construction: a route is
33/// protected by a human proof, a machine signature, an artifact this
34/// service issued, or nothing — never two of them, and never by
35/// whichever check the module happened to wire up.
36#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
37#[serde(rename_all = "kebab-case")]
38pub enum RoutePolicy {
39    /// No gateway-level protection. The default, and correct for reads
40    /// and for anything the handler authenticates itself.
41    #[default]
42    Open,
43    /// A public form submission from a browser. The handler verifies the
44    /// `captchaToken` the renderer supplies through the `Captcha` port,
45    /// and `Harness::build` refuses to boot a production venture whose
46    /// runtime cannot actually do that ([`captcha_effective`]).
47    HumanForm,
48    /// An authenticated machine caller — a provider webhook. The handler
49    /// proves the delivery with the module's declared verifier
50    /// ([`SignatureVerification`]: `Payments::verify_webhook` by default,
51    /// or a `webhook_signature` scheme) plus the [`Inbox`] dedup ledger. A
52    /// CAPTCHA is meaningless here (the caller has no browser) and must
53    /// never be rendered or required on such a route.
54    ///
55    /// [`Payments::verify_webhook`]: crate::ports::Payments::verify_webhook
56    /// [`Inbox`]: crate::idempotency::Inbox
57    Signature,
58    /// A public write whose proof is a single-use, purpose-bound
59    /// artifact **this service issued**: a magic link, a passkey or OAuth
60    /// challenge, an unsubscribe link (issue #143). The caller presents
61    /// something only a prior request of ours could have produced, so the
62    /// gate is the [`Signer`] key ring (issue #137), not a widget.
63    ///
64    /// This variant exists because the auth login methods had nowhere
65    /// honest to sit. They are public writers with no ADR 0010 surface,
66    /// so [`WriteGuards::collect`]'s conservative fallback filed them
67    /// under CAPTCHA — and a passkey challenge will never render one. A
68    /// production venture composing only auth modules therefore could not
69    /// boot at all except through `fz doctor --allow-no-captcha`, an
70    /// override the code itself documents as previews-only. This is not
71    /// an exemption: it carries its own production requirement, a
72    /// usable [`Signer`], because without one there is nothing to issue
73    /// or verify the artifact with.
74    ///
75    /// [`Signer`]: crate::ports::Signer
76    SignedLink,
77    /// An authenticated developer caller (issue #532): the request
78    /// presents `Authorization: Bearer <api key>` and the handler gates
79    /// it through [`require_api_key`], which verifies the key's hash and
80    /// checks one scope. A CAPTCHA is meaningless here (the caller is a
81    /// machine) and must never be rendered or required on such a route —
82    /// the same rule as [`RoutePolicy::Signature`].
83    ///
84    /// This variant carries no scope on purpose: a `RoutePolicy` is
85    /// `Copy` + serde and travels through surface documents, so it
86    /// cannot hold a per-action string. The route policy says *how* the
87    /// route is guarded; the scope is the handler's per-request argument
88    /// to `require_api_key`.
89    ///
90    /// [`require_api_key`]: crate::api_key::require_api_key
91    ApiKey,
92}
93
94impl RoutePolicy {
95    /// Whether this policy is a protection (as opposed to [`RoutePolicy::Open`]).
96    #[must_use]
97    pub fn is_guarded(self) -> bool {
98        self != Self::Open
99    }
100}
101
102/// Which verifier proves a module's [`RoutePolicy::Signature`] deliveries
103/// (issue #533): the `Payments` port — the default every existing module was
104/// built against — or the core `webhook_signature` HMAC scheme keyed by the
105/// module's own config secret. The production gate demands whichever the
106/// module named, never both and never neither.
107#[derive(Debug, Clone, Copy, PartialEq, Eq)]
108pub enum SignatureVerification {
109    /// `Payments::verify_webhook` — the default; production requires an
110    /// effective `Payments` port.
111    Payments,
112    /// A `webhook_signature` scheme keyed by a module-scoped config secret:
113    /// `secret` is a [`ModuleConfig`](crate::config::ModuleConfig)
114    /// **suffix**, read as `{MODULE}_{SECRET}` (`"WEBHOOK_SECRET"` on module
115    /// `pos` is `POS_WEBHOOK_SECRET`). Production requires that key — see
116    /// [`webhook_secret_readiness`] — and no `Payments` port.
117    Hmac {
118        /// The module-scoped config key suffix holding the shared secret.
119        secret: &'static str,
120    },
121}
122
123/// What the composed modules demand of the runtime, module by module.
124#[derive(Debug, Clone, Default, PartialEq, Eq)]
125pub struct WriteGuards {
126    /// Modules with a [`RoutePolicy::HumanForm`]-guarded write (or the
127    /// conservative fallback: [`public_writes`] and no declared policy at all).
128    ///
129    /// [`public_writes`]: Module::public_writes
130    pub captcha_modules: Vec<String>,
131    /// Modules with a [`RoutePolicy::Signature`]-guarded write (webhooks),
132    /// however they verify them.
133    pub signature_modules: Vec<String>,
134    /// The signature modules that prove their deliveries through
135    /// `Payments::verify_webhook` — the default [`SignatureVerification`].
136    pub payments_signature_modules: Vec<String>,
137    /// The signature modules proving their deliveries with a
138    /// `webhook_signature` scheme (issue #533), paired with the
139    /// [`ModuleConfig`](crate::config::ModuleConfig) **suffix** of the key
140    /// holding each secret; [`Self::hmac_signature_secrets`] composes the
141    /// full keys.
142    pub hmac_signature_modules: Vec<(String, &'static str)>,
143    /// Modules whose public writes are proved by an artifact this service
144    /// issued ([`RoutePolicy::SignedLink`], issue #143).
145    pub signed_link_modules: Vec<String>,
146    /// Whether the composition exposes any **public write** at all
147    /// (issue #437): a declared guarded action of any kind — a captcha
148    /// form, a webhook, a signed link — or the module-level
149    /// [`public_writes`] flag for modules that predate surfaces. Broader
150    /// than the three lists above by design: a webhook is an
151    /// unauthenticated endpoint too, and the rate-limiter leg of
152    /// [`production_readiness`] cares that *something* is writable, not
153    /// by which proof. A [`RoutePolicy::ApiKey`] write demands no
154    /// `RateLimiter` here: per-key limiting happens only where a handler
155    /// calls `check_rate_limit` with
156    /// [`ApiKeyPrincipal::rate_limit_key`] and a limiter is wired.
157    ///
158    /// [`public_writes`]: Module::public_writes
159    /// [`ApiKeyPrincipal::rate_limit_key`]: crate::api_key::ApiKeyPrincipal::rate_limit_key
160    pub has_public_writes: bool,
161    /// Whether any declared action is the **admin plane** (issue #437):
162    /// an [`Audience::Admin`] action, or any path under `/admin`. An
163    /// admin route's bearer token is guessed rather than submitted, so
164    /// it needs a budget even when the venture has no public writes —
165    /// which is why this is its own answer and not a byproduct of
166    /// [`Self::has_public_writes`].
167    pub has_admin_routes: bool,
168    /// Modules that **declare** `Port::RateLimiter` — in `requires()` or
169    /// `optional()` — and expose public routes, each paired with those
170    /// routes named `METHOD /v1/<module><path>` (issue #562). A module
171    /// saying it throttles is a claim about the routes it publishes, so
172    /// the readiness gate reads both halves: the declaration from the
173    /// module, the routes from its surface. Public here is
174    /// [`Surface::public`](crate::surface::Surface::public)'s renderer
175    /// answer minus `Audience::Subject` — reachable without an
176    /// authenticated principal, the admin plane excluded
177    /// (`has_admin_routes` already speaks for it), webhooks and API-key
178    /// routes excluded (`has_public_writes` already speaks for the
179    /// webhook, and an API-key route's budget is the per-key hook).
180    /// Empty for sidecar surfaces ([`Self::from_surface`]): a merged
181    /// document carries no `requires()`/`optional()` to read, and its
182    /// public writes and admin routes stay under the two legs above.
183    pub rate_limiter_modules: Vec<(String, Vec<String>)>,
184    /// Every declared [`RoutePolicy::Signature`] route, named the way the
185    /// readiness error names it (`METHOD /v1/<module><path>`), with the
186    /// verifier it resolved to (issue #595) — the action's own
187    /// [`verification`](crate::surface::Action::verification) when it
188    /// names one, else the module's
189    /// [`signature_verification`](Module::signature_verification). The
190    /// production gate and `fz doctor` read this to name the exact route a
191    /// missing `Payments` port or HMAC secret blocks, instead of blaming
192    /// the whole module. A module that reached the signature gate through
193    /// the surface-less fallback declares no route and contributes none.
194    pub signature_routes: Vec<(String, String, SignatureVerification)>,
195}
196
197impl WriteGuards {
198    /// Reads each module's declared surface. A module whose surface has
199    /// no policy-guarded write but still sets [`public_writes`] counts as
200    /// needing a CAPTCHA — the undeclared-public-writer fallback keeps
201    /// out-of-tree and surface-less modules (the auth crates predate
202    /// ADR 0010 surfaces) under the same gate instead of silently
203    /// exempting them.
204    ///
205    /// [`public_writes`]: Module::public_writes
206    #[must_use]
207    pub fn collect(modules: &[Arc<dyn Module>]) -> Self {
208        let mut guards = Self::default();
209        for module in modules {
210            let surface: Surface = module.surface();
211            let (mut form, mut signature) = Self::surface_flags(&surface);
212            let mut signed_link = surface
213                .actions
214                .iter()
215                .any(|action| action.policy == RoutePolicy::SignedLink);
216            // The undeclared-public-writer fallback only applies when
217            // nothing in the surface is guarded. What it falls back *to*
218            // is the module's own answer (issue #143): a surface-less
219            // module has no action to hang a policy on, so this is the
220            // only place it can say what protects its writes. The default
221            // is still `HumanForm`, so saying nothing changes nothing.
222            if !form && !signature && !signed_link && module.public_writes() {
223                match module.public_write_policy() {
224                    RoutePolicy::Signature => signature = true,
225                    RoutePolicy::SignedLink => signed_link = true,
226                    // An API-key writer (issue #532) is guarded — it is
227                    // never pulled into the captcha fallback — but it
228                    // demands no runtime port (the key store is the
229                    // app's) and no production-legible decision: its
230                    // budget is the per-key limiter hook, not a boot
231                    // gate.
232                    RoutePolicy::ApiKey => {}
233                    RoutePolicy::HumanForm | RoutePolicy::Open => form = true,
234                }
235            }
236            if form {
237                guards.captcha_modules.push(module.name().to_owned());
238            }
239            if signature {
240                guards.signature_modules.push(module.name().to_owned());
241                // Resolve each Signature route's verifier (issue #595): the
242                // action's own, else the module-level default. A module
243                // with no declared Signature route — the surface-less
244                // fallback above — resolves through the default alone and
245                // has no route to name.
246                let declared: Vec<(String, SignatureVerification)> = surface
247                    .actions
248                    .iter()
249                    .filter(|action| action.policy == RoutePolicy::Signature)
250                    .map(|action| {
251                        (
252                            Self::mounted_route(module.name(), action),
253                            action
254                                .verification
255                                .unwrap_or_else(|| module.signature_verification()),
256                        )
257                    })
258                    .collect();
259                let routes = if declared.is_empty() {
260                    vec![(String::new(), module.signature_verification())]
261                } else {
262                    declared
263                };
264                let mut wants_payments = false;
265                for (route, verifier) in routes {
266                    match verifier {
267                        SignatureVerification::Payments => wants_payments = true,
268                        // Dedupe on `(module, suffix)`: one module may
269                        // name the same secret on several routes.
270                        SignatureVerification::Hmac { secret } => {
271                            if !guards
272                                .hmac_signature_modules
273                                .iter()
274                                .any(|(m, s)| m == module.name() && *s == secret)
275                            {
276                                guards
277                                    .hmac_signature_modules
278                                    .push((module.name().to_owned(), secret));
279                            }
280                        }
281                    }
282                    if !route.is_empty() {
283                        guards
284                            .signature_routes
285                            .push((module.name().to_owned(), route, verifier));
286                    }
287                }
288                if wants_payments {
289                    guards
290                        .payments_signature_modules
291                        .push(module.name().to_owned());
292                }
293            }
294            if signed_link {
295                guards.signed_link_modules.push(module.name().to_owned());
296            }
297            // The limiter leg (issue #437) asks two broader questions
298            // than the per-policy lists: is anything writable publicly
299            // at all, and is there an admin plane to brute-force. The
300            // path check is the surface validation rule (`Audience::Admin`
301            // lives under `/admin`), applied in both directions so an
302            // undeclared admin path is caught even when the audience is
303            // missing.
304            if form || signature || signed_link || module.public_writes() {
305                guards.has_public_writes = true;
306            }
307            if Self::declares_admin_routes(&surface) {
308                guards.has_admin_routes = true;
309            }
310            // The declaration-keyed half of the limiter leg (issue #562):
311            // a module that declares `Port::RateLimiter` claims it
312            // throttles its public routes, and the readiness gate holds
313            // the composition to that claim even when nothing here is a
314            // public *write* — a public search endpoint is throttled
315            // surface all the same. Routes ride along so the error can
316            // name what runs unlimited.
317            if module.requires().contains(&Port::RateLimiter)
318                || module.optional().contains(&Port::RateLimiter)
319            {
320                let routes = Self::public_limiter_routes(module.name(), &surface);
321                if !routes.is_empty() {
322                    guards
323                        .rate_limiter_modules
324                        .push((module.name().to_owned(), routes));
325                }
326            }
327        }
328        guards
329    }
330
331    /// The guards a **declared surface** demands (issue #131). A sidecar's
332    /// merged surface is not a [`Module`] this process can ask
333    /// [`public_writes`](Module::public_writes) or
334    /// [`signature_verification`](Module::signature_verification) of, so
335    /// whatever the document declares is exactly what it needs, and a
336    /// signature route that names no verifier verifies through `Payments`,
337    /// the default (issues #533, #595). Same per-action reading as
338    /// [`Self::collect`]
339    /// through the one [`Action::demands_captcha`] predicate and the one
340    /// `declares_admin_routes` predicate — a sidecar's admin plane needs
341    /// the limiter floor as much as an in-process module's.
342    ///
343    /// [`Action::demands_captcha`]: crate::surface::Action::demands_captcha
344    #[must_use]
345    pub fn from_surface(module: &str, surface: &Surface) -> Self {
346        let (form, signature) = Self::surface_flags(surface);
347        let signed_link = surface
348            .actions
349            .iter()
350            .any(|action| action.policy == RoutePolicy::SignedLink);
351        // A sidecar cannot ask a module for its default, so an action that
352        // names no verifier verifies through `Payments` — the pre-#533
353        // behaviour, now resolved per route (issue #595).
354        let mut payments = false;
355        let mut hmac_signature_modules: Vec<(String, &'static str)> = Vec::new();
356        let mut signature_routes = Vec::new();
357        for action in &surface.actions {
358            if action.policy != RoutePolicy::Signature {
359                continue;
360            }
361            let verifier = action
362                .verification
363                .unwrap_or(SignatureVerification::Payments);
364            signature_routes.push((
365                module.to_owned(),
366                Self::mounted_route(module, action),
367                verifier,
368            ));
369            match verifier {
370                SignatureVerification::Payments => payments = true,
371                SignatureVerification::Hmac { secret } => {
372                    if !hmac_signature_modules.iter().any(|(_, s)| *s == secret) {
373                        hmac_signature_modules.push((module.to_owned(), secret));
374                    }
375                }
376            }
377        }
378        Self {
379            captcha_modules: form.then(|| module.to_owned()).into_iter().collect(),
380            signature_modules: signature.then(|| module.to_owned()).into_iter().collect(),
381            payments_signature_modules: payments.then(|| module.to_owned()).into_iter().collect(),
382            hmac_signature_modules,
383            signed_link_modules: signed_link.then(|| module.to_owned()).into_iter().collect(),
384            has_public_writes: form || signature || signed_link,
385            has_admin_routes: Self::declares_admin_routes(surface),
386            // No declaration to read (see `rate_limiter_modules`): a
387            // sidecar's public writes and admin routes stay under the
388            // two legs above.
389            rate_limiter_modules: Vec::new(),
390            signature_routes,
391        }
392    }
393
394    /// Whether any action needs a captcha and whether any is a signature
395    /// route. The `Open`-with-`captcha` legacy mirror resolves inside
396    /// [`Action::demands_captcha`].
397    fn surface_flags(surface: &Surface) -> (bool, bool) {
398        let mut form = false;
399        let mut signature = false;
400        for action in &surface.actions {
401            if action.demands_captcha() {
402                form = true;
403            }
404            if action.policy == RoutePolicy::Signature {
405                signature = true;
406            }
407        }
408        (form, signature)
409    }
410
411    /// Whether the surface declares the admin plane: an
412    /// [`Audience::Admin`](crate::surface::Audience::Admin) action, or any
413    /// path under `/admin`. Both spellings, so an action whose audience was
414    /// forgotten is still counted by where it sits — the same rule
415    /// [`Surface::validate`] enforces, read here instead of trusted.
416    ///
417    /// [`Surface::validate`]: crate::surface::Surface::validate
418    fn declares_admin_routes(surface: &Surface) -> bool {
419        surface.actions.iter().any(|action| {
420            action.audience == Audience::Admin
421                || action.path == "/admin"
422                || action.path.starts_with("/admin/")
423        })
424    }
425
426    /// Whether any module proves a public write with an artifact this
427    /// service issued, and so needs a usable [`Signer`] (issue #143).
428    ///
429    /// [`Signer`]: crate::ports::Signer
430    #[must_use]
431    pub fn needs_signer(&self) -> bool {
432        !self.signed_link_modules.is_empty()
433    }
434
435    /// Whether any module needs the `Captcha` port.
436    #[must_use]
437    pub fn needs_captcha(&self) -> bool {
438        !self.captcha_modules.is_empty()
439    }
440
441    /// Whether any module proves a public write with
442    /// [`Payments::verify_webhook`](crate::ports::Payments). Since issue
443    /// #533 this is no longer "some module receives signed webhooks": a
444    /// module may declare [`SignatureVerification::Hmac`] and verify with
445    /// the core `webhook_signature` scheme instead, and this answers
446    /// `false` for it — its production requirement is its secret key, not a
447    /// port (see [`webhook_secret_readiness`]). The `STRIPE_WEBHOOK_SECRET`
448    /// doctor rule is keyed on this, correctly.
449    #[must_use]
450    pub fn needs_payments(&self) -> bool {
451        !self.payments_signature_modules.is_empty()
452    }
453
454    /// The HMAC-verified signature modules and the **full** config key each
455    /// reads its secret from, composed through
456    /// [`ModuleConfig`](crate::config::ModuleConfig)'s `{MODULE}_{SUFFIX}`
457    /// rule — the one place the prefix logic lives (issue #533).
458    #[must_use]
459    pub fn hmac_signature_secrets(&self) -> Vec<(String, String)> {
460        self.hmac_signature_modules
461            .iter()
462            .map(|(module, suffix)| {
463                let key = crate::config::ModuleConfig::new(module, &crate::config::EmptyConfig)
464                    .key(suffix);
465                (module.clone(), key)
466            })
467            .collect()
468    }
469
470    /// The HMAC-verified signature modules, the **full** config key each
471    /// reads its secret from, and the `METHOD /v1/<module><path>` routes
472    /// that read it (issue #595) — the shape `webhook_secret_readiness`
473    /// and `fz doctor` name a missing secret by, so a module with a Stripe
474    /// route and a `RevenueCat` route says which one the unset key blocks.
475    #[must_use]
476    pub fn hmac_signature_routes(&self) -> Vec<(String, String, Vec<String>)> {
477        self.hmac_signature_secrets()
478            .into_iter()
479            .zip(&self.hmac_signature_modules)
480            .map(|((module, key), (_, secret))| {
481                let routes =
482                    self.signature_routes_for(&module, SignatureVerification::Hmac { secret });
483                (module, key, routes)
484            })
485            .collect()
486    }
487
488    /// The routes `module` verifies with `verifier`, named
489    /// `METHOD /v1/<module><path>` (issue #595), so a readiness error can
490    /// point at the route instead of the whole module. Empty when the
491    /// module declared no surface route (the surface-less fallback).
492    #[must_use]
493    pub fn signature_routes_for(
494        &self,
495        module: &str,
496        verifier: SignatureVerification,
497    ) -> Vec<String> {
498        self.signature_routes
499            .iter()
500            .filter(|(m, _, v)| m == module && *v == verifier)
501            .map(|(_, route, _)| route.clone())
502            .collect()
503    }
504
505    /// `module (METHOD /v1/<module><path>, ...)`, or the bare module name
506    /// when it declared no route (the surface-less fallback) — the way the boot
507    /// gate and `fz doctor` name a signature module in a message
508    /// (issue #595).
509    #[must_use]
510    pub fn signature_module_named(&self, module: &str, verifier: SignatureVerification) -> String {
511        let routes = self.signature_routes_for(module, verifier);
512        if routes.is_empty() {
513            module.to_owned()
514        } else {
515            format!("{module} ({})", routes.join(", "))
516        }
517    }
518
519    /// One action named the way a readiness error names it:
520    /// `METHOD /v1/<module><path>` (issues #562, #595). The surface stores
521    /// paths relative to the module's mount, so the mount is composed back
522    /// on here — an error should name the URL that runs, not a
523    /// surface-internal fragment. The index action is declared `/`, and the
524    /// mounted URL has no trailing slash to name.
525    fn mounted_route(module: &str, action: &Action) -> String {
526        let path = action.path.trim_end_matches('/');
527        if path.is_empty() {
528            format!("{} /v1/{module}", action.method)
529        } else {
530            format!("{} /v1/{module}{path}", action.method)
531        }
532    }
533
534    /// The module's public routes, named the way the readiness error names
535    /// them: `METHOD /v1/<module><path>` (issue #562).
536    fn public_limiter_routes(module: &str, surface: &Surface) -> Vec<String> {
537        surface
538            .actions
539            .iter()
540            .filter(|action| {
541                action.audience != Audience::Admin
542                    && action.audience != Audience::Subject
543                    && action.policy != RoutePolicy::Signature
544                    && action.policy != RoutePolicy::ApiKey
545            })
546            .map(|action| Self::mounted_route(module, action))
547            .collect()
548    }
549
550    /// Whether any route in the composition needs a limiter sitting in
551    /// front of it (issues #437, #562): a public write, an admin route,
552    /// or a module that declares `Port::RateLimiter` over public routes.
553    /// The admin plane is enough on its own — a bearer token is guessed,
554    /// not submitted, so a venture that publishes nothing still needs the
555    /// budget before its `/admin/*` routes — and a module declaring the
556    /// port is a claim to throttle that the gate holds the venture to
557    /// even when nothing it publishes is a write.
558    #[must_use]
559    pub fn needs_rate_limiter(&self) -> bool {
560        self.has_public_writes || self.has_admin_routes || !self.rate_limiter_modules.is_empty()
561    }
562}
563
564/// Whether the runtime's `Captcha` port can really verify a token in
565/// production: the port is provided **and**, when the adapter reports,
566/// hostname-bound and not fail-open. An adapter that does not report
567/// ([`Captcha::binding`] = `None`) counts as effective — presence is all
568/// the harness can know about a third-party implementation; the adapter
569/// contract carries the duty to fail closed per request.
570///
571/// [`Captcha::binding`]: crate::ports::Captcha::binding
572#[must_use]
573pub fn captcha_effective(runtime: Option<&Arc<dyn Runtime>>) -> bool {
574    runtime.is_some_and(|runtime| runtime.effectively_configured(Port::Captcha))
575}
576
577/// Whether the runtime can verify webhook signatures for the
578/// [`RoutePolicy::Signature`]-guarded routes whose modules verify through
579/// `Payments` (the default): signature verification is the adapter's
580/// per-request duty (`verify_webhook` + `Inbox`), and which secret it
581/// checks against is deploy configuration. A module that declared
582/// [`SignatureVerification::Hmac`] is not this function's business — its
583/// production requirement is its secret key, checked by
584/// [`webhook_secret_readiness`].
585#[must_use]
586pub fn payments_effective(runtime: Option<&Arc<dyn Runtime>>) -> bool {
587    runtime.is_some_and(|runtime| runtime.effectively_configured(Port::Payments))
588}
589
590/// The boot-time leg for HMAC-verified signature modules (issue #533): in
591/// production, each one's configured secret key must be present and
592/// non-blank, or the module's handler refuses every delivery. The errors
593/// name the module and the config key, never the value, and there is no
594/// waiver: an operator who wants the endpoint unverified removes the
595/// route, which is a build-time decision. Run by the boot gate
596/// ([`Harness::router`](crate::Harness::router)'s readiness re-check),
597/// which holds the deployment config; the build-time gate has none, so it
598/// skips this leg — the same split as the overrides there.
599#[must_use]
600pub fn webhook_secret_readiness(
601    env: VentureEnv,
602    guards: &WriteGuards,
603    config: &dyn crate::config::Config,
604) -> Vec<String> {
605    if env != VentureEnv::Production {
606        return Vec::new();
607    }
608    guards
609        .hmac_signature_routes()
610        .into_iter()
611        .filter(|(_, key, _)| config.get(key).is_none_or(|value| value.trim().is_empty()))
612        .map(|(module, key, routes)| {
613            // Name the route(s) the unset key blocks (issue #595): a
614            // module may verify one webhook with `Payments` and another
615            // with this secret, so blaming the whole module would not say
616            // which endpoint refuses every delivery.
617            let on = if routes.is_empty() {
618                String::new()
619            } else {
620                format!(" on {}", routes.join(", "))
621            };
622            format!(
623                "production venture verifies `{module}` webhook signatures with the \
624                 webhook_signature HMAC scheme but `{key}` is not set — its handler refuses \
625                 every delivery{on} until the provider's signing secret is configured"
626            )
627        })
628        .collect()
629}
630
631/// The operator's reason, if they actually gave one.
632///
633/// `docs/SECURITY.md` puts it in four words — "A blank reason is not an
634/// acceptance" — and `unprotected_writes_override` already enforced it
635/// for `HARNESS_ALLOW_UNPROTECTED_WRITES`. The `fz doctor` flag reached
636/// the same gate without it: `--allow-no-captcha ""` arrives as
637/// `Some("")`, which waived a production abuse control and recorded an
638/// empty reason for it. A waiver with nothing to answer for is the
639/// silent default the gate exists to remove.
640///
641/// One function, so the two ways to the same waiver cannot disagree
642/// again.
643#[must_use]
644pub fn stated_reason(reason: Option<&str>) -> Option<&str> {
645    reason.map(str::trim).filter(|reason| !reason.is_empty())
646}
647
648/// Config key holding an operator's explicit, recorded acceptance that
649/// this deployment serves guarded routes it cannot fully protect
650/// (issue #143).
651///
652/// The value is the **reason**, and an empty one does not count: an
653/// override with nothing to answer for is the silent default this issue
654/// exists to remove. It is recorded through `tracing` on every boot, so
655/// it appears wherever the operator ships logs, and `fz doctor` reports
656/// it. It exists because the gate landed on ventures that had already
657/// been serving unprotected for months — refusing their traffic outright
658/// on the next deploy would trade a quiet hole for a loud outage without
659/// anyone choosing it. Wire the missing port and delete the key.
660pub const ALLOW_UNPROTECTED_WRITES: &str = "HARNESS_ALLOW_UNPROTECTED_WRITES";
661
662/// The operator's recorded reason for serving guarded routes unprotected,
663/// if they set one (issue #143).
664#[must_use]
665pub fn unprotected_writes_override(config: &dyn crate::config::Config) -> Option<String> {
666    config
667        .get(ALLOW_UNPROTECTED_WRITES)
668        .and_then(|raw| stated_reason(Some(raw.as_str())).map(str::to_owned))
669}
670
671/// Config key holding an operator's explicit, recorded acceptance that
672/// this deployment serves its public writes and admin routes with **no
673/// resolved rate limiter** (issue #437). Same contract as
674/// [`ALLOW_UNPROTECTED_WRITES`]: the value is the **reason**, and a blank
675/// one does not count; the boot gate records it once, wherever the
676/// operator ships logs. It exists because the limiter was opt-in — a
677/// runtime that failed to resolve its binding degraded to no limiter and
678/// served — and because the escape from that must be as deliberate as the
679/// gap it accepts. Resolve the binding and delete the key.
680pub const ALLOW_UNLIMITED_PUBLIC_ROUTES: &str = "HARNESS_ALLOW_UNLIMITED_PUBLIC_ROUTES";
681
682/// The operator's recorded reason for serving public writes and admin
683/// routes unlimited, if they set one (issue #437).
684#[must_use]
685pub fn unlimited_public_routes_override(config: &dyn crate::config::Config) -> Option<String> {
686    config
687        .get(ALLOW_UNLIMITED_PUBLIC_ROUTES)
688        .and_then(|raw| stated_reason(Some(raw.as_str())).map(str::to_owned))
689}
690
691/// The environment this **deployment** runs in (issue #143).
692///
693/// A venture carries a compiled [`VentureEnv`] — a builder default the
694/// operator cannot change without a rebuild — and a deployment carries
695/// an `ENV` binding it can. Every production-only rule used to read the
696/// compiled one alone, so a Worker deployed with `ENV = "production"`
697/// over a venture that never called [`Venture::env`] ran with all of
698/// them switched off. Neither source may downgrade the other: if either
699/// says `Production`, this is production.
700///
701/// [`Venture::env`]: crate::venture::Venture::env
702#[must_use]
703pub fn deployed_env(compiled: VentureEnv, config: &dyn crate::config::Config) -> VentureEnv {
704    let declared = config
705        .get("ENV")
706        .as_deref()
707        .and_then(VentureEnv::parse)
708        .unwrap_or_default();
709    compiled.strictest(declared)
710}
711
712/// Whether the deployment's environment contradicts the compiled one
713/// (issue #143). Not an error by itself — the stricter answer wins — but
714/// it means the build-time gate ran against the wrong environment, so
715/// the caller re-checks and says so.
716#[must_use]
717pub fn env_disagreement(compiled: VentureEnv, deployed: VentureEnv) -> Option<String> {
718    (compiled != deployed).then(|| {
719        format!(
720            "this deployment declares ENV={} but the venture was compiled with \
721             VentureEnv::{compiled:?}: the production readiness gate at build time ran \
722             against the wrong environment. Treating it as {} — call \
723             `.env(VentureEnv::{deployed:?})` on the venture so the build refuses what \
724             the deployment cannot serve (issue #143)",
725            deployed.as_str(),
726            deployed.as_str(),
727        )
728    })
729}
730
731/// Whether the runtime can issue and verify the artifacts a
732/// [`RoutePolicy::SignedLink`] route rests on (issue #143).
733///
734/// Like [`rate_limiter_effective`], the runtime's own, provisional
735/// answer: the native and Cloudflare runtimes advertise [`Port::Signer`]
736/// unconditionally and leave `ports.signer == None` with only a warning
737/// when the harness configuration does not parse (issue #478). The boot
738/// gate re-checks against the resolved port — [`production_readiness`]
739/// takes it as its `signer_ready` parameter.
740#[must_use]
741pub fn signer_effective(runtime: Option<&Arc<dyn Runtime>>) -> bool {
742    runtime.is_some_and(|runtime| runtime.effectively_configured(Port::Signer))
743}
744
745/// Whether the runtime has a limiter it can actually consult (issue #437).
746///
747/// This is the **runtime's own answer**, and a provisional one: it is all
748/// the build-time gate and `fz doctor` can know, because neither holds the
749/// resolved [`Ports`](crate::ports::Ports). A Cloudflare binding that is
750/// named but fails to resolve still reports `true` here — the adapter
751/// degrades to `ports.rate_limiter == None` with only a warning — so the
752/// boot gate re-checks against the port the runtime actually handed over:
753/// [`production_readiness`] takes that resolved answer as its
754/// `rate_limiter_ready` parameter and never computes it from this.
755#[must_use]
756pub fn rate_limiter_effective(runtime: Option<&Arc<dyn Runtime>>) -> bool {
757    runtime.is_some_and(|runtime| runtime.effectively_configured(Port::RateLimiter))
758}
759
760/// The production abuse-control gate (issue #133), as collected error
761/// strings (the [`ConfigError`] convention: report every problem at
762/// once). Empty for anything but `Production`.
763///
764/// The two overrides are deliberate, independent, recorded decisions:
765/// `allow_no_captcha` (the `fz doctor` flag, and
766/// `HARNESS_ALLOW_UNPROTECTED_WRITES` at the boot gate) downgrades only
767/// the CAPTCHA refusal, and `allow_unlimited`
768/// ([`ALLOW_UNLIMITED_PUBLIC_ROUTES`]) downgrades only the
769/// rate-limiter refusal (issue #437). One key waiving two controls is how
770/// the second stays unwired after the first is fixed.
771///
772/// The limiter leg is keyed on `rate_limiter_ready`, the **resolved**
773/// answer, and never inferred from [`Runtime::provides`]: the Cloudflare
774/// runtime reports [`Port::RateLimiter`] from a configured binding *name*,
775/// and a binding that fails to resolve degrades to
776/// `ports.rate_limiter == None` with only a `warn_once`. Callers holding
777/// ports pass `ports.rate_limiter.is_some()`; the build-time gate and
778/// `fz doctor` pass [`rate_limiter_effective`], the runtime's own answer.
779/// The leg is additive on declarations (issue #562): a module whose
780/// `requires()`/`optional()` names [`Port::RateLimiter`] and that exposes
781/// public routes makes the leg fire on its own, and the message names each
782/// declaring module with its routes instead of the undeclared-writer text.
783///
784/// The signer leg is keyed the same way on `signer_ready` (issue #478):
785/// the runtimes advertise [`Port::Signer`] yet leave `ports.signer ==
786/// None` when the harness configuration fails to parse. Callers holding
787/// ports pass `ports.signer.is_some()`; the build-time gate passes
788/// [`signer_effective`].
789///
790/// [`ConfigError`]: crate::config::ConfigError
791#[must_use]
792pub fn production_readiness(
793    env: VentureEnv,
794    guards: &WriteGuards,
795    runtime: Option<&Arc<dyn Runtime>>,
796    rate_limiter_ready: bool,
797    signer_ready: bool,
798    allow_no_captcha: Option<&str>,
799    allow_unlimited: Option<&str>,
800) -> Vec<String> {
801    if env != VentureEnv::Production {
802        return Vec::new();
803    }
804    let mut errors = Vec::new();
805    if guards.needs_captcha()
806        && !captcha_effective(runtime)
807        && stated_reason(allow_no_captcha).is_none()
808    {
809        errors.push(format!(
810            "production venture has captcha-guarded public writes from [{}] but the Captcha \
811             port is not effectively configured: provide it on the runtime with a bound \
812             adapter (Turnstile needs its secret and expected hostname) — `fz doctor \
813             --allow-no-captcha <reason>` overrides this check for previews only, and \
814             {ALLOW_UNPROTECTED_WRITES} records the acceptance in a serving deployment",
815            guards.captcha_modules.join(", ")
816        ));
817    }
818    if guards.needs_signer() && !signer_ready {
819        errors.push(format!(
820            "production venture has signed-link public writes from [{}] but the Signer port \
821             is not provided — the magic links, challenges and unsubscribe links those \
822             routes verify cannot be issued or checked without one (issue #143)",
823            guards.signed_link_modules.join(", ")
824        ));
825    }
826    if guards.needs_payments() && !payments_effective(runtime) {
827        // Name each module with the routes that verify through Payments
828        // (issue #595): a module may verify its Stripe webhook this way
829        // and another provider's with an HMAC secret, and the operator
830        // needs to see which endpoint the missing port blocks.
831        let named = guards
832            .payments_signature_modules
833            .iter()
834            .map(|module| guards.signature_module_named(module, SignatureVerification::Payments))
835            .collect::<Vec<_>>()
836            .join(", ");
837        errors.push(format!(
838            "production venture has signature-guarded routes from [{named}] that verify through \
839             Payments but the Payments port is not provided — those webhook deliveries cannot \
840             be verified (see the Inbox dedup ledger and the STRIPE_WEBHOOK_SECRET doctor \
841             rule; modules verifying with the webhook_signature HMAC scheme instead are \
842             gated on their own secret key, not this port)",
843        ));
844    }
845    if guards.needs_rate_limiter()
846        && !rate_limiter_ready
847        && stated_reason(allow_unlimited).is_none()
848    {
849        // When a module *declares* the port (issue #562), name it and the
850        // routes it publishes unlimited — the captcha leg's convention:
851        // the operator should be able to fix the named thing, not go
852        // hunting for which route made the gate fire.
853        if guards.rate_limiter_modules.is_empty() {
854            errors.push(format!(
855                "production venture takes public writes or admin routes but the RateLimiter \
856                 port is not resolved: admin bearer routes have no brute-force backstop behind \
857                 the limiter (the fail-closed rule the sidecar forward runs) and every public \
858                 write runs without a budget — resolve the binding so the runtime actually \
859                 hands over a limiter, or set {ALLOW_UNLIMITED_PUBLIC_ROUTES} to a reason to \
860                 serve unlimited (issue #437)"
861            ));
862        } else {
863            let declared = guards
864                .rate_limiter_modules
865                .iter()
866                .map(|(module, routes)| format!("{module} ({})", routes.join(", ")))
867                .collect::<Vec<_>>()
868                .join(", ");
869            errors.push(format!(
870                "production venture declares RateLimiter for public routes from [{declared}] \
871                 but the RateLimiter port is not resolved: those routes — and any public write \
872                 or admin route beside them — run without a budget — resolve the binding so \
873                 the runtime actually hands over a limiter, or set \
874                 {ALLOW_UNLIMITED_PUBLIC_ROUTES} to a reason to serve unlimited \
875                 (issues #437, #562)"
876            ));
877        }
878    }
879    errors
880}
881
882/// The shared CAPTCHA gate for `HumanForm` handlers (issue #133): the
883/// single place a module asks "is this form submission a human?".
884///
885/// Fail-closed rules, in order:
886///
887/// - the port is present: the token must exist and verify — any
888///   non-`ok` verdict or transport error is a `captcha-failed` problem;
889/// - the port is absent in **production**: `captcha-failed` as well.
890///   `Harness::build` refuses that composition, so reaching this branch
891///   means a runtime lied about its binding — refuse rather than wave
892///   the request through. **Unless** `accepted_unprotected`: an operator
893///   has recorded that this deployment serves without the port
894///   (`HARNESS_ALLOW_UNPROTECTED_WRITES`, issue #143), the boot gate
895///   honoured that and served, and refusing here would be the same
896///   outage the acceptance exists to avoid, with a different status
897///   code. The record is the accountability, not this branch;
898/// - the port is absent in development/staging: allow, so a staging
899///   deploy can drive the form without a live Turnstile.
900///
901/// # Errors
902///
903/// A `captcha-failed` problem when the submission is not proven human.
904pub async fn verify_human_form(
905    captcha: Option<&Arc<dyn Captcha>>,
906    env: VentureEnv,
907    accepted_unprotected: bool,
908    token: Option<&str>,
909    remote_ip: Option<&str>,
910    instance: &str,
911) -> Result<(), Problem> {
912    let refused = || Problem::new(&SLUGS.captcha_failed).instance(instance);
913    match captcha {
914        Some(captcha) => {
915            let Some(token) = token else {
916                return Err(refused());
917            };
918            match captcha.verify(token, remote_ip).await {
919                Ok(verdict) if verdict.ok => Ok(()),
920                _ => Err(refused()),
921            }
922        }
923        // No port, in production, with nobody having accepted that: the
924        // composition `Harness::build` refuses is somehow serving, so
925        // refuse the request rather than wave it through.
926        None if env == VentureEnv::Production && !accepted_unprotected => Err(refused()),
927        None => Ok(()),
928    }
929}
930
931#[cfg(test)]
932mod tests {
933    use super::*;
934    use crate::Migrations;
935    use crate::ports::{CaptchaError, Verdict};
936    use crate::surface::Action;
937    use async_trait::async_trait;
938
939    struct StubCaptcha {
940        ok: bool,
941        transport_error: bool,
942    }
943
944    #[async_trait]
945    impl Captcha for StubCaptcha {
946        async fn verify(
947            &self,
948            _token: &str,
949            _remote_ip: Option<&str>,
950        ) -> Result<Verdict, CaptchaError> {
951            if self.transport_error {
952                return Err(CaptchaError::Transport("down".to_owned()));
953            }
954            Ok(Verdict {
955                ok: self.ok,
956                reason: None,
957            })
958        }
959    }
960
961    fn port(ok: bool, transport_error: bool) -> Arc<dyn Captcha> {
962        Arc::new(StubCaptcha {
963            ok,
964            transport_error,
965        })
966    }
967
968    #[pollster::test]
969    async fn verified_tokens_pass_in_every_environment() {
970        for env in [
971            VentureEnv::Development,
972            VentureEnv::Staging,
973            VentureEnv::Production,
974        ] {
975            verify_human_form(
976                Some(&port(true, false)),
977                env,
978                false,
979                Some("token"),
980                Some("203.0.113.7"),
981                "request-1",
982            )
983            .await
984            .expect("a verified token passes");
985        }
986    }
987
988    #[pollster::test]
989    async fn refused_missing_or_unreachable_verifications_fail_closed() {
990        let good = port(true, false);
991        let refused = port(false, false);
992        let unreachable = port(true, true);
993        for env in [VentureEnv::Development, VentureEnv::Production] {
994            assert!(
995                verify_human_form(Some(&refused), env, false, Some("t"), None, "r")
996                    .await
997                    .is_err()
998            );
999            assert!(
1000                verify_human_form(Some(&unreachable), env, false, Some("t"), None, "r")
1001                    .await
1002                    .is_err()
1003            );
1004            assert!(
1005                verify_human_form(Some(&good), env, false, None, None, "r")
1006                    .await
1007                    .is_err()
1008            );
1009        }
1010    }
1011
1012    #[pollster::test]
1013    async fn absent_port_refuses_production_and_allows_lower_envs() {
1014        assert!(
1015            verify_human_form(None, VentureEnv::Production, false, Some("t"), None, "r")
1016                .await
1017                .is_err()
1018        );
1019        for env in [VentureEnv::Development, VentureEnv::Staging] {
1020            assert!(
1021                verify_human_form(None, env, false, Some("t"), None, "r")
1022                    .await
1023                    .is_ok()
1024            );
1025        }
1026    }
1027
1028    struct Guarded {
1029        name: &'static str,
1030        surface: Surface,
1031        public_writes: bool,
1032        public_write_policy: RoutePolicy,
1033        verification: SignatureVerification,
1034    }
1035
1036    impl Module for Guarded {
1037        fn name(&self) -> &'static str {
1038            self.name
1039        }
1040        fn version(&self) -> &'static str {
1041            "0.0.0-test"
1042        }
1043        fn requires(&self) -> &'static [Port] {
1044            &[]
1045        }
1046        fn public_writes(&self) -> bool {
1047            self.public_writes
1048        }
1049        fn public_write_policy(&self) -> RoutePolicy {
1050            self.public_write_policy
1051        }
1052        fn signature_verification(&self) -> SignatureVerification {
1053            self.verification
1054        }
1055        fn migrations(&self) -> Migrations {
1056            Migrations::default()
1057        }
1058        fn validate_config(
1059            &self,
1060            _: &dyn crate::config::Config,
1061        ) -> Result<(), crate::config::ConfigError> {
1062            Ok(())
1063        }
1064        fn surface(&self) -> Surface {
1065            self.surface.clone()
1066        }
1067        fn router(&self, _: crate::ModuleContext) -> axum::Router {
1068            axum::Router::new()
1069        }
1070    }
1071
1072    fn module(name: &'static str, actions: Vec<Action>, public_writes: bool) -> Arc<dyn Module> {
1073        Arc::new(Guarded {
1074            name,
1075            surface: Surface {
1076                actions,
1077                views: vec![],
1078            },
1079            public_writes,
1080            public_write_policy: RoutePolicy::HumanForm,
1081            verification: SignatureVerification::Payments,
1082        })
1083    }
1084
1085    /// A surface-less public writer that declares what really guards it.
1086    fn declaring(name: &'static str, policy: RoutePolicy) -> Arc<dyn Module> {
1087        Arc::new(Guarded {
1088            name,
1089            surface: Surface {
1090                actions: vec![],
1091                views: vec![],
1092            },
1093            public_writes: true,
1094            public_write_policy: policy,
1095            verification: SignatureVerification::Payments,
1096        })
1097    }
1098
1099    #[test]
1100    fn human_form_action_requires_captcha() {
1101        let guards = WriteGuards::collect(&[module(
1102            "forms",
1103            vec![Action::post("join", "/").policy(RoutePolicy::HumanForm)],
1104            false,
1105        )]);
1106        assert_eq!(guards.captcha_modules, vec!["forms".to_owned()]);
1107        assert!(!guards.needs_payments());
1108    }
1109
1110    #[test]
1111    fn signature_action_never_requires_captcha() {
1112        // The old bug: a webhook counted as "protected", so a captcha
1113        // config problem passed unnoticed AND the widget rendered on the
1114        // webhook. A Signature-only module must ask for Payments only.
1115        let guards = WriteGuards::collect(&[module(
1116            "billing",
1117            vec![Action::post("webhook", "/webhook").policy(RoutePolicy::Signature)],
1118            true, // even with the legacy flag set, the declared policy wins
1119        )]);
1120        assert!(guards.captcha_modules.is_empty());
1121        assert_eq!(guards.signature_modules, vec!["billing".to_owned()]);
1122    }
1123
1124    #[test]
1125    fn undeclared_public_writer_falls_back_to_captcha() {
1126        let guards = WriteGuards::collect(&[module("auth", Vec::new(), true)]);
1127        assert_eq!(guards.captcha_modules, vec!["auth".to_owned()]);
1128    }
1129
1130    #[test]
1131    fn legacy_captcha_flag_alias_still_guards() {
1132        // Pre-#133 declaration shapes — a bare `captcha: true` without a
1133        // policy — must still pull the module into the captcha gate.
1134        let mut action = Action::post("join", "/");
1135        action.captcha = true;
1136        let guards = WriteGuards::collect(&[module("forms", vec![action], false)]);
1137        assert_eq!(guards.captcha_modules, vec!["forms".to_owned()]);
1138    }
1139
1140    #[test]
1141    fn guarded_surface_does_not_need_the_fallback() {
1142        // A module with declared policies is read as declared: `public_writes`
1143        // adds nothing on top (a form writer declares HumanForm; the
1144        // fallback exists for modules that predate surfaces).
1145        let guards = WriteGuards::collect(&[module(
1146            "waitlist",
1147            vec![
1148                Action::post("join", "/").captcha(),
1149                Action::post("webhook", "/webhook").policy(RoutePolicy::Signature),
1150            ],
1151            true,
1152        )]);
1153        assert_eq!(guards.captcha_modules, vec!["waitlist".to_owned()]);
1154        assert_eq!(guards.signature_modules, vec!["waitlist".to_owned()]);
1155    }
1156
1157    #[test]
1158    fn development_venture_needs_nothing() {
1159        let guards = WriteGuards::collect(&[module("forms", Vec::new(), true)]);
1160        for env in [VentureEnv::Development, VentureEnv::Staging] {
1161            assert!(
1162                production_readiness(env, &guards, None, true, false, None, None).is_empty(),
1163                "{env:?} must not gate"
1164            );
1165        }
1166    }
1167
1168    #[test]
1169    fn production_without_runtime_refuses_captcha_writes() {
1170        let guards = WriteGuards::collect(&[module(
1171            "forms",
1172            vec![Action::post("join", "/").captcha()],
1173            false,
1174        )]);
1175        // The limiter leg is held aside (`rate_limiter_ready = true`) so
1176        // this test stays about the captcha leg; #437's own tests are
1177        // below.
1178        let errors = production_readiness(
1179            VentureEnv::Production,
1180            &guards,
1181            None,
1182            true,
1183            false,
1184            None,
1185            None,
1186        );
1187        assert_eq!(errors.len(), 1);
1188        assert!(errors[0].contains("forms"));
1189        assert!(errors[0].contains("Captcha"));
1190        // The override exists for `fz doctor` previews only.
1191        assert!(
1192            production_readiness(
1193                VentureEnv::Production,
1194                &guards,
1195                None,
1196                true,
1197                false,
1198                Some("preview"),
1199                None
1200            )
1201            .is_empty()
1202        );
1203        // And it is the *reason* that overrides, not the flag. `fz
1204        // doctor --allow-no-captcha ""` hands this `Some("")`, and an
1205        // override with nothing to answer for is the silent default
1206        // `ALLOW_UNPROTECTED_WRITES` already refuses to be — its
1207        // `unprotected_writes_override` trims and drops an empty one.
1208        // Two ways to the same waiver, and only one of them asked for a
1209        // reason.
1210        for nothing in ["", "   ", "\t\n"] {
1211            assert_eq!(
1212                production_readiness(
1213                    VentureEnv::Production,
1214                    &guards,
1215                    None,
1216                    true,
1217                    false,
1218                    Some(nothing),
1219                    None
1220                )
1221                .len(),
1222                1,
1223                "an empty reason waived the captcha gate: {nothing:?}"
1224            );
1225        }
1226    }
1227
1228    #[test]
1229    fn production_signature_without_payments_refuses() {
1230        let guards = WriteGuards::collect(&[module(
1231            "billing",
1232            vec![Action::post("webhook", "/webhook").policy(RoutePolicy::Signature)],
1233            false,
1234        )]);
1235        let errors = production_readiness(
1236            VentureEnv::Production,
1237            &guards,
1238            None,
1239            true,
1240            false,
1241            None,
1242            None,
1243        );
1244        assert_eq!(errors.len(), 1);
1245        assert!(errors[0].contains("billing"));
1246        assert!(errors[0].contains("Payments"));
1247        // No override for the payments leg — webhooks cannot be "previewed"
1248        // unverified without shipping unsigned money events.
1249        assert_eq!(
1250            production_readiness(
1251                VentureEnv::Production,
1252                &guards,
1253                None,
1254                true,
1255                false,
1256                Some("preview"),
1257                None
1258            )
1259            .len(),
1260            1
1261        );
1262    }
1263
1264    // ------------------------------------------------ issue #533
1265
1266    use crate::config::MapConfig;
1267
1268    /// A webhook receiver verifying with the core HMAC scheme, holding its
1269    /// secret under `{MODULE}_WEBHOOK_SECRET`.
1270    fn hmac_module(name: &'static str) -> Arc<dyn Module> {
1271        Arc::new(Guarded {
1272            name,
1273            surface: Surface {
1274                actions: vec![Action::post("webhook", "/webhook").policy(RoutePolicy::Signature)],
1275                views: vec![],
1276            },
1277            public_writes: false,
1278            public_write_policy: RoutePolicy::HumanForm,
1279            verification: SignatureVerification::Hmac {
1280                secret: "WEBHOOK_SECRET",
1281            },
1282        })
1283    }
1284
1285    #[test]
1286    fn an_hmac_signature_writer_needs_no_payments_and_names_its_secret_key() {
1287        let guards = WriteGuards::collect(&[hmac_module("pos")]);
1288        assert_eq!(guards.signature_modules, ["pos"]);
1289        assert!(!guards.needs_payments());
1290        assert_eq!(
1291            guards.hmac_signature_secrets(),
1292            vec![("pos".to_owned(), "POS_WEBHOOK_SECRET".to_owned())],
1293            "the full key is composed through ModuleConfig's prefix rule"
1294        );
1295    }
1296
1297    #[test]
1298    fn the_webhook_secret_gate_is_production_only() {
1299        let guards = WriteGuards::collect(&[hmac_module("pos")]);
1300        // No runtime at all — no Payments port anywhere — and production is
1301        // clean once the secret is set.
1302        let set = MapConfig::from_pairs([("POS_WEBHOOK_SECRET", "whsec-test-dummy")]);
1303        assert!(
1304            production_readiness(
1305                VentureEnv::Production,
1306                &guards,
1307                None,
1308                true,
1309                false,
1310                None,
1311                None
1312            )
1313            .is_empty()
1314        );
1315        assert!(webhook_secret_readiness(VentureEnv::Production, &guards, &set).is_empty());
1316        // Missing or blank refuses in production, naming the module and the
1317        // key; staging demands nothing (the handler refuses every delivery
1318        // per request anyway).
1319        for config in [
1320            MapConfig::default(),
1321            MapConfig::from_pairs([("POS_WEBHOOK_SECRET", "   ")]),
1322        ] {
1323            let errors = webhook_secret_readiness(VentureEnv::Production, &guards, &config);
1324            assert_eq!(errors.len(), 1, "{errors:?}");
1325            assert!(
1326                errors[0].contains("pos") && errors[0].contains("POS_WEBHOOK_SECRET"),
1327                "{}",
1328                errors[0]
1329            );
1330        }
1331        assert!(
1332            webhook_secret_readiness(VentureEnv::Staging, &guards, &MapConfig::default())
1333                .is_empty()
1334        );
1335    }
1336
1337    #[test]
1338    fn a_mixed_venture_gates_payments_only_for_the_module_that_names_it() {
1339        // A sidecar document cannot name a verifier, so it keeps the
1340        // pre-#533 behaviour: the Payments gate.
1341        let surface = Surface {
1342            actions: vec![Action::post("webhook", "/webhook").policy(RoutePolicy::Signature)],
1343            views: vec![],
1344        };
1345        assert!(WriteGuards::from_surface("remote", &surface).needs_payments());
1346        // A mixed venture still demands Payments for exactly the module
1347        // that verifies through it.
1348        let guards = WriteGuards::collect(&[
1349            hmac_module("pos"),
1350            declaring("billing", RoutePolicy::Signature),
1351        ]);
1352        assert!(guards.needs_payments());
1353        let errors = production_readiness(
1354            VentureEnv::Production,
1355            &guards,
1356            None,
1357            true,
1358            false,
1359            None,
1360            None,
1361        );
1362        assert_eq!(errors.len(), 1, "{errors:?}");
1363        assert!(
1364            errors[0].contains("billing") && !errors[0].contains("pos"),
1365            "{}",
1366            errors[0]
1367        );
1368    }
1369
1370    // ------------------------------------------------ issue #595
1371
1372    /// A billing module with two webhooks and two verifiers: the Stripe
1373    /// route keeps the module default (`Payments`), the `RevenueCat` route
1374    /// names its own HMAC secret.
1375    fn two_verifier_billing() -> Arc<dyn Module> {
1376        module(
1377            "billing",
1378            vec![
1379                Action::post("webhook-stripe", "/webhooks/stripe").policy(RoutePolicy::Signature),
1380                Action::post("webhook-revenuecat", "/webhooks/revenuecat")
1381                    .policy(RoutePolicy::Signature)
1382                    .verification(SignatureVerification::Hmac {
1383                        secret: "REVENUECAT_WEBHOOK_SECRET",
1384                    }),
1385            ],
1386            false,
1387        )
1388    }
1389
1390    #[test]
1391    fn a_two_verifier_module_composes_and_gates_each_verifier_by_route() {
1392        struct ProvidesPayments;
1393        impl Runtime for ProvidesPayments {
1394            fn provides(&self) -> Vec<Port> {
1395                vec![Port::Payments]
1396            }
1397        }
1398        let guards = WriteGuards::collect(&[two_verifier_billing()]);
1399        // One module in both lists, each exactly once (issue #595).
1400        assert_eq!(guards.signature_modules, ["billing"]);
1401        assert_eq!(guards.payments_signature_modules, ["billing"]);
1402        assert_eq!(
1403            guards.hmac_signature_secrets(),
1404            vec![(
1405                "billing".to_owned(),
1406                "BILLING_REVENUECAT_WEBHOOK_SECRET".to_owned()
1407            )]
1408        );
1409
1410        // The Payments leg fires for the Stripe route only, and names it.
1411        let errors = production_readiness(
1412            VentureEnv::Production,
1413            &guards,
1414            None,
1415            true,
1416            false,
1417            None,
1418            None,
1419        );
1420        assert_eq!(errors.len(), 1, "{errors:?}");
1421        assert!(
1422            errors[0].contains("POST /v1/billing/webhooks/stripe"),
1423            "{}",
1424            errors[0]
1425        );
1426        assert!(
1427            !errors[0].contains("POST /v1/billing/webhooks/revenuecat"),
1428            "{}",
1429            errors[0]
1430        );
1431
1432        // The HMAC leg fires for the RevenueCat route only, naming the key
1433        // and the route.
1434        let missing =
1435            webhook_secret_readiness(VentureEnv::Production, &guards, &MapConfig::default());
1436        assert_eq!(missing.len(), 1, "{missing:?}");
1437        assert!(
1438            missing[0].contains("BILLING_REVENUECAT_WEBHOOK_SECRET")
1439                && missing[0].contains("POST /v1/billing/webhooks/revenuecat"),
1440            "{}",
1441            missing[0]
1442        );
1443
1444        // Both verifiers satisfied: a runtime with an effective Payments
1445        // port and the secret set is clean.
1446        let runtime: Arc<dyn Runtime> = Arc::new(ProvidesPayments);
1447        let set = MapConfig::from_pairs([("BILLING_REVENUECAT_WEBHOOK_SECRET", "whsec-dummy")]);
1448        assert!(
1449            production_readiness(
1450                VentureEnv::Production,
1451                &guards,
1452                Some(&runtime),
1453                true,
1454                false,
1455                None,
1456                None,
1457            )
1458            .is_empty()
1459        );
1460        assert!(webhook_secret_readiness(VentureEnv::Production, &guards, &set).is_empty());
1461    }
1462
1463    #[test]
1464    fn from_surface_honours_a_route_verifier_on_an_in_process_surface() {
1465        // `verification` is `#[serde(skip)]`, so a sidecar document cannot
1466        // carry it: this builds the `Surface` in process to exercise the
1467        // `from_surface` branch. It cannot ask a module for its default, so
1468        // a route that names `Hmac` gets it — the `Payments` default is the
1469        // #533 test above (issue #595).
1470        let surface = Surface {
1471            actions: vec![
1472                Action::post("revenuecat", "/webhooks/revenuecat")
1473                    .policy(RoutePolicy::Signature)
1474                    .verification(SignatureVerification::Hmac {
1475                        secret: "REVENUECAT_WEBHOOK_SECRET",
1476                    }),
1477            ],
1478            views: vec![],
1479        };
1480        let guards = WriteGuards::from_surface("billing", &surface);
1481        assert!(!guards.needs_payments());
1482        assert_eq!(
1483            guards.hmac_signature_secrets(),
1484            vec![(
1485                "billing".to_owned(),
1486                "BILLING_REVENUECAT_WEBHOOK_SECRET".to_owned()
1487            )]
1488        );
1489    }
1490
1491    // ------------------------------------------------ issue #143
1492
1493    #[test]
1494    fn the_deployment_environment_is_the_stricter_of_the_two() {
1495        // The bug: `ventures/cratefield-waitlist` ships ENV="production"
1496        // and never calls `.env()`, so every production rule read the
1497        // compiled `Development` and did not apply in production.
1498        let deployed = MapConfig::from_pairs([("ENV", "production")]);
1499        assert_eq!(
1500            deployed_env(VentureEnv::Development, &deployed),
1501            VentureEnv::Production,
1502        );
1503        // And the other way: a binding must not be able to switch the
1504        // protections off for a venture compiled as production.
1505        let downgrade = MapConfig::from_pairs([("ENV", "development")]);
1506        assert_eq!(
1507            deployed_env(VentureEnv::Production, &downgrade),
1508            VentureEnv::Production,
1509        );
1510        // No binding at all leaves the compiled answer standing.
1511        assert_eq!(
1512            deployed_env(VentureEnv::Staging, &MapConfig::default()),
1513            VentureEnv::Staging,
1514        );
1515        // Agreement is not a disagreement.
1516        assert!(env_disagreement(VentureEnv::Production, VentureEnv::Production).is_none());
1517        let note = env_disagreement(VentureEnv::Development, VentureEnv::Production)
1518            .expect("a compiled-vs-deployed mismatch is reported");
1519        assert!(note.contains("ENV=production"), "{note}");
1520        // The literal is split across lines; it renders as one sentence (issue #477).
1521        assert!(!note.contains("  "), "{note}");
1522        assert!(
1523            note.contains("compiled with VentureEnv::Development: the production readiness gate"),
1524            "{note}"
1525        );
1526    }
1527
1528    #[test]
1529    fn a_surface_less_writer_declares_what_actually_guards_it() {
1530        // Saying nothing is unchanged: still the conservative CAPTCHA
1531        // fallback, so no existing module moves.
1532        let guards = WriteGuards::collect(&[module("legacy", vec![], true)]);
1533        assert_eq!(guards.captcha_modules, ["legacy"]);
1534        assert!(!guards.needs_signer());
1535
1536        // A passkey ceremony or an OAuth callback is proved by an artifact
1537        // this service issued, and will never render a widget.
1538        let guards = WriteGuards::collect(&[declaring("passkeys", RoutePolicy::SignedLink)]);
1539        assert!(
1540            guards.captcha_modules.is_empty(),
1541            "a signed-link writer must not demand a captcha it never renders"
1542        );
1543        assert_eq!(guards.signed_link_modules, ["passkeys"]);
1544        assert!(guards.needs_signer());
1545    }
1546
1547    #[test]
1548    fn a_signed_link_writer_still_has_a_production_requirement_of_its_own() {
1549        // Not an exemption: without a Signer there is nothing to issue or
1550        // verify the artifact with, so production still refuses.
1551        let guards = WriteGuards::collect(&[declaring("passkeys", RoutePolicy::SignedLink)]);
1552        let errors = production_readiness(
1553            VentureEnv::Production,
1554            &guards,
1555            None,
1556            true,
1557            false,
1558            None,
1559            None,
1560        );
1561        assert_eq!(errors.len(), 1, "{errors:?}");
1562        assert!(errors[0].contains("signed-link"), "{}", errors[0]);
1563        assert!(errors[0].contains("passkeys"), "{}", errors[0]);
1564
1565        // And nothing is demanded outside production.
1566        assert!(
1567            production_readiness(VentureEnv::Staging, &guards, None, true, false, None, None)
1568                .is_empty()
1569        );
1570    }
1571
1572    #[test]
1573    fn a_production_venture_needs_a_resolved_signer() {
1574        // Issue #478: the runtime advertises the Signer port, but a
1575        // harness configuration that fails to parse (a bad `ENV`, a short
1576        // `ADMIN_TOKEN`) leaves `ports.signer == None`. Advertised is not
1577        // resolved, so the caller's answer decides the leg.
1578        struct Advertises;
1579        impl Runtime for Advertises {
1580            fn provides(&self) -> Vec<Port> {
1581                vec![Port::Signer]
1582            }
1583        }
1584        let runtime: Arc<dyn Runtime> = Arc::new(Advertises);
1585        let guards = WriteGuards::collect(&[declaring("passkeys", RoutePolicy::SignedLink)]);
1586        let check = |env, signer_ready| {
1587            production_readiness(env, &guards, Some(&runtime), true, signer_ready, None, None)
1588        };
1589
1590        let errors = check(VentureEnv::Production, false);
1591        assert_eq!(errors.len(), 1, "{errors:?}");
1592        assert!(errors[0].contains("Signer"), "{}", errors[0]);
1593        // The literal is split across lines; it renders as one sentence, the
1594        // same fix #511 made to the ENV warning (#512 made this reachable at boot).
1595        assert!(!errors[0].contains("  "), "{}", errors[0]);
1596        assert!(
1597            errors[0].contains("but the Signer port is not provided"),
1598            "{}",
1599            errors[0]
1600        );
1601        assert!(check(VentureEnv::Production, true).is_empty());
1602        assert!(check(VentureEnv::Development, false).is_empty());
1603    }
1604
1605    #[test]
1606    fn a_declared_policy_on_an_action_still_wins_over_the_fallback() {
1607        // The fallback applies only when the surface declares nothing —
1608        // a module with a guarded action is untouched by #143.
1609        let guards = WriteGuards::collect(&[Arc::new(Guarded {
1610            name: "mixed",
1611            surface: Surface {
1612                actions: vec![Action::post("join", "/join").policy(RoutePolicy::HumanForm)],
1613                views: vec![],
1614            },
1615            public_writes: true,
1616            public_write_policy: RoutePolicy::SignedLink,
1617            verification: SignatureVerification::Payments,
1618        })]);
1619        assert_eq!(guards.captcha_modules, ["mixed"]);
1620        assert!(
1621            guards.signed_link_modules.is_empty(),
1622            "the module-level fallback must not override a declared action"
1623        );
1624    }
1625
1626    // ------------------------------------------------ issue #437
1627
1628    #[test]
1629    fn admin_and_public_writes_are_recorded_on_the_guards() {
1630        // An admin action alone is enough: the bearer token is guessed,
1631        // not submitted, so the budget question is not a corollary of the
1632        // public-write one.
1633        let guards = WriteGuards::collect(&[module(
1634            "exports",
1635            vec![Action::post("export", "/admin/export").audience(Audience::Admin)],
1636            false,
1637        )]);
1638        assert!(guards.has_admin_routes);
1639        assert!(!guards.has_public_writes);
1640        assert!(guards.needs_rate_limiter());
1641
1642        // The path half of the predicate, for an action whose audience
1643        // was never declared.
1644        let guards = WriteGuards::collect(&[module(
1645            "landing",
1646            vec![Action::get("console", "/admin/console")],
1647            false,
1648        )]);
1649        assert!(guards.has_admin_routes);
1650
1651        // The module-level fallback flag counts as a public write, the
1652        // way the captcha fallback does.
1653        let guards = WriteGuards::collect(&[module("legacy", Vec::new(), true)]);
1654        assert!(guards.has_public_writes);
1655        assert!(!guards.has_admin_routes);
1656
1657        // And a webhook is an unauthenticated endpoint: it counts for the
1658        // limiter leg even though it asks nothing of the captcha one.
1659        let guards = WriteGuards::collect(&[module(
1660            "billing",
1661            vec![Action::post("webhook", "/webhook").policy(RoutePolicy::Signature)],
1662            false,
1663        )]);
1664        assert!(guards.has_public_writes);
1665    }
1666
1667    #[test]
1668    fn a_production_venture_needs_a_resolved_rate_limiter() {
1669        let guards = WriteGuards::collect(&[module(
1670            "forms",
1671            vec![Action::post("join", "/").captcha()],
1672            false,
1673        )]);
1674
1675        // Advertised is not resolved: the Cloudflare runtime reports the
1676        // port from a configured binding name and still hands over `None`
1677        // when the binding fails to resolve. The caller says which
1678        // happened, and the captcha waiver next door does not reach this
1679        // leg.
1680        let unlimited = production_readiness(
1681            VentureEnv::Production,
1682            &guards,
1683            None,
1684            false,
1685            false,
1686            Some("preview"),
1687            None,
1688        );
1689        assert_eq!(unlimited.len(), 1, "{unlimited:?}");
1690        assert!(unlimited[0].contains("RateLimiter"), "{}", unlimited[0]);
1691        assert!(
1692            unlimited[0].contains(ALLOW_UNLIMITED_PUBLIC_ROUTES),
1693            "{}",
1694            unlimited[0]
1695        );
1696
1697        // Resolved: the leg passes.
1698        assert!(
1699            production_readiness(
1700                VentureEnv::Production,
1701                &guards,
1702                None,
1703                true,
1704                false,
1705                Some("preview"),
1706                None
1707            )
1708            .is_empty()
1709        );
1710
1711        // Waived with a reason it passes; waived with a blank one it does
1712        // not — the same "a blank reason is not an acceptance" rule the
1713        // captcha override runs on.
1714        assert!(
1715            production_readiness(
1716                VentureEnv::Production,
1717                &guards,
1718                None,
1719                false,
1720                false,
1721                Some("preview"),
1722                Some("issue #437: between pivots"),
1723            )
1724            .is_empty()
1725        );
1726        for nothing in ["", "   "] {
1727            assert_eq!(
1728                production_readiness(
1729                    VentureEnv::Production,
1730                    &guards,
1731                    None,
1732                    false,
1733                    false,
1734                    Some("preview"),
1735                    Some(nothing),
1736                )
1737                .len(),
1738                1,
1739                "an empty reason waived the limiter gate: {nothing:?}"
1740            );
1741        }
1742    }
1743
1744    #[test]
1745    fn a_module_with_only_admin_routes_still_needs_the_limiter() {
1746        let guards = WriteGuards::collect(&[module(
1747            "exports",
1748            vec![Action::post("export", "/admin/export").audience(Audience::Admin)],
1749            false,
1750        )]);
1751        assert!(guards.captcha_modules.is_empty());
1752        let errors = production_readiness(
1753            VentureEnv::Production,
1754            &guards,
1755            None,
1756            false,
1757            false,
1758            None,
1759            None,
1760        );
1761        assert_eq!(errors.len(), 1, "{errors:?}");
1762        assert!(errors[0].contains("RateLimiter"), "{}", errors[0]);
1763        assert!(
1764            errors[0].contains(ALLOW_UNLIMITED_PUBLIC_ROUTES),
1765            "{}",
1766            errors[0]
1767        );
1768    }
1769
1770    // ------------------------------------------------ issue #562
1771
1772    /// A module that declares `Port::RateLimiter` (optional, the common
1773    /// shape — the waitlist and auth modules all declare it there) and
1774    /// publishes public routes.
1775    struct DeclaresLimiter {
1776        name: &'static str,
1777        surface: Surface,
1778    }
1779
1780    impl Module for DeclaresLimiter {
1781        fn name(&self) -> &'static str {
1782            self.name
1783        }
1784        fn version(&self) -> &'static str {
1785            "0.0.0-test"
1786        }
1787        fn requires(&self) -> &'static [Port] {
1788            &[]
1789        }
1790        fn optional(&self) -> &'static [Port] {
1791            &[Port::RateLimiter]
1792        }
1793        fn migrations(&self) -> Migrations {
1794            Migrations::default()
1795        }
1796        fn validate_config(
1797            &self,
1798            _: &dyn crate::config::Config,
1799        ) -> Result<(), crate::config::ConfigError> {
1800            Ok(())
1801        }
1802        fn surface(&self) -> Surface {
1803            self.surface.clone()
1804        }
1805        fn router(&self, _: crate::ModuleContext) -> axum::Router {
1806            axum::Router::new()
1807        }
1808    }
1809
1810    #[test]
1811    fn a_declared_limiter_over_public_reads_needs_one_resolved() {
1812        // A public search endpoint is not a write and not the admin
1813        // plane, so the #437 triggers stay silent — but the module says
1814        // it throttles, and the gate holds the venture to that claim
1815        // (issue #562).
1816        let guards = WriteGuards::collect(&[Arc::new(DeclaresLimiter {
1817            name: "search",
1818            surface: Surface {
1819                actions: vec![
1820                    Action::get("query", "/search").audience(Audience::Public),
1821                    Action::get("suggest", "/suggest").audience(Audience::Public),
1822                ],
1823                views: vec![],
1824            },
1825        })]);
1826        assert!(guards.captcha_modules.is_empty());
1827        assert!(!guards.has_public_writes);
1828        assert!(!guards.has_admin_routes);
1829        assert_eq!(
1830            guards.rate_limiter_modules,
1831            vec![(
1832                "search".to_owned(),
1833                vec![
1834                    "GET /v1/search/search".to_owned(),
1835                    "GET /v1/search/suggest".to_owned(),
1836                ]
1837            )]
1838        );
1839        assert!(guards.needs_rate_limiter());
1840
1841        // Unresolved: the error names the module and its routes.
1842        let errors = production_readiness(
1843            VentureEnv::Production,
1844            &guards,
1845            None,
1846            false,
1847            false,
1848            None,
1849            None,
1850        );
1851        assert_eq!(errors.len(), 1, "{errors:?}");
1852        assert!(errors[0].contains("search"), "{}", errors[0]);
1853        assert!(errors[0].contains("GET /v1/search/search"), "{}", errors[0]);
1854
1855        // Resolved: the leg passes.
1856        assert!(
1857            production_readiness(
1858                VentureEnv::Production,
1859                &guards,
1860                None,
1861                true,
1862                false,
1863                None,
1864                None
1865            )
1866            .is_empty()
1867        );
1868
1869        // The waiver still waives, and non-production never gates.
1870        assert!(
1871            production_readiness(
1872                VentureEnv::Production,
1873                &guards,
1874                None,
1875                false,
1876                false,
1877                None,
1878                Some("issue #562: binding pending"),
1879            )
1880            .is_empty()
1881        );
1882        for env in [VentureEnv::Development, VentureEnv::Staging] {
1883            assert!(
1884                production_readiness(env, &guards, None, false, false, None, None).is_empty(),
1885                "{env:?} must not gate"
1886            );
1887        }
1888    }
1889
1890    #[test]
1891    fn a_declared_limiter_error_names_each_module_and_its_routes() {
1892        // Two declaring modules, and a module declaring the port with no
1893        // public route to hang it on contributes nothing to the list.
1894        let guards = WriteGuards::collect(&[
1895            Arc::new(DeclaresLimiter {
1896                name: "waitlist",
1897                surface: Surface {
1898                    actions: vec![Action::post("join", "/").captcha()],
1899                    views: vec![],
1900                },
1901            }),
1902            Arc::new(DeclaresLimiter {
1903                name: "quiet",
1904                surface: Surface {
1905                    actions: vec![
1906                        Action::post("export", "/admin/export").audience(Audience::Admin),
1907                    ],
1908                    views: vec![],
1909                },
1910            }),
1911        ]);
1912        assert_eq!(
1913            guards.rate_limiter_modules,
1914            vec![("waitlist".to_owned(), vec!["POST /v1/waitlist".to_owned()])]
1915        );
1916        let errors = production_readiness(
1917            VentureEnv::Production,
1918            &guards,
1919            None,
1920            false,
1921            false,
1922            Some("preview"),
1923            None,
1924        );
1925        assert_eq!(errors.len(), 1, "{errors:?}");
1926        assert!(errors[0].contains("waitlist (POST /v1/waitlist)"));
1927    }
1928
1929    #[test]
1930    fn the_undeclared_limiter_error_keeps_its_own_message() {
1931        // Without a declaring module the leg keeps the #437 message —
1932        // there is nothing to name, and existing operators read that
1933        // text in their doctor output and logs.
1934        let guards = WriteGuards::collect(&[module(
1935            "forms",
1936            vec![Action::post("join", "/").captcha()],
1937            false,
1938        )]);
1939        assert!(guards.rate_limiter_modules.is_empty());
1940        // The captcha leg is waived aside so the assertion stays about
1941        // which limiter message fired (the captcha waiver never reaches
1942        // this leg — see the #437 test above).
1943        let errors = production_readiness(
1944            VentureEnv::Production,
1945            &guards,
1946            None,
1947            false,
1948            false,
1949            Some("preview"),
1950            None,
1951        );
1952        assert_eq!(errors.len(), 1, "{errors:?}");
1953        assert!(
1954            errors[0].contains("takes public writes or admin routes"),
1955            "{}",
1956            errors[0]
1957        );
1958        assert!(!errors[0].contains("declares RateLimiter"));
1959    }
1960
1961    // ------------------------------------------------ issue #532
1962
1963    #[test]
1964    fn an_api_key_policy_serializes_as_api_key_and_guards() {
1965        assert_eq!(
1966            serde_json::to_string(&RoutePolicy::ApiKey).expect("serializes"),
1967            "\"api-key\""
1968        );
1969        let policy: RoutePolicy = serde_json::from_str("\"api-key\"").expect("deserializes");
1970        assert_eq!(policy, RoutePolicy::ApiKey);
1971        assert!(policy.is_guarded());
1972    }
1973
1974    #[test]
1975    fn an_api_key_writer_demands_no_captcha_and_no_port() {
1976        // A declared api-key action is read as declared: no captcha (a
1977        // developer machine cannot fill one), no Signer, no Payments —
1978        // and not the public-write leg either, because its budget is the
1979        // per-key limiter hook, not a boot gate.
1980        let guards = WriteGuards::collect(&[module(
1981            "devapi",
1982            vec![Action::post("sync", "/sync").policy(RoutePolicy::ApiKey)],
1983            false,
1984        )]);
1985        assert!(guards.captcha_modules.is_empty());
1986        assert!(guards.signature_modules.is_empty());
1987        assert!(guards.signed_link_modules.is_empty());
1988        assert!(!guards.has_public_writes);
1989        assert!(!guards.needs_rate_limiter());
1990
1991        // The surface-less fallback is the same: guarded, never pulled
1992        // into the captcha gate.
1993        let guards = WriteGuards::collect(&[declaring("devapi", RoutePolicy::ApiKey)]);
1994        assert!(
1995            guards.captcha_modules.is_empty(),
1996            "an api-key writer must not demand a captcha it never renders"
1997        );
1998        assert!(!guards.needs_signer());
1999        assert!(!guards.needs_payments());
2000    }
2001}