Skip to main content

cratefield_core/
route_policy.rs

1//! Route protection policies and the production abuse gate (issue #133).
2//!
3//! Every declared write route carries an explicit [`RoutePolicy`]: it is
4//! open, protected by a human-form CAPTCHA, or protected by a machine
5//! signature (a 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}