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}