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::{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}
169
170impl WriteGuards {
171    /// Reads each module's declared surface. A module whose surface has
172    /// no policy-guarded write but still sets [`public_writes`] counts as
173    /// needing a CAPTCHA — the undeclared-public-writer fallback keeps
174    /// out-of-tree and surface-less modules (the auth crates predate
175    /// ADR 0010 surfaces) under the same gate instead of silently
176    /// exempting them.
177    ///
178    /// [`public_writes`]: Module::public_writes
179    #[must_use]
180    pub fn collect(modules: &[Arc<dyn Module>]) -> Self {
181        let mut guards = Self::default();
182        for module in modules {
183            let surface: Surface = module.surface();
184            let (mut form, mut signature) = Self::surface_flags(&surface);
185            let mut signed_link = surface
186                .actions
187                .iter()
188                .any(|action| action.policy == RoutePolicy::SignedLink);
189            // The undeclared-public-writer fallback only applies when
190            // nothing in the surface is guarded. What it falls back *to*
191            // is the module's own answer (issue #143): a surface-less
192            // module has no action to hang a policy on, so this is the
193            // only place it can say what protects its writes. The default
194            // is still `HumanForm`, so saying nothing changes nothing.
195            if !form && !signature && !signed_link && module.public_writes() {
196                match module.public_write_policy() {
197                    RoutePolicy::Signature => signature = true,
198                    RoutePolicy::SignedLink => signed_link = true,
199                    // An API-key writer (issue #532) is guarded — it is
200                    // never pulled into the captcha fallback — but it
201                    // demands no runtime port (the key store is the
202                    // app's) and no production-legible decision: its
203                    // budget is the per-key limiter hook, not a boot
204                    // gate.
205                    RoutePolicy::ApiKey => {}
206                    RoutePolicy::HumanForm | RoutePolicy::Open => form = true,
207                }
208            }
209            if form {
210                guards.captcha_modules.push(module.name().to_owned());
211            }
212            if signature {
213                guards.signature_modules.push(module.name().to_owned());
214                // Whichever verifier the module declared (issue #533).
215                match module.signature_verification() {
216                    SignatureVerification::Payments => {
217                        guards
218                            .payments_signature_modules
219                            .push(module.name().to_owned());
220                    }
221                    SignatureVerification::Hmac { secret } => {
222                        guards
223                            .hmac_signature_modules
224                            .push((module.name().to_owned(), secret));
225                    }
226                }
227            }
228            if signed_link {
229                guards.signed_link_modules.push(module.name().to_owned());
230            }
231            // The limiter leg (issue #437) asks two broader questions
232            // than the per-policy lists: is anything writable publicly
233            // at all, and is there an admin plane to brute-force. The
234            // path check is the surface validation rule (`Audience::Admin`
235            // lives under `/admin`), applied in both directions so an
236            // undeclared admin path is caught even when the audience is
237            // missing.
238            if form || signature || signed_link || module.public_writes() {
239                guards.has_public_writes = true;
240            }
241            if Self::declares_admin_routes(&surface) {
242                guards.has_admin_routes = true;
243            }
244        }
245        guards
246    }
247
248    /// The guards a **declared surface** demands (issue #131). A sidecar's
249    /// merged surface is not a [`Module`] this process can ask
250    /// [`public_writes`](Module::public_writes) or
251    /// [`signature_verification`](Module::signature_verification) of, so
252    /// whatever the document declares is exactly what it needs, and its
253    /// signature routes verify through `Payments`, the default (issue
254    /// #533). Same per-action reading as [`Self::collect`]
255    /// through the one [`Action::demands_captcha`] predicate and the one
256    /// `declares_admin_routes` predicate — a sidecar's admin plane needs
257    /// the limiter floor as much as an in-process module's.
258    ///
259    /// [`Action::demands_captcha`]: crate::surface::Action::demands_captcha
260    #[must_use]
261    pub fn from_surface(module: &str, surface: &Surface) -> Self {
262        let (form, signature) = Self::surface_flags(surface);
263        let signed_link = surface
264            .actions
265            .iter()
266            .any(|action| action.policy == RoutePolicy::SignedLink);
267        Self {
268            captcha_modules: form.then(|| module.to_owned()).into_iter().collect(),
269            signature_modules: signature.then(|| module.to_owned()).into_iter().collect(),
270            payments_signature_modules: signature.then(|| module.to_owned()).into_iter().collect(),
271            hmac_signature_modules: Vec::new(),
272            signed_link_modules: signed_link.then(|| module.to_owned()).into_iter().collect(),
273            has_public_writes: form || signature || signed_link,
274            has_admin_routes: Self::declares_admin_routes(surface),
275        }
276    }
277
278    /// Whether any action needs a captcha and whether any is a signature
279    /// route. The `Open`-with-`captcha` legacy mirror resolves inside
280    /// [`Action::demands_captcha`].
281    fn surface_flags(surface: &Surface) -> (bool, bool) {
282        let mut form = false;
283        let mut signature = false;
284        for action in &surface.actions {
285            if action.demands_captcha() {
286                form = true;
287            }
288            if action.policy == RoutePolicy::Signature {
289                signature = true;
290            }
291        }
292        (form, signature)
293    }
294
295    /// Whether the surface declares the admin plane: an
296    /// [`Audience::Admin`](crate::surface::Audience::Admin) action, or any
297    /// path under `/admin`. Both spellings, so an action whose audience was
298    /// forgotten is still counted by where it sits — the same rule
299    /// [`Surface::validate`] enforces, read here instead of trusted.
300    ///
301    /// [`Surface::validate`]: crate::surface::Surface::validate
302    fn declares_admin_routes(surface: &Surface) -> bool {
303        surface.actions.iter().any(|action| {
304            action.audience == Audience::Admin
305                || action.path == "/admin"
306                || action.path.starts_with("/admin/")
307        })
308    }
309
310    /// Whether any module proves a public write with an artifact this
311    /// service issued, and so needs a usable [`Signer`] (issue #143).
312    ///
313    /// [`Signer`]: crate::ports::Signer
314    #[must_use]
315    pub fn needs_signer(&self) -> bool {
316        !self.signed_link_modules.is_empty()
317    }
318
319    /// Whether any module needs the `Captcha` port.
320    #[must_use]
321    pub fn needs_captcha(&self) -> bool {
322        !self.captcha_modules.is_empty()
323    }
324
325    /// Whether any module proves a public write with
326    /// [`Payments::verify_webhook`](crate::ports::Payments). Since issue
327    /// #533 this is no longer "some module receives signed webhooks": a
328    /// module may declare [`SignatureVerification::Hmac`] and verify with
329    /// the core `webhook_signature` scheme instead, and this answers
330    /// `false` for it — its production requirement is its secret key, not a
331    /// port (see [`webhook_secret_readiness`]). The `STRIPE_WEBHOOK_SECRET`
332    /// doctor rule is keyed on this, correctly.
333    #[must_use]
334    pub fn needs_payments(&self) -> bool {
335        !self.payments_signature_modules.is_empty()
336    }
337
338    /// The HMAC-verified signature modules and the **full** config key each
339    /// reads its secret from, composed through
340    /// [`ModuleConfig`](crate::config::ModuleConfig)'s `{MODULE}_{SUFFIX}`
341    /// rule — the one place the prefix logic lives (issue #533).
342    #[must_use]
343    pub fn hmac_signature_secrets(&self) -> Vec<(String, String)> {
344        self.hmac_signature_modules
345            .iter()
346            .map(|(module, suffix)| {
347                let key = crate::config::ModuleConfig::new(module, &crate::config::EmptyConfig)
348                    .key(suffix);
349                (module.clone(), key)
350            })
351            .collect()
352    }
353
354    /// Whether any route in the composition needs a limiter sitting in
355    /// front of it (issue #437): a public write or an admin route. The
356    /// admin plane is enough on its own — a bearer token is guessed, not
357    /// submitted, so a venture that publishes nothing still needs the
358    /// budget before its `/admin/*` routes.
359    #[must_use]
360    pub fn needs_rate_limiter(&self) -> bool {
361        self.has_public_writes || self.has_admin_routes
362    }
363}
364
365/// Whether the runtime's `Captcha` port can really verify a token in
366/// production: the port is provided **and**, when the adapter reports,
367/// hostname-bound and not fail-open. An adapter that does not report
368/// ([`Captcha::binding`] = `None`) counts as effective — presence is all
369/// the harness can know about a third-party implementation; the adapter
370/// contract carries the duty to fail closed per request.
371///
372/// [`Captcha::binding`]: crate::ports::Captcha::binding
373#[must_use]
374pub fn captcha_effective(runtime: Option<&Arc<dyn Runtime>>) -> bool {
375    runtime.is_some_and(|runtime| runtime.effectively_configured(Port::Captcha))
376}
377
378/// Whether the runtime can verify webhook signatures for the
379/// [`RoutePolicy::Signature`]-guarded routes whose modules verify through
380/// `Payments` (the default): signature verification is the adapter's
381/// per-request duty (`verify_webhook` + `Inbox`), and which secret it
382/// checks against is deploy configuration. A module that declared
383/// [`SignatureVerification::Hmac`] is not this function's business — its
384/// production requirement is its secret key, checked by
385/// [`webhook_secret_readiness`].
386#[must_use]
387pub fn payments_effective(runtime: Option<&Arc<dyn Runtime>>) -> bool {
388    runtime.is_some_and(|runtime| runtime.effectively_configured(Port::Payments))
389}
390
391/// The boot-time leg for HMAC-verified signature modules (issue #533): in
392/// production, each one's configured secret key must be present and
393/// non-blank, or the module's handler refuses every delivery. The errors
394/// name the module and the config key, never the value, and there is no
395/// waiver: an operator who wants the endpoint unverified removes the
396/// route, which is a build-time decision. Run by the boot gate
397/// ([`Harness::router`](crate::Harness::router)'s readiness re-check),
398/// which holds the deployment config; the build-time gate has none, so it
399/// skips this leg — the same split as the overrides there.
400#[must_use]
401pub fn webhook_secret_readiness(
402    env: VentureEnv,
403    guards: &WriteGuards,
404    config: &dyn crate::config::Config,
405) -> Vec<String> {
406    if env != VentureEnv::Production {
407        return Vec::new();
408    }
409    guards
410        .hmac_signature_secrets()
411        .iter()
412        .filter(|(_, key)| config.get(key).is_none_or(|value| value.trim().is_empty()))
413        .map(|(module, key)| {
414            format!(
415                "production venture verifies `{module}` webhook signatures with the \
416                 webhook_signature HMAC scheme but `{key}` is not set — its handler refuses \
417                 every delivery until the provider's signing secret is configured"
418            )
419        })
420        .collect()
421}
422
423/// The operator's reason, if they actually gave one.
424///
425/// `docs/SECURITY.md` puts it in four words — "A blank reason is not an
426/// acceptance" — and `unprotected_writes_override` already enforced it
427/// for `HARNESS_ALLOW_UNPROTECTED_WRITES`. The `fz doctor` flag reached
428/// the same gate without it: `--allow-no-captcha ""` arrives as
429/// `Some("")`, which waived a production abuse control and recorded an
430/// empty reason for it. A waiver with nothing to answer for is the
431/// silent default the gate exists to remove.
432///
433/// One function, so the two ways to the same waiver cannot disagree
434/// again.
435#[must_use]
436pub fn stated_reason(reason: Option<&str>) -> Option<&str> {
437    reason.map(str::trim).filter(|reason| !reason.is_empty())
438}
439
440/// Config key holding an operator's explicit, recorded acceptance that
441/// this deployment serves guarded routes it cannot fully protect
442/// (issue #143).
443///
444/// The value is the **reason**, and an empty one does not count: an
445/// override with nothing to answer for is the silent default this issue
446/// exists to remove. It is recorded through `tracing` on every boot, so
447/// it appears wherever the operator ships logs, and `fz doctor` reports
448/// it. It exists because the gate landed on ventures that had already
449/// been serving unprotected for months — refusing their traffic outright
450/// on the next deploy would trade a quiet hole for a loud outage without
451/// anyone choosing it. Wire the missing port and delete the key.
452pub const ALLOW_UNPROTECTED_WRITES: &str = "HARNESS_ALLOW_UNPROTECTED_WRITES";
453
454/// The operator's recorded reason for serving guarded routes unprotected,
455/// if they set one (issue #143).
456#[must_use]
457pub fn unprotected_writes_override(config: &dyn crate::config::Config) -> Option<String> {
458    config
459        .get(ALLOW_UNPROTECTED_WRITES)
460        .and_then(|raw| stated_reason(Some(raw.as_str())).map(str::to_owned))
461}
462
463/// Config key holding an operator's explicit, recorded acceptance that
464/// this deployment serves its public writes and admin routes with **no
465/// resolved rate limiter** (issue #437). Same contract as
466/// [`ALLOW_UNPROTECTED_WRITES`]: the value is the **reason**, and a blank
467/// one does not count; the boot gate records it once, wherever the
468/// operator ships logs. It exists because the limiter was opt-in — a
469/// runtime that failed to resolve its binding degraded to no limiter and
470/// served — and because the escape from that must be as deliberate as the
471/// gap it accepts. Resolve the binding and delete the key.
472pub const ALLOW_UNLIMITED_PUBLIC_ROUTES: &str = "HARNESS_ALLOW_UNLIMITED_PUBLIC_ROUTES";
473
474/// The operator's recorded reason for serving public writes and admin
475/// routes unlimited, if they set one (issue #437).
476#[must_use]
477pub fn unlimited_public_routes_override(config: &dyn crate::config::Config) -> Option<String> {
478    config
479        .get(ALLOW_UNLIMITED_PUBLIC_ROUTES)
480        .and_then(|raw| stated_reason(Some(raw.as_str())).map(str::to_owned))
481}
482
483/// The environment this **deployment** runs in (issue #143).
484///
485/// A venture carries a compiled [`VentureEnv`] — a builder default the
486/// operator cannot change without a rebuild — and a deployment carries
487/// an `ENV` binding it can. Every production-only rule used to read the
488/// compiled one alone, so a Worker deployed with `ENV = "production"`
489/// over a venture that never called [`Venture::env`] ran with all of
490/// them switched off. Neither source may downgrade the other: if either
491/// says `Production`, this is production.
492///
493/// [`Venture::env`]: crate::venture::Venture::env
494#[must_use]
495pub fn deployed_env(compiled: VentureEnv, config: &dyn crate::config::Config) -> VentureEnv {
496    let declared = config
497        .get("ENV")
498        .as_deref()
499        .and_then(VentureEnv::parse)
500        .unwrap_or_default();
501    compiled.strictest(declared)
502}
503
504/// Whether the deployment's environment contradicts the compiled one
505/// (issue #143). Not an error by itself — the stricter answer wins — but
506/// it means the build-time gate ran against the wrong environment, so
507/// the caller re-checks and says so.
508#[must_use]
509pub fn env_disagreement(compiled: VentureEnv, deployed: VentureEnv) -> Option<String> {
510    (compiled != deployed).then(|| {
511        format!(
512            "this deployment declares ENV={} but the venture was compiled with \
513             VentureEnv::{compiled:?}: the production readiness gate at build time ran \
514             against the wrong environment. Treating it as {} — call \
515             `.env(VentureEnv::{deployed:?})` on the venture so the build refuses what \
516             the deployment cannot serve (issue #143)",
517            deployed.as_str(),
518            deployed.as_str(),
519        )
520    })
521}
522
523/// Whether the runtime can issue and verify the artifacts a
524/// [`RoutePolicy::SignedLink`] route rests on (issue #143).
525///
526/// Like [`rate_limiter_effective`], the runtime's own, provisional
527/// answer: the native and Cloudflare runtimes advertise [`Port::Signer`]
528/// unconditionally and leave `ports.signer == None` with only a warning
529/// when the harness configuration does not parse (issue #478). The boot
530/// gate re-checks against the resolved port — [`production_readiness`]
531/// takes it as its `signer_ready` parameter.
532#[must_use]
533pub fn signer_effective(runtime: Option<&Arc<dyn Runtime>>) -> bool {
534    runtime.is_some_and(|runtime| runtime.effectively_configured(Port::Signer))
535}
536
537/// Whether the runtime has a limiter it can actually consult (issue #437).
538///
539/// This is the **runtime's own answer**, and a provisional one: it is all
540/// the build-time gate and `fz doctor` can know, because neither holds the
541/// resolved [`Ports`](crate::ports::Ports). A Cloudflare binding that is
542/// named but fails to resolve still reports `true` here — the adapter
543/// degrades to `ports.rate_limiter == None` with only a warning — so the
544/// boot gate re-checks against the port the runtime actually handed over:
545/// [`production_readiness`] takes that resolved answer as its
546/// `rate_limiter_ready` parameter and never computes it from this.
547#[must_use]
548pub fn rate_limiter_effective(runtime: Option<&Arc<dyn Runtime>>) -> bool {
549    runtime.is_some_and(|runtime| runtime.effectively_configured(Port::RateLimiter))
550}
551
552/// The production abuse-control gate (issue #133), as collected error
553/// strings (the [`ConfigError`] convention: report every problem at
554/// once). Empty for anything but `Production`.
555///
556/// The two overrides are deliberate, independent, recorded decisions:
557/// `allow_no_captcha` (the `fz doctor` flag, and
558/// `HARNESS_ALLOW_UNPROTECTED_WRITES` at the boot gate) downgrades only
559/// the CAPTCHA refusal, and `allow_unlimited`
560/// ([`ALLOW_UNLIMITED_PUBLIC_ROUTES`]) downgrades only the
561/// rate-limiter refusal (issue #437). One key waiving two controls is how
562/// the second stays unwired after the first is fixed.
563///
564/// The limiter leg is keyed on `rate_limiter_ready`, the **resolved**
565/// answer, and never inferred from [`Runtime::provides`]: the Cloudflare
566/// runtime reports [`Port::RateLimiter`] from a configured binding *name*,
567/// and a binding that fails to resolve degrades to
568/// `ports.rate_limiter == None` with only a `warn_once`. Callers holding
569/// ports pass `ports.rate_limiter.is_some()`; the build-time gate and
570/// `fz doctor` pass [`rate_limiter_effective`], the runtime's own answer.
571///
572/// The signer leg is keyed the same way on `signer_ready` (issue #478):
573/// the runtimes advertise [`Port::Signer`] yet leave `ports.signer ==
574/// None` when the harness configuration fails to parse. Callers holding
575/// ports pass `ports.signer.is_some()`; the build-time gate passes
576/// [`signer_effective`].
577///
578/// [`ConfigError`]: crate::config::ConfigError
579#[must_use]
580pub fn production_readiness(
581    env: VentureEnv,
582    guards: &WriteGuards,
583    runtime: Option<&Arc<dyn Runtime>>,
584    rate_limiter_ready: bool,
585    signer_ready: bool,
586    allow_no_captcha: Option<&str>,
587    allow_unlimited: Option<&str>,
588) -> Vec<String> {
589    if env != VentureEnv::Production {
590        return Vec::new();
591    }
592    let mut errors = Vec::new();
593    if guards.needs_captcha()
594        && !captcha_effective(runtime)
595        && stated_reason(allow_no_captcha).is_none()
596    {
597        errors.push(format!(
598            "production venture has captcha-guarded public writes from [{}] but the Captcha \
599             port is not effectively configured: provide it on the runtime with a bound \
600             adapter (Turnstile needs its secret and expected hostname) — `fz doctor \
601             --allow-no-captcha <reason>` overrides this check for previews only, and \
602             {ALLOW_UNPROTECTED_WRITES} records the acceptance in a serving deployment",
603            guards.captcha_modules.join(", ")
604        ));
605    }
606    if guards.needs_signer() && !signer_ready {
607        errors.push(format!(
608            "production venture has signed-link public writes from [{}] but the Signer port \
609             is not provided — the magic links, challenges and unsubscribe links those \
610             routes verify cannot be issued or checked without one (issue #143)",
611            guards.signed_link_modules.join(", ")
612        ));
613    }
614    if guards.needs_payments() && !payments_effective(runtime) {
615        errors.push(format!(
616            "production venture has signature-guarded routes from [{}] that verify through \
617             Payments but the Payments port is not provided — those webhook deliveries cannot \
618             be verified (see the Inbox dedup ledger and the STRIPE_WEBHOOK_SECRET doctor \
619             rule; modules verifying with the webhook_signature HMAC scheme instead are \
620             gated on their own secret key, not this port)",
621            guards.payments_signature_modules.join(", ")
622        ));
623    }
624    if guards.needs_rate_limiter()
625        && !rate_limiter_ready
626        && stated_reason(allow_unlimited).is_none()
627    {
628        errors.push(format!(
629            "production venture takes public writes or admin routes but the RateLimiter port \
630             is not resolved: admin bearer routes have no brute-force backstop behind the \
631             limiter (the fail-closed rule the sidecar forward runs) and every public write \
632             runs without a budget — resolve the binding so the runtime actually hands over \
633             a limiter, or set {ALLOW_UNLIMITED_PUBLIC_ROUTES} to a reason to serve \
634             unlimited (issue #437)"
635        ));
636    }
637    errors
638}
639
640/// The shared CAPTCHA gate for `HumanForm` handlers (issue #133): the
641/// single place a module asks "is this form submission a human?".
642///
643/// Fail-closed rules, in order:
644///
645/// - the port is present: the token must exist and verify — any
646///   non-`ok` verdict or transport error is a `captcha-failed` problem;
647/// - the port is absent in **production**: `captcha-failed` as well.
648///   `Harness::build` refuses that composition, so reaching this branch
649///   means a runtime lied about its binding — refuse rather than wave
650///   the request through. **Unless** `accepted_unprotected`: an operator
651///   has recorded that this deployment serves without the port
652///   (`HARNESS_ALLOW_UNPROTECTED_WRITES`, issue #143), the boot gate
653///   honoured that and served, and refusing here would be the same
654///   outage the acceptance exists to avoid, with a different status
655///   code. The record is the accountability, not this branch;
656/// - the port is absent in development/staging: allow, so a staging
657///   deploy can drive the form without a live Turnstile.
658///
659/// # Errors
660///
661/// A `captcha-failed` problem when the submission is not proven human.
662pub async fn verify_human_form(
663    captcha: Option<&Arc<dyn Captcha>>,
664    env: VentureEnv,
665    accepted_unprotected: bool,
666    token: Option<&str>,
667    remote_ip: Option<&str>,
668    instance: &str,
669) -> Result<(), Problem> {
670    let refused = || Problem::new(&SLUGS.captcha_failed).instance(instance);
671    match captcha {
672        Some(captcha) => {
673            let Some(token) = token else {
674                return Err(refused());
675            };
676            match captcha.verify(token, remote_ip).await {
677                Ok(verdict) if verdict.ok => Ok(()),
678                _ => Err(refused()),
679            }
680        }
681        // No port, in production, with nobody having accepted that: the
682        // composition `Harness::build` refuses is somehow serving, so
683        // refuse the request rather than wave it through.
684        None if env == VentureEnv::Production && !accepted_unprotected => Err(refused()),
685        None => Ok(()),
686    }
687}
688
689#[cfg(test)]
690mod tests {
691    use super::*;
692    use crate::Migrations;
693    use crate::ports::{CaptchaError, Verdict};
694    use crate::surface::Action;
695    use async_trait::async_trait;
696
697    struct StubCaptcha {
698        ok: bool,
699        transport_error: bool,
700    }
701
702    #[async_trait]
703    impl Captcha for StubCaptcha {
704        async fn verify(
705            &self,
706            _token: &str,
707            _remote_ip: Option<&str>,
708        ) -> Result<Verdict, CaptchaError> {
709            if self.transport_error {
710                return Err(CaptchaError::Transport("down".to_owned()));
711            }
712            Ok(Verdict {
713                ok: self.ok,
714                reason: None,
715            })
716        }
717    }
718
719    fn port(ok: bool, transport_error: bool) -> Arc<dyn Captcha> {
720        Arc::new(StubCaptcha {
721            ok,
722            transport_error,
723        })
724    }
725
726    #[pollster::test]
727    async fn verified_tokens_pass_in_every_environment() {
728        for env in [
729            VentureEnv::Development,
730            VentureEnv::Staging,
731            VentureEnv::Production,
732        ] {
733            verify_human_form(
734                Some(&port(true, false)),
735                env,
736                false,
737                Some("token"),
738                Some("203.0.113.7"),
739                "request-1",
740            )
741            .await
742            .expect("a verified token passes");
743        }
744    }
745
746    #[pollster::test]
747    async fn refused_missing_or_unreachable_verifications_fail_closed() {
748        let good = port(true, false);
749        let refused = port(false, false);
750        let unreachable = port(true, true);
751        for env in [VentureEnv::Development, VentureEnv::Production] {
752            assert!(
753                verify_human_form(Some(&refused), env, false, Some("t"), None, "r")
754                    .await
755                    .is_err()
756            );
757            assert!(
758                verify_human_form(Some(&unreachable), env, false, Some("t"), None, "r")
759                    .await
760                    .is_err()
761            );
762            assert!(
763                verify_human_form(Some(&good), env, false, None, None, "r")
764                    .await
765                    .is_err()
766            );
767        }
768    }
769
770    #[pollster::test]
771    async fn absent_port_refuses_production_and_allows_lower_envs() {
772        assert!(
773            verify_human_form(None, VentureEnv::Production, false, Some("t"), None, "r")
774                .await
775                .is_err()
776        );
777        for env in [VentureEnv::Development, VentureEnv::Staging] {
778            assert!(
779                verify_human_form(None, env, false, Some("t"), None, "r")
780                    .await
781                    .is_ok()
782            );
783        }
784    }
785
786    struct Guarded {
787        name: &'static str,
788        surface: Surface,
789        public_writes: bool,
790        public_write_policy: RoutePolicy,
791        verification: SignatureVerification,
792    }
793
794    impl Module for Guarded {
795        fn name(&self) -> &'static str {
796            self.name
797        }
798        fn version(&self) -> &'static str {
799            "0.0.0-test"
800        }
801        fn requires(&self) -> &'static [Port] {
802            &[]
803        }
804        fn public_writes(&self) -> bool {
805            self.public_writes
806        }
807        fn public_write_policy(&self) -> RoutePolicy {
808            self.public_write_policy
809        }
810        fn signature_verification(&self) -> SignatureVerification {
811            self.verification
812        }
813        fn migrations(&self) -> Migrations {
814            Migrations::default()
815        }
816        fn validate_config(
817            &self,
818            _: &dyn crate::config::Config,
819        ) -> Result<(), crate::config::ConfigError> {
820            Ok(())
821        }
822        fn surface(&self) -> Surface {
823            self.surface.clone()
824        }
825        fn router(&self, _: crate::ModuleContext) -> axum::Router {
826            axum::Router::new()
827        }
828    }
829
830    fn module(name: &'static str, actions: Vec<Action>, public_writes: bool) -> Arc<dyn Module> {
831        Arc::new(Guarded {
832            name,
833            surface: Surface {
834                actions,
835                views: vec![],
836            },
837            public_writes,
838            public_write_policy: RoutePolicy::HumanForm,
839            verification: SignatureVerification::Payments,
840        })
841    }
842
843    /// A surface-less public writer that declares what really guards it.
844    fn declaring(name: &'static str, policy: RoutePolicy) -> Arc<dyn Module> {
845        Arc::new(Guarded {
846            name,
847            surface: Surface {
848                actions: vec![],
849                views: vec![],
850            },
851            public_writes: true,
852            public_write_policy: policy,
853            verification: SignatureVerification::Payments,
854        })
855    }
856
857    #[test]
858    fn human_form_action_requires_captcha() {
859        let guards = WriteGuards::collect(&[module(
860            "forms",
861            vec![Action::post("join", "/").policy(RoutePolicy::HumanForm)],
862            false,
863        )]);
864        assert_eq!(guards.captcha_modules, vec!["forms".to_owned()]);
865        assert!(!guards.needs_payments());
866    }
867
868    #[test]
869    fn signature_action_never_requires_captcha() {
870        // The old bug: a webhook counted as "protected", so a captcha
871        // config problem passed unnoticed AND the widget rendered on the
872        // webhook. A Signature-only module must ask for Payments only.
873        let guards = WriteGuards::collect(&[module(
874            "billing",
875            vec![Action::post("webhook", "/webhook").policy(RoutePolicy::Signature)],
876            true, // even with the legacy flag set, the declared policy wins
877        )]);
878        assert!(guards.captcha_modules.is_empty());
879        assert_eq!(guards.signature_modules, vec!["billing".to_owned()]);
880    }
881
882    #[test]
883    fn undeclared_public_writer_falls_back_to_captcha() {
884        let guards = WriteGuards::collect(&[module("auth", Vec::new(), true)]);
885        assert_eq!(guards.captcha_modules, vec!["auth".to_owned()]);
886    }
887
888    #[test]
889    fn legacy_captcha_flag_alias_still_guards() {
890        // Pre-#133 declaration shapes — a bare `captcha: true` without a
891        // policy — must still pull the module into the captcha gate.
892        let mut action = Action::post("join", "/");
893        action.captcha = true;
894        let guards = WriteGuards::collect(&[module("forms", vec![action], false)]);
895        assert_eq!(guards.captcha_modules, vec!["forms".to_owned()]);
896    }
897
898    #[test]
899    fn guarded_surface_does_not_need_the_fallback() {
900        // A module with declared policies is read as declared: `public_writes`
901        // adds nothing on top (a form writer declares HumanForm; the
902        // fallback exists for modules that predate surfaces).
903        let guards = WriteGuards::collect(&[module(
904            "waitlist",
905            vec![
906                Action::post("join", "/").captcha(),
907                Action::post("webhook", "/webhook").policy(RoutePolicy::Signature),
908            ],
909            true,
910        )]);
911        assert_eq!(guards.captcha_modules, vec!["waitlist".to_owned()]);
912        assert_eq!(guards.signature_modules, vec!["waitlist".to_owned()]);
913    }
914
915    #[test]
916    fn development_venture_needs_nothing() {
917        let guards = WriteGuards::collect(&[module("forms", Vec::new(), true)]);
918        for env in [VentureEnv::Development, VentureEnv::Staging] {
919            assert!(
920                production_readiness(env, &guards, None, true, false, None, None).is_empty(),
921                "{env:?} must not gate"
922            );
923        }
924    }
925
926    #[test]
927    fn production_without_runtime_refuses_captcha_writes() {
928        let guards = WriteGuards::collect(&[module(
929            "forms",
930            vec![Action::post("join", "/").captcha()],
931            false,
932        )]);
933        // The limiter leg is held aside (`rate_limiter_ready = true`) so
934        // this test stays about the captcha leg; #437's own tests are
935        // below.
936        let errors = production_readiness(
937            VentureEnv::Production,
938            &guards,
939            None,
940            true,
941            false,
942            None,
943            None,
944        );
945        assert_eq!(errors.len(), 1);
946        assert!(errors[0].contains("forms"));
947        assert!(errors[0].contains("Captcha"));
948        // The override exists for `fz doctor` previews only.
949        assert!(
950            production_readiness(
951                VentureEnv::Production,
952                &guards,
953                None,
954                true,
955                false,
956                Some("preview"),
957                None
958            )
959            .is_empty()
960        );
961        // And it is the *reason* that overrides, not the flag. `fz
962        // doctor --allow-no-captcha ""` hands this `Some("")`, and an
963        // override with nothing to answer for is the silent default
964        // `ALLOW_UNPROTECTED_WRITES` already refuses to be — its
965        // `unprotected_writes_override` trims and drops an empty one.
966        // Two ways to the same waiver, and only one of them asked for a
967        // reason.
968        for nothing in ["", "   ", "\t\n"] {
969            assert_eq!(
970                production_readiness(
971                    VentureEnv::Production,
972                    &guards,
973                    None,
974                    true,
975                    false,
976                    Some(nothing),
977                    None
978                )
979                .len(),
980                1,
981                "an empty reason waived the captcha gate: {nothing:?}"
982            );
983        }
984    }
985
986    #[test]
987    fn production_signature_without_payments_refuses() {
988        let guards = WriteGuards::collect(&[module(
989            "billing",
990            vec![Action::post("webhook", "/webhook").policy(RoutePolicy::Signature)],
991            false,
992        )]);
993        let errors = production_readiness(
994            VentureEnv::Production,
995            &guards,
996            None,
997            true,
998            false,
999            None,
1000            None,
1001        );
1002        assert_eq!(errors.len(), 1);
1003        assert!(errors[0].contains("billing"));
1004        assert!(errors[0].contains("Payments"));
1005        // No override for the payments leg — webhooks cannot be "previewed"
1006        // unverified without shipping unsigned money events.
1007        assert_eq!(
1008            production_readiness(
1009                VentureEnv::Production,
1010                &guards,
1011                None,
1012                true,
1013                false,
1014                Some("preview"),
1015                None
1016            )
1017            .len(),
1018            1
1019        );
1020    }
1021
1022    // ------------------------------------------------ issue #533
1023
1024    use crate::config::MapConfig;
1025
1026    /// A webhook receiver verifying with the core HMAC scheme, holding its
1027    /// secret under `{MODULE}_WEBHOOK_SECRET`.
1028    fn hmac_module(name: &'static str) -> Arc<dyn Module> {
1029        Arc::new(Guarded {
1030            name,
1031            surface: Surface {
1032                actions: vec![Action::post("webhook", "/webhook").policy(RoutePolicy::Signature)],
1033                views: vec![],
1034            },
1035            public_writes: false,
1036            public_write_policy: RoutePolicy::HumanForm,
1037            verification: SignatureVerification::Hmac {
1038                secret: "WEBHOOK_SECRET",
1039            },
1040        })
1041    }
1042
1043    #[test]
1044    fn an_hmac_signature_writer_needs_no_payments_and_names_its_secret_key() {
1045        let guards = WriteGuards::collect(&[hmac_module("pos")]);
1046        assert_eq!(guards.signature_modules, ["pos"]);
1047        assert!(!guards.needs_payments());
1048        assert_eq!(
1049            guards.hmac_signature_secrets(),
1050            vec![("pos".to_owned(), "POS_WEBHOOK_SECRET".to_owned())],
1051            "the full key is composed through ModuleConfig's prefix rule"
1052        );
1053    }
1054
1055    #[test]
1056    fn the_webhook_secret_gate_is_production_only() {
1057        let guards = WriteGuards::collect(&[hmac_module("pos")]);
1058        // No runtime at all — no Payments port anywhere — and production is
1059        // clean once the secret is set.
1060        let set = MapConfig::from_pairs([("POS_WEBHOOK_SECRET", "whsec-test-dummy")]);
1061        assert!(
1062            production_readiness(
1063                VentureEnv::Production,
1064                &guards,
1065                None,
1066                true,
1067                false,
1068                None,
1069                None
1070            )
1071            .is_empty()
1072        );
1073        assert!(webhook_secret_readiness(VentureEnv::Production, &guards, &set).is_empty());
1074        // Missing or blank refuses in production, naming the module and the
1075        // key; staging demands nothing (the handler refuses every delivery
1076        // per request anyway).
1077        for config in [
1078            MapConfig::default(),
1079            MapConfig::from_pairs([("POS_WEBHOOK_SECRET", "   ")]),
1080        ] {
1081            let errors = webhook_secret_readiness(VentureEnv::Production, &guards, &config);
1082            assert_eq!(errors.len(), 1, "{errors:?}");
1083            assert!(
1084                errors[0].contains("pos") && errors[0].contains("POS_WEBHOOK_SECRET"),
1085                "{}",
1086                errors[0]
1087            );
1088        }
1089        assert!(
1090            webhook_secret_readiness(VentureEnv::Staging, &guards, &MapConfig::default())
1091                .is_empty()
1092        );
1093    }
1094
1095    #[test]
1096    fn a_mixed_venture_gates_payments_only_for_the_module_that_names_it() {
1097        // A sidecar document cannot name a verifier, so it keeps the
1098        // pre-#533 behaviour: the Payments gate.
1099        let surface = Surface {
1100            actions: vec![Action::post("webhook", "/webhook").policy(RoutePolicy::Signature)],
1101            views: vec![],
1102        };
1103        assert!(WriteGuards::from_surface("remote", &surface).needs_payments());
1104        // A mixed venture still demands Payments for exactly the module
1105        // that verifies through it.
1106        let guards = WriteGuards::collect(&[
1107            hmac_module("pos"),
1108            declaring("billing", RoutePolicy::Signature),
1109        ]);
1110        assert!(guards.needs_payments());
1111        let errors = production_readiness(
1112            VentureEnv::Production,
1113            &guards,
1114            None,
1115            true,
1116            false,
1117            None,
1118            None,
1119        );
1120        assert_eq!(errors.len(), 1, "{errors:?}");
1121        assert!(
1122            errors[0].contains("billing") && !errors[0].contains("pos"),
1123            "{}",
1124            errors[0]
1125        );
1126    }
1127
1128    // ------------------------------------------------ issue #143
1129
1130    #[test]
1131    fn the_deployment_environment_is_the_stricter_of_the_two() {
1132        // The bug: `ventures/cratefield-waitlist` ships ENV="production"
1133        // and never calls `.env()`, so every production rule read the
1134        // compiled `Development` and did not apply in production.
1135        let deployed = MapConfig::from_pairs([("ENV", "production")]);
1136        assert_eq!(
1137            deployed_env(VentureEnv::Development, &deployed),
1138            VentureEnv::Production,
1139        );
1140        // And the other way: a binding must not be able to switch the
1141        // protections off for a venture compiled as production.
1142        let downgrade = MapConfig::from_pairs([("ENV", "development")]);
1143        assert_eq!(
1144            deployed_env(VentureEnv::Production, &downgrade),
1145            VentureEnv::Production,
1146        );
1147        // No binding at all leaves the compiled answer standing.
1148        assert_eq!(
1149            deployed_env(VentureEnv::Staging, &MapConfig::default()),
1150            VentureEnv::Staging,
1151        );
1152        // Agreement is not a disagreement.
1153        assert!(env_disagreement(VentureEnv::Production, VentureEnv::Production).is_none());
1154        let note = env_disagreement(VentureEnv::Development, VentureEnv::Production)
1155            .expect("a compiled-vs-deployed mismatch is reported");
1156        assert!(note.contains("ENV=production"), "{note}");
1157        // The literal is split across lines; it renders as one sentence (issue #477).
1158        assert!(!note.contains("  "), "{note}");
1159        assert!(
1160            note.contains("compiled with VentureEnv::Development: the production readiness gate"),
1161            "{note}"
1162        );
1163    }
1164
1165    #[test]
1166    fn a_surface_less_writer_declares_what_actually_guards_it() {
1167        // Saying nothing is unchanged: still the conservative CAPTCHA
1168        // fallback, so no existing module moves.
1169        let guards = WriteGuards::collect(&[module("legacy", vec![], true)]);
1170        assert_eq!(guards.captcha_modules, ["legacy"]);
1171        assert!(!guards.needs_signer());
1172
1173        // A passkey ceremony or an OAuth callback is proved by an artifact
1174        // this service issued, and will never render a widget.
1175        let guards = WriteGuards::collect(&[declaring("passkeys", RoutePolicy::SignedLink)]);
1176        assert!(
1177            guards.captcha_modules.is_empty(),
1178            "a signed-link writer must not demand a captcha it never renders"
1179        );
1180        assert_eq!(guards.signed_link_modules, ["passkeys"]);
1181        assert!(guards.needs_signer());
1182    }
1183
1184    #[test]
1185    fn a_signed_link_writer_still_has_a_production_requirement_of_its_own() {
1186        // Not an exemption: without a Signer there is nothing to issue or
1187        // verify the artifact with, so production still refuses.
1188        let guards = WriteGuards::collect(&[declaring("passkeys", RoutePolicy::SignedLink)]);
1189        let errors = production_readiness(
1190            VentureEnv::Production,
1191            &guards,
1192            None,
1193            true,
1194            false,
1195            None,
1196            None,
1197        );
1198        assert_eq!(errors.len(), 1, "{errors:?}");
1199        assert!(errors[0].contains("signed-link"), "{}", errors[0]);
1200        assert!(errors[0].contains("passkeys"), "{}", errors[0]);
1201
1202        // And nothing is demanded outside production.
1203        assert!(
1204            production_readiness(VentureEnv::Staging, &guards, None, true, false, None, None)
1205                .is_empty()
1206        );
1207    }
1208
1209    #[test]
1210    fn a_production_venture_needs_a_resolved_signer() {
1211        // Issue #478: the runtime advertises the Signer port, but a
1212        // harness configuration that fails to parse (a bad `ENV`, a short
1213        // `ADMIN_TOKEN`) leaves `ports.signer == None`. Advertised is not
1214        // resolved, so the caller's answer decides the leg.
1215        struct Advertises;
1216        impl Runtime for Advertises {
1217            fn provides(&self) -> Vec<Port> {
1218                vec![Port::Signer]
1219            }
1220        }
1221        let runtime: Arc<dyn Runtime> = Arc::new(Advertises);
1222        let guards = WriteGuards::collect(&[declaring("passkeys", RoutePolicy::SignedLink)]);
1223        let check = |env, signer_ready| {
1224            production_readiness(env, &guards, Some(&runtime), true, signer_ready, None, None)
1225        };
1226
1227        let errors = check(VentureEnv::Production, false);
1228        assert_eq!(errors.len(), 1, "{errors:?}");
1229        assert!(errors[0].contains("Signer"), "{}", errors[0]);
1230        // The literal is split across lines; it renders as one sentence, the
1231        // same fix #511 made to the ENV warning (#512 made this reachable at boot).
1232        assert!(!errors[0].contains("  "), "{}", errors[0]);
1233        assert!(
1234            errors[0].contains("but the Signer port is not provided"),
1235            "{}",
1236            errors[0]
1237        );
1238        assert!(check(VentureEnv::Production, true).is_empty());
1239        assert!(check(VentureEnv::Development, false).is_empty());
1240    }
1241
1242    #[test]
1243    fn a_declared_policy_on_an_action_still_wins_over_the_fallback() {
1244        // The fallback applies only when the surface declares nothing —
1245        // a module with a guarded action is untouched by #143.
1246        let guards = WriteGuards::collect(&[Arc::new(Guarded {
1247            name: "mixed",
1248            surface: Surface {
1249                actions: vec![Action::post("join", "/join").policy(RoutePolicy::HumanForm)],
1250                views: vec![],
1251            },
1252            public_writes: true,
1253            public_write_policy: RoutePolicy::SignedLink,
1254            verification: SignatureVerification::Payments,
1255        })]);
1256        assert_eq!(guards.captcha_modules, ["mixed"]);
1257        assert!(
1258            guards.signed_link_modules.is_empty(),
1259            "the module-level fallback must not override a declared action"
1260        );
1261    }
1262
1263    // ------------------------------------------------ issue #437
1264
1265    #[test]
1266    fn admin_and_public_writes_are_recorded_on_the_guards() {
1267        // An admin action alone is enough: the bearer token is guessed,
1268        // not submitted, so the budget question is not a corollary of the
1269        // public-write one.
1270        let guards = WriteGuards::collect(&[module(
1271            "exports",
1272            vec![Action::post("export", "/admin/export").audience(Audience::Admin)],
1273            false,
1274        )]);
1275        assert!(guards.has_admin_routes);
1276        assert!(!guards.has_public_writes);
1277        assert!(guards.needs_rate_limiter());
1278
1279        // The path half of the predicate, for an action whose audience
1280        // was never declared.
1281        let guards = WriteGuards::collect(&[module(
1282            "landing",
1283            vec![Action::get("console", "/admin/console")],
1284            false,
1285        )]);
1286        assert!(guards.has_admin_routes);
1287
1288        // The module-level fallback flag counts as a public write, the
1289        // way the captcha fallback does.
1290        let guards = WriteGuards::collect(&[module("legacy", Vec::new(), true)]);
1291        assert!(guards.has_public_writes);
1292        assert!(!guards.has_admin_routes);
1293
1294        // And a webhook is an unauthenticated endpoint: it counts for the
1295        // limiter leg even though it asks nothing of the captcha one.
1296        let guards = WriteGuards::collect(&[module(
1297            "billing",
1298            vec![Action::post("webhook", "/webhook").policy(RoutePolicy::Signature)],
1299            false,
1300        )]);
1301        assert!(guards.has_public_writes);
1302    }
1303
1304    #[test]
1305    fn a_production_venture_needs_a_resolved_rate_limiter() {
1306        let guards = WriteGuards::collect(&[module(
1307            "forms",
1308            vec![Action::post("join", "/").captcha()],
1309            false,
1310        )]);
1311
1312        // Advertised is not resolved: the Cloudflare runtime reports the
1313        // port from a configured binding name and still hands over `None`
1314        // when the binding fails to resolve. The caller says which
1315        // happened, and the captcha waiver next door does not reach this
1316        // leg.
1317        let unlimited = production_readiness(
1318            VentureEnv::Production,
1319            &guards,
1320            None,
1321            false,
1322            false,
1323            Some("preview"),
1324            None,
1325        );
1326        assert_eq!(unlimited.len(), 1, "{unlimited:?}");
1327        assert!(unlimited[0].contains("RateLimiter"), "{}", unlimited[0]);
1328        assert!(
1329            unlimited[0].contains(ALLOW_UNLIMITED_PUBLIC_ROUTES),
1330            "{}",
1331            unlimited[0]
1332        );
1333
1334        // Resolved: the leg passes.
1335        assert!(
1336            production_readiness(
1337                VentureEnv::Production,
1338                &guards,
1339                None,
1340                true,
1341                false,
1342                Some("preview"),
1343                None
1344            )
1345            .is_empty()
1346        );
1347
1348        // Waived with a reason it passes; waived with a blank one it does
1349        // not — the same "a blank reason is not an acceptance" rule the
1350        // captcha override runs on.
1351        assert!(
1352            production_readiness(
1353                VentureEnv::Production,
1354                &guards,
1355                None,
1356                false,
1357                false,
1358                Some("preview"),
1359                Some("issue #437: between pivots"),
1360            )
1361            .is_empty()
1362        );
1363        for nothing in ["", "   "] {
1364            assert_eq!(
1365                production_readiness(
1366                    VentureEnv::Production,
1367                    &guards,
1368                    None,
1369                    false,
1370                    false,
1371                    Some("preview"),
1372                    Some(nothing),
1373                )
1374                .len(),
1375                1,
1376                "an empty reason waived the limiter gate: {nothing:?}"
1377            );
1378        }
1379    }
1380
1381    #[test]
1382    fn a_module_with_only_admin_routes_still_needs_the_limiter() {
1383        let guards = WriteGuards::collect(&[module(
1384            "exports",
1385            vec![Action::post("export", "/admin/export").audience(Audience::Admin)],
1386            false,
1387        )]);
1388        assert!(guards.captcha_modules.is_empty());
1389        let errors = production_readiness(
1390            VentureEnv::Production,
1391            &guards,
1392            None,
1393            false,
1394            false,
1395            None,
1396            None,
1397        );
1398        assert_eq!(errors.len(), 1, "{errors:?}");
1399        assert!(errors[0].contains("RateLimiter"), "{}", errors[0]);
1400        assert!(
1401            errors[0].contains(ALLOW_UNLIMITED_PUBLIC_ROUTES),
1402            "{}",
1403            errors[0]
1404        );
1405    }
1406
1407    // ------------------------------------------------ issue #532
1408
1409    #[test]
1410    fn an_api_key_policy_serializes_as_api_key_and_guards() {
1411        assert_eq!(
1412            serde_json::to_string(&RoutePolicy::ApiKey).expect("serializes"),
1413            "\"api-key\""
1414        );
1415        let policy: RoutePolicy = serde_json::from_str("\"api-key\"").expect("deserializes");
1416        assert_eq!(policy, RoutePolicy::ApiKey);
1417        assert!(policy.is_guarded());
1418    }
1419
1420    #[test]
1421    fn an_api_key_writer_demands_no_captcha_and_no_port() {
1422        // A declared api-key action is read as declared: no captcha (a
1423        // developer machine cannot fill one), no Signer, no Payments —
1424        // and not the public-write leg either, because its budget is the
1425        // per-key limiter hook, not a boot gate.
1426        let guards = WriteGuards::collect(&[module(
1427            "devapi",
1428            vec![Action::post("sync", "/sync").policy(RoutePolicy::ApiKey)],
1429            false,
1430        )]);
1431        assert!(guards.captcha_modules.is_empty());
1432        assert!(guards.signature_modules.is_empty());
1433        assert!(guards.signed_link_modules.is_empty());
1434        assert!(!guards.has_public_writes);
1435        assert!(!guards.needs_rate_limiter());
1436
1437        // The surface-less fallback is the same: guarded, never pulled
1438        // into the captcha gate.
1439        let guards = WriteGuards::collect(&[declaring("devapi", RoutePolicy::ApiKey)]);
1440        assert!(
1441            guards.captcha_modules.is_empty(),
1442            "an api-key writer must not demand a captcha it never renders"
1443        );
1444        assert!(!guards.needs_signer());
1445        assert!(!guards.needs_payments());
1446    }
1447}