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 payments 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::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 payments webhook. The handler
49 /// proves the delivery with [`Payments::verify_webhook`]
50 /// (`cratefield_core::Payments`) plus the [`Inbox`] dedup ledger. A
51 /// CAPTCHA is meaningless here (the caller has no browser) and must
52 /// never be rendered or required on such a route.
53 ///
54 /// [`Payments::verify_webhook`]: crate::ports::Payments::verify_webhook
55 /// [`Inbox`]: crate::idempotency::Inbox
56 Signature,
57 /// A public write whose proof is a single-use, purpose-bound
58 /// artifact **this service issued**: a magic link, a passkey or OAuth
59 /// challenge, an unsubscribe link (issue #143). The caller presents
60 /// something only a prior request of ours could have produced, so the
61 /// gate is the [`Signer`] key ring (issue #137), not a widget.
62 ///
63 /// This variant exists because the auth login methods had nowhere
64 /// honest to sit. They are public writers with no ADR 0010 surface,
65 /// so [`WriteGuards::collect`]'s conservative fallback filed them
66 /// under CAPTCHA — and a passkey challenge will never render one. A
67 /// production venture composing only auth modules therefore could not
68 /// boot at all except through `fz doctor --allow-no-captcha`, an
69 /// override the code itself documents as previews-only. This is not
70 /// an exemption: it carries its own production requirement, a
71 /// usable [`Signer`], because without one there is nothing to issue
72 /// or verify the artifact with.
73 ///
74 /// [`Signer`]: crate::ports::Signer
75 SignedLink,
76}
77
78impl RoutePolicy {
79 /// Whether this policy is a protection (as opposed to [`RoutePolicy::Open`]).
80 #[must_use]
81 pub fn is_guarded(self) -> bool {
82 self != Self::Open
83 }
84}
85
86/// What the composed modules demand of the runtime, module by module.
87#[derive(Debug, Clone, Default, PartialEq, Eq)]
88pub struct WriteGuards {
89 /// Modules with a [`RoutePolicy::HumanForm`]-guarded write (or the
90 /// conservative fallback: [`public_writes`] and no declared policy at all).
91 ///
92 /// [`public_writes`]: Module::public_writes
93 pub captcha_modules: Vec<String>,
94 /// Modules with a [`RoutePolicy::Signature`]-guarded write (webhooks).
95 pub signature_modules: Vec<String>,
96 /// Modules whose public writes are proved by an artifact this service
97 /// issued ([`RoutePolicy::SignedLink`], issue #143).
98 pub signed_link_modules: Vec<String>,
99}
100
101impl WriteGuards {
102 /// Reads each module's declared surface. A module whose surface has
103 /// no policy-guarded write but still sets [`public_writes`] counts as
104 /// needing a CAPTCHA — the undeclared-public-writer fallback keeps
105 /// out-of-tree and surface-less modules (the auth crates predate
106 /// ADR 0010 surfaces) under the same gate instead of silently
107 /// exempting them.
108 ///
109 /// [`public_writes`]: Module::public_writes
110 #[must_use]
111 pub fn collect(modules: &[Arc<dyn Module>]) -> Self {
112 let mut guards = Self::default();
113 for module in modules {
114 let surface: Surface = module.surface();
115 let (mut form, mut signature) = Self::surface_flags(&surface);
116 let mut signed_link = surface
117 .actions
118 .iter()
119 .any(|action| action.policy == RoutePolicy::SignedLink);
120 // The undeclared-public-writer fallback only applies when
121 // nothing in the surface is guarded. What it falls back *to*
122 // is the module's own answer (issue #143): a surface-less
123 // module has no action to hang a policy on, so this is the
124 // only place it can say what protects its writes. The default
125 // is still `HumanForm`, so saying nothing changes nothing.
126 if !form && !signature && !signed_link && module.public_writes() {
127 match module.public_write_policy() {
128 RoutePolicy::Signature => signature = true,
129 RoutePolicy::SignedLink => signed_link = true,
130 RoutePolicy::HumanForm | RoutePolicy::Open => form = true,
131 }
132 }
133 if form {
134 guards.captcha_modules.push(module.name().to_owned());
135 }
136 if signature {
137 guards.signature_modules.push(module.name().to_owned());
138 }
139 if signed_link {
140 guards.signed_link_modules.push(module.name().to_owned());
141 }
142 }
143 guards
144 }
145
146 /// The guards a **declared surface** demands (issue #131). A sidecar's
147 /// merged surface is not a [`Module`] this process can ask
148 /// [`public_writes`](Module::public_writes) of, so the fallback has no
149 /// meaning here: whatever the document declares is exactly what it
150 /// needs. Same per-action reading as [`Self::collect`] through the one
151 /// [`Action::demands_captcha`] predicate.
152 ///
153 /// [`Action::demands_captcha`]: crate::surface::Action::demands_captcha
154 #[must_use]
155 pub fn from_surface(module: &str, surface: &Surface) -> Self {
156 let (form, signature) = Self::surface_flags(surface);
157 let signed_link = surface
158 .actions
159 .iter()
160 .any(|action| action.policy == RoutePolicy::SignedLink);
161 Self {
162 captcha_modules: form.then(|| module.to_owned()).into_iter().collect(),
163 signature_modules: signature.then(|| module.to_owned()).into_iter().collect(),
164 signed_link_modules: signed_link.then(|| module.to_owned()).into_iter().collect(),
165 }
166 }
167
168 /// Whether any action needs a captcha and whether any is a signature
169 /// route. The `Open`-with-`captcha` legacy mirror resolves inside
170 /// [`Action::demands_captcha`].
171 fn surface_flags(surface: &Surface) -> (bool, bool) {
172 let mut form = false;
173 let mut signature = false;
174 for action in &surface.actions {
175 if action.demands_captcha() {
176 form = true;
177 }
178 if action.policy == RoutePolicy::Signature {
179 signature = true;
180 }
181 }
182 (form, signature)
183 }
184
185 /// Whether any module proves a public write with an artifact this
186 /// service issued, and so needs a usable [`Signer`] (issue #143).
187 ///
188 /// [`Signer`]: crate::ports::Signer
189 #[must_use]
190 pub fn needs_signer(&self) -> bool {
191 !self.signed_link_modules.is_empty()
192 }
193
194 /// Whether any module needs the `Captcha` port.
195 #[must_use]
196 pub fn needs_captcha(&self) -> bool {
197 !self.captcha_modules.is_empty()
198 }
199
200 /// Whether any module needs the `Payments` port.
201 #[must_use]
202 pub fn needs_payments(&self) -> bool {
203 !self.signature_modules.is_empty()
204 }
205}
206
207/// Whether the runtime's `Captcha` port can really verify a token in
208/// production: the port is provided **and**, when the adapter reports,
209/// hostname-bound and not fail-open. An adapter that does not report
210/// ([`Captcha::binding`] = `None`) counts as effective — presence is all
211/// the harness can know about a third-party implementation; the adapter
212/// contract carries the duty to fail closed per request.
213///
214/// [`Captcha::binding`]: crate::ports::Captcha::binding
215#[must_use]
216pub fn captcha_effective(runtime: Option<&Arc<dyn Runtime>>) -> bool {
217 runtime.is_some_and(|runtime| runtime.effectively_configured(Port::Captcha))
218}
219
220/// Whether the runtime can verify webhook signatures for
221/// [`RoutePolicy::Signature`]-guarded routes. Presence of the `Payments`
222/// bar: signature verification is the adapter's per-request duty
223/// (`verify_webhook` + `Inbox`), and which secret it checks against is
224/// deploy configuration that `fz doctor` reads from the environment.
225#[must_use]
226pub fn payments_effective(runtime: Option<&Arc<dyn Runtime>>) -> bool {
227 runtime.is_some_and(|runtime| runtime.effectively_configured(Port::Payments))
228}
229
230/// Config key holding an operator's explicit, recorded acceptance that
231/// this deployment serves guarded routes it cannot fully protect
232/// (issue #143).
233///
234/// The value is the **reason**, and an empty one does not count: an
235/// override with nothing to answer for is the silent default this issue
236/// exists to remove. It is recorded through `tracing` on every boot, so
237/// it appears wherever the operator ships logs, and `fz doctor` reports
238/// it. It exists because the gate landed on ventures that had already
239/// been serving unprotected for months — refusing their traffic outright
240/// on the next deploy would trade a quiet hole for a loud outage without
241/// anyone choosing it. Wire the missing port and delete the key.
242pub const ALLOW_UNPROTECTED_WRITES: &str = "HARNESS_ALLOW_UNPROTECTED_WRITES";
243
244/// The operator's recorded reason for serving guarded routes unprotected,
245/// if they set one (issue #143).
246#[must_use]
247pub fn unprotected_writes_override(config: &dyn crate::config::Config) -> Option<String> {
248 config
249 .get(ALLOW_UNPROTECTED_WRITES)
250 .map(|reason| reason.trim().to_owned())
251 .filter(|reason| !reason.is_empty())
252}
253
254/// The environment this **deployment** runs in (issue #143).
255///
256/// A venture carries a compiled [`VentureEnv`] — a builder default the
257/// operator cannot change without a rebuild — and a deployment carries
258/// an `ENV` binding it can. Every production-only rule used to read the
259/// compiled one alone, so a Worker deployed with `ENV = "production"`
260/// over a venture that never called [`Venture::env`] ran with all of
261/// them switched off. Neither source may downgrade the other: if either
262/// says `Production`, this is production.
263///
264/// [`Venture::env`]: crate::venture::Venture::env
265#[must_use]
266pub fn deployed_env(compiled: VentureEnv, config: &dyn crate::config::Config) -> VentureEnv {
267 let declared = config
268 .get("ENV")
269 .as_deref()
270 .and_then(VentureEnv::parse)
271 .unwrap_or_default();
272 compiled.strictest(declared)
273}
274
275/// Whether the deployment's environment contradicts the compiled one
276/// (issue #143). Not an error by itself — the stricter answer wins — but
277/// it means the build-time gate ran against the wrong environment, so
278/// the caller re-checks and says so.
279#[must_use]
280pub fn env_disagreement(compiled: VentureEnv, deployed: VentureEnv) -> Option<String> {
281 (compiled != deployed).then(|| {
282 format!(
283 "this deployment declares ENV={} but the venture was compiled with VentureEnv::{compiled:?}: the production readiness gate at build time ran against the wrong environment. Treating it as {} — call `.env(VentureEnv::{deployed:?})` on the venture so the build refuses what the deployment cannot serve (issue #143)",
284 deployed.as_str(),
285 deployed.as_str(),
286 )
287 })
288}
289
290/// Whether the runtime can issue and verify the artifacts a
291/// [`RoutePolicy::SignedLink`] route rests on (issue #143).
292#[must_use]
293pub fn signer_effective(runtime: Option<&Arc<dyn Runtime>>) -> bool {
294 runtime.is_some_and(|runtime| runtime.effectively_configured(Port::Signer))
295}
296
297/// The production abuse-control gate (issue #133), as collected error
298/// strings (the [`ConfigError`] convention: report every problem at
299/// once). Empty for anything but `Production`; pass
300/// `allow_no_captcha = Some(reason)` (the `fz doctor` override) to
301/// downgrade the CAPTCHA refusal to nothing — the runtime path has no
302/// override and always fails closed.
303///
304/// [`ConfigError`]: crate::config::ConfigError
305#[must_use]
306pub fn production_readiness(
307 env: VentureEnv,
308 guards: &WriteGuards,
309 runtime: Option<&Arc<dyn Runtime>>,
310 allow_no_captcha: Option<&str>,
311) -> Vec<String> {
312 if env != VentureEnv::Production {
313 return Vec::new();
314 }
315 let mut errors = Vec::new();
316 if guards.needs_captcha() && !captcha_effective(runtime) && allow_no_captcha.is_none() {
317 errors.push(format!(
318 "production venture has captcha-guarded public writes from [{}] but the Captcha \
319 port is not effectively configured: provide it on the runtime with a bound \
320 adapter (Turnstile needs its secret and expected hostname) — `fz doctor \
321 --allow-no-captcha <reason>` overrides this check for previews only",
322 guards.captcha_modules.join(", ")
323 ));
324 }
325 if guards.needs_signer() && !signer_effective(runtime) {
326 errors.push(format!(
327 "production venture has signed-link public writes from [{}] but the Signer port is not provided — the magic links, challenges and unsubscribe links those routes verify cannot be issued or checked without one (issue #143)",
328 guards.signed_link_modules.join(", ")
329 ));
330 }
331 if guards.needs_payments() && !payments_effective(runtime) {
332 errors.push(format!(
333 "production venture has signature-guarded routes from [{}] but the Payments port \
334 is not provided — webhook deliveries cannot be verified (see the Inbox dedup \
335 ledger and the STRIPE_WEBHOOK_SECRET doctor rule)",
336 guards.signature_modules.join(", ")
337 ));
338 }
339 errors
340}
341
342/// The shared CAPTCHA gate for `HumanForm` handlers (issue #133): the
343/// single place a module asks "is this form submission a human?".
344///
345/// Fail-closed rules, in order:
346///
347/// - the port is present: the token must exist and verify — any
348/// non-`ok` verdict or transport error is a `captcha-failed` problem;
349/// - the port is absent in **production**: `captcha-failed` as well.
350/// `Harness::build` refuses that composition, so reaching this branch
351/// means a runtime lied about its binding — refuse rather than wave
352/// the request through. **Unless** `accepted_unprotected`: an operator
353/// has recorded that this deployment serves without the port
354/// (`HARNESS_ALLOW_UNPROTECTED_WRITES`, issue #143), the boot gate
355/// honoured that and served, and refusing here would be the same
356/// outage the acceptance exists to avoid, with a different status
357/// code. The record is the accountability, not this branch;
358/// - the port is absent in development/staging: allow, so a staging
359/// deploy can drive the form without a live Turnstile.
360///
361/// # Errors
362///
363/// A `captcha-failed` problem when the submission is not proven human.
364pub async fn verify_human_form(
365 captcha: Option<&Arc<dyn Captcha>>,
366 env: VentureEnv,
367 accepted_unprotected: bool,
368 token: Option<&str>,
369 remote_ip: Option<&str>,
370 instance: &str,
371) -> Result<(), Problem> {
372 let refused = || Problem::new(&SLUGS.captcha_failed).instance(instance);
373 match captcha {
374 Some(captcha) => {
375 let Some(token) = token else {
376 return Err(refused());
377 };
378 match captcha.verify(token, remote_ip).await {
379 Ok(verdict) if verdict.ok => Ok(()),
380 _ => Err(refused()),
381 }
382 }
383 // No port, in production, with nobody having accepted that: the
384 // composition `Harness::build` refuses is somehow serving, so
385 // refuse the request rather than wave it through.
386 None if env == VentureEnv::Production && !accepted_unprotected => Err(refused()),
387 None => Ok(()),
388 }
389}
390
391#[cfg(test)]
392mod tests {
393 use super::*;
394 use crate::Migrations;
395 use crate::ports::{CaptchaError, Verdict};
396 use crate::surface::Action;
397 use async_trait::async_trait;
398
399 struct StubCaptcha {
400 ok: bool,
401 transport_error: bool,
402 }
403
404 #[async_trait]
405 impl Captcha for StubCaptcha {
406 async fn verify(
407 &self,
408 _token: &str,
409 _remote_ip: Option<&str>,
410 ) -> Result<Verdict, CaptchaError> {
411 if self.transport_error {
412 return Err(CaptchaError::Transport("down".to_owned()));
413 }
414 Ok(Verdict {
415 ok: self.ok,
416 reason: None,
417 })
418 }
419 }
420
421 fn port(ok: bool, transport_error: bool) -> Arc<dyn Captcha> {
422 Arc::new(StubCaptcha {
423 ok,
424 transport_error,
425 })
426 }
427
428 #[pollster::test]
429 async fn verified_tokens_pass_in_every_environment() {
430 for env in [
431 VentureEnv::Development,
432 VentureEnv::Staging,
433 VentureEnv::Production,
434 ] {
435 verify_human_form(
436 Some(&port(true, false)),
437 env,
438 false,
439 Some("token"),
440 Some("203.0.113.7"),
441 "request-1",
442 )
443 .await
444 .expect("a verified token passes");
445 }
446 }
447
448 #[pollster::test]
449 async fn refused_missing_or_unreachable_verifications_fail_closed() {
450 let good = port(true, false);
451 let refused = port(false, false);
452 let unreachable = port(true, true);
453 for env in [VentureEnv::Development, VentureEnv::Production] {
454 assert!(
455 verify_human_form(Some(&refused), env, false, Some("t"), None, "r")
456 .await
457 .is_err()
458 );
459 assert!(
460 verify_human_form(Some(&unreachable), env, false, Some("t"), None, "r")
461 .await
462 .is_err()
463 );
464 assert!(
465 verify_human_form(Some(&good), env, false, None, None, "r")
466 .await
467 .is_err()
468 );
469 }
470 }
471
472 #[pollster::test]
473 async fn absent_port_refuses_production_and_allows_lower_envs() {
474 assert!(
475 verify_human_form(None, VentureEnv::Production, false, Some("t"), None, "r")
476 .await
477 .is_err()
478 );
479 for env in [VentureEnv::Development, VentureEnv::Staging] {
480 assert!(
481 verify_human_form(None, env, false, Some("t"), None, "r")
482 .await
483 .is_ok()
484 );
485 }
486 }
487
488 struct Guarded {
489 name: &'static str,
490 surface: Surface,
491 public_writes: bool,
492 public_write_policy: RoutePolicy,
493 }
494
495 impl Module for Guarded {
496 fn name(&self) -> &'static str {
497 self.name
498 }
499 fn version(&self) -> &'static str {
500 "0.0.0-test"
501 }
502 fn requires(&self) -> &'static [Port] {
503 &[]
504 }
505 fn public_writes(&self) -> bool {
506 self.public_writes
507 }
508 fn public_write_policy(&self) -> RoutePolicy {
509 self.public_write_policy
510 }
511 fn migrations(&self) -> Migrations {
512 Migrations::default()
513 }
514 fn validate_config(
515 &self,
516 _: &dyn crate::config::Config,
517 ) -> Result<(), crate::config::ConfigError> {
518 Ok(())
519 }
520 fn surface(&self) -> Surface {
521 self.surface.clone()
522 }
523 fn router(&self, _: crate::ModuleContext) -> axum::Router {
524 axum::Router::new()
525 }
526 }
527
528 fn module(name: &'static str, actions: Vec<Action>, public_writes: bool) -> Arc<dyn Module> {
529 Arc::new(Guarded {
530 name,
531 surface: Surface {
532 actions,
533 views: vec![],
534 },
535 public_writes,
536 public_write_policy: RoutePolicy::HumanForm,
537 })
538 }
539
540 /// A surface-less public writer that declares what really guards it.
541 fn declaring(name: &'static str, policy: RoutePolicy) -> Arc<dyn Module> {
542 Arc::new(Guarded {
543 name,
544 surface: Surface {
545 actions: vec![],
546 views: vec![],
547 },
548 public_writes: true,
549 public_write_policy: policy,
550 })
551 }
552
553 #[test]
554 fn human_form_action_requires_captcha() {
555 let guards = WriteGuards::collect(&[module(
556 "forms",
557 vec![Action::post("join", "/").policy(RoutePolicy::HumanForm)],
558 false,
559 )]);
560 assert_eq!(guards.captcha_modules, vec!["forms".to_owned()]);
561 assert!(!guards.needs_payments());
562 }
563
564 #[test]
565 fn signature_action_never_requires_captcha() {
566 // The old bug: a webhook counted as "protected", so a captcha
567 // config problem passed unnoticed AND the widget rendered on the
568 // webhook. A Signature-only module must ask for Payments only.
569 let guards = WriteGuards::collect(&[module(
570 "billing",
571 vec![Action::post("webhook", "/webhook").policy(RoutePolicy::Signature)],
572 true, // even with the legacy flag set, the declared policy wins
573 )]);
574 assert!(guards.captcha_modules.is_empty());
575 assert_eq!(guards.signature_modules, vec!["billing".to_owned()]);
576 }
577
578 #[test]
579 fn undeclared_public_writer_falls_back_to_captcha() {
580 let guards = WriteGuards::collect(&[module("auth", Vec::new(), true)]);
581 assert_eq!(guards.captcha_modules, vec!["auth".to_owned()]);
582 }
583
584 #[test]
585 fn legacy_captcha_flag_alias_still_guards() {
586 // Pre-#133 declaration shapes — a bare `captcha: true` without a
587 // policy — must still pull the module into the captcha gate.
588 let mut action = Action::post("join", "/");
589 action.captcha = true;
590 let guards = WriteGuards::collect(&[module("forms", vec![action], false)]);
591 assert_eq!(guards.captcha_modules, vec!["forms".to_owned()]);
592 }
593
594 #[test]
595 fn guarded_surface_does_not_need_the_fallback() {
596 // A module with declared policies is read as declared: `public_writes`
597 // adds nothing on top (a form writer declares HumanForm; the
598 // fallback exists for modules that predate surfaces).
599 let guards = WriteGuards::collect(&[module(
600 "waitlist",
601 vec![
602 Action::post("join", "/").captcha(),
603 Action::post("webhook", "/webhook").policy(RoutePolicy::Signature),
604 ],
605 true,
606 )]);
607 assert_eq!(guards.captcha_modules, vec!["waitlist".to_owned()]);
608 assert_eq!(guards.signature_modules, vec!["waitlist".to_owned()]);
609 }
610
611 #[test]
612 fn development_venture_needs_nothing() {
613 let guards = WriteGuards::collect(&[module("forms", Vec::new(), true)]);
614 for env in [VentureEnv::Development, VentureEnv::Staging] {
615 assert!(
616 production_readiness(env, &guards, None, None).is_empty(),
617 "{env:?} must not gate"
618 );
619 }
620 }
621
622 #[test]
623 fn production_without_runtime_refuses_captcha_writes() {
624 let guards = WriteGuards::collect(&[module(
625 "forms",
626 vec![Action::post("join", "/").captcha()],
627 false,
628 )]);
629 let errors = production_readiness(VentureEnv::Production, &guards, None, None);
630 assert_eq!(errors.len(), 1);
631 assert!(errors[0].contains("forms"));
632 assert!(errors[0].contains("Captcha"));
633 // The override exists for `fz doctor` previews only.
634 assert!(
635 production_readiness(VentureEnv::Production, &guards, None, Some("preview")).is_empty()
636 );
637 }
638
639 #[test]
640 fn production_signature_without_payments_refuses() {
641 let guards = WriteGuards::collect(&[module(
642 "billing",
643 vec![Action::post("webhook", "/webhook").policy(RoutePolicy::Signature)],
644 false,
645 )]);
646 let errors = production_readiness(VentureEnv::Production, &guards, None, None);
647 assert_eq!(errors.len(), 1);
648 assert!(errors[0].contains("billing"));
649 assert!(errors[0].contains("Payments"));
650 // No override for the payments leg — webhooks cannot be "previewed"
651 // unverified without shipping unsigned money events.
652 assert_eq!(
653 production_readiness(VentureEnv::Production, &guards, None, Some("preview")).len(),
654 1
655 );
656 }
657
658 // ------------------------------------------------ issue #143
659
660 use crate::config::MapConfig;
661
662 #[test]
663 fn the_deployment_environment_is_the_stricter_of_the_two() {
664 // The bug: `ventures/cratefield-waitlist` ships ENV="production"
665 // and never calls `.env()`, so every production rule read the
666 // compiled `Development` and did not apply in production.
667 let deployed = MapConfig::from_pairs([("ENV", "production")]);
668 assert_eq!(
669 deployed_env(VentureEnv::Development, &deployed),
670 VentureEnv::Production,
671 );
672 // And the other way: a binding must not be able to switch the
673 // protections off for a venture compiled as production.
674 let downgrade = MapConfig::from_pairs([("ENV", "development")]);
675 assert_eq!(
676 deployed_env(VentureEnv::Production, &downgrade),
677 VentureEnv::Production,
678 );
679 // No binding at all leaves the compiled answer standing.
680 assert_eq!(
681 deployed_env(VentureEnv::Staging, &MapConfig::default()),
682 VentureEnv::Staging,
683 );
684 // Agreement is not a disagreement.
685 assert!(env_disagreement(VentureEnv::Production, VentureEnv::Production).is_none());
686 let note = env_disagreement(VentureEnv::Development, VentureEnv::Production)
687 .expect("a compiled-vs-deployed mismatch is reported");
688 assert!(note.contains("ENV=production"), "{note}");
689 }
690
691 #[test]
692 fn a_surface_less_writer_declares_what_actually_guards_it() {
693 // Saying nothing is unchanged: still the conservative CAPTCHA
694 // fallback, so no existing module moves.
695 let guards = WriteGuards::collect(&[module("legacy", vec![], true)]);
696 assert_eq!(guards.captcha_modules, ["legacy"]);
697 assert!(!guards.needs_signer());
698
699 // A passkey ceremony or an OAuth callback is proved by an artifact
700 // this service issued, and will never render a widget.
701 let guards = WriteGuards::collect(&[declaring("passkeys", RoutePolicy::SignedLink)]);
702 assert!(
703 guards.captcha_modules.is_empty(),
704 "a signed-link writer must not demand a captcha it never renders"
705 );
706 assert_eq!(guards.signed_link_modules, ["passkeys"]);
707 assert!(guards.needs_signer());
708 }
709
710 #[test]
711 fn a_signed_link_writer_still_has_a_production_requirement_of_its_own() {
712 // Not an exemption: without a Signer there is nothing to issue or
713 // verify the artifact with, so production still refuses.
714 let guards = WriteGuards::collect(&[declaring("passkeys", RoutePolicy::SignedLink)]);
715 let errors = production_readiness(VentureEnv::Production, &guards, None, None);
716 assert_eq!(errors.len(), 1, "{errors:?}");
717 assert!(errors[0].contains("signed-link"), "{}", errors[0]);
718 assert!(errors[0].contains("passkeys"), "{}", errors[0]);
719
720 // And nothing is demanded outside production.
721 assert!(production_readiness(VentureEnv::Staging, &guards, None, None).is_empty());
722 }
723
724 #[test]
725 fn a_declared_policy_on_an_action_still_wins_over_the_fallback() {
726 // The fallback applies only when the surface declares nothing —
727 // a module with a guarded action is untouched by #143.
728 let guards = WriteGuards::collect(&[Arc::new(Guarded {
729 name: "mixed",
730 surface: Surface {
731 actions: vec![Action::post("join", "/join").policy(RoutePolicy::HumanForm)],
732 views: vec![],
733 },
734 public_writes: true,
735 public_write_policy: RoutePolicy::SignedLink,
736 })]);
737 assert_eq!(guards.captcha_modules, ["mixed"]);
738 assert!(
739 guards.signed_link_modules.is_empty(),
740 "the module-level fallback must not override a declared action"
741 );
742 }
743}