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