Skip to main content

cratefield_cli/
doctor.rs

1//! `fz doctor` (issue #8): harness validity, lockfile consistency and the
2//! portable-SQL lint. It re-asserts the contract-version rule (issue #17)
3//! and the production abuse-port rules (issues #13/#102/#133) — the same
4//! checks `Harness::build` enforces at initialization, kept visible here
5//! for operator tooling.
6//!
7//! Output comes in two disciplines (harness #140). Without `--json` the
8//! doctor is prose: warnings on stderr, every failure joined into one
9//! message, byte-for-byte as it has always been. With `--json` it prints
10//! exactly one JSON object to stdout — `schema`, `ok` and the failures,
11//! each with its stable code from [`crate::codes`] — and nothing else,
12//! so an agent can parse the verdict without screen-scraping.
13
14use crate::EnvVars;
15use crate::codes::{CODES, DoctorCodeDef};
16use crate::collect::{LockedMigrationError, verify_locked};
17use crate::lint::banned_tokens;
18use crate::lock::{Lock, read_lock};
19use cratefield_core::lint_card_data;
20use cratefield_core::{
21    HARNESS_API, HARNESS_SIDECARS, Harness, MIN_SECRET_BYTES, Port, SIDECAR_GATEWAY_SECRET,
22    VentureEnv, deployed_env, env_disagreement, harness_api_mismatch,
23};
24use cratefield_push_wiring::PushWiring;
25use std::path::Path;
26use std::process::ExitCode;
27
28/// The `schema` field of the `fz doctor --json` object (harness #140):
29/// consumers branch on it, so it only ever grows.
30pub const REPORT_SCHEMA: u32 = 1;
31
32/// One failed doctor check: its stable code from the catalogue
33/// ([`crate::codes`]) plus the human message, which is byte-identical to
34/// the prose path's.
35#[derive(Debug)]
36pub struct DoctorFailure {
37    /// The catalogue definition this failure is classified under.
38    pub code: &'static DoctorCodeDef,
39    /// The failure message, exactly as the prose path prints it.
40    pub message: String,
41}
42
43/// The outcome of a doctor run: every failed check, in check order.
44#[derive(Debug)]
45pub struct DoctorReport {
46    /// Empty when every check passed.
47    pub failures: Vec<DoctorFailure>,
48}
49
50impl DoctorReport {
51    /// `true` when every check passed.
52    #[must_use]
53    pub fn ok(&self) -> bool {
54        self.failures.is_empty()
55    }
56
57    /// The exact stdout payload of `fz doctor --json`: one object, one
58    /// line, no trailing newline.
59    ///
60    /// # Errors
61    ///
62    /// Only if `serde_json` cannot serialize the report — a struct of
63    /// strings and numbers cannot hit that, but the Result keeps every
64    /// caller total instead of panicking.
65    pub fn render_json(&self) -> Result<String, serde_json::Error> {
66        #[derive(serde::Serialize)]
67        struct Report<'a> {
68            schema: u32,
69            ok: bool,
70            failures: Vec<Failure<'a>>,
71        }
72        #[derive(serde::Serialize)]
73        struct Failure<'a> {
74            code: &'a str,
75            message: &'a str,
76        }
77        serde_json::to_string(&Report {
78            schema: REPORT_SCHEMA,
79            ok: self.ok(),
80            failures: self
81                .failures
82                .iter()
83                .map(|failure| Failure {
84                    code: failure.code.code,
85                    message: &failure.message,
86                })
87                .collect(),
88        })
89    }
90}
91
92/// Which output discipline the doctor runs under.
93#[derive(Clone, Copy, PartialEq, Eq)]
94enum Output {
95    /// Prose: operator warnings on stderr, failures joined into one
96    /// message by [`doctor`].
97    Human,
98    /// `--json`: exactly one object on stdout, operator warnings
99    /// suppressed so nothing but the JSON reaches a consumer.
100    Json,
101}
102
103/// Runs every doctor check. `allow_no_captcha` downgrades the
104/// production-captcha failure to a printed warning for the stated
105/// reason (issue #13).
106///
107/// # Errors
108///
109/// One message listing every failed check — the same bytes `fz doctor`
110/// has always produced.
111pub fn doctor(
112    harness: &Harness,
113    migrations_dir: &Path,
114    allow_no_captcha: Option<&str>,
115    sidecars: Option<&str>,
116) -> Result<(), String> {
117    let report = run_checks(
118        harness,
119        migrations_dir,
120        allow_no_captcha,
121        sidecars,
122        Output::Human,
123        Configured::from_env(),
124    );
125    if report.ok() {
126        return Ok(());
127    }
128    let messages: Vec<String> = report
129        .failures
130        .into_iter()
131        .map(|failure| failure.message)
132        .collect();
133    Err(messages.join("\n  - "))
134}
135
136/// Runs every doctor check under `--json` discipline (harness #140):
137/// prints exactly one JSON object to stdout — `schema`, `ok`, `failures`
138/// with their stable codes — and nothing else, then reports the verdict
139/// as the exit code.
140#[must_use = "call process::exit with the returned ExitCode"]
141pub fn doctor_json(
142    harness: &Harness,
143    migrations_dir: &Path,
144    allow_no_captcha: Option<&str>,
145    sidecars: Option<&str>,
146) -> ExitCode {
147    let report = run_checks(
148        harness,
149        migrations_dir,
150        allow_no_captcha,
151        sidecars,
152        Output::Json,
153        Configured::from_env(),
154    );
155    match report.render_json() {
156        Ok(payload) => println!("{payload}"),
157        Err(err) => {
158            eprintln!("fz: cannot serialize the doctor report: {err}");
159            return ExitCode::FAILURE;
160        }
161    }
162    if report.ok() {
163        ExitCode::SUCCESS
164    } else {
165        ExitCode::FAILURE
166    }
167}
168
169/// The doctor's checks under `--json` discipline, without printing —
170/// the exact report [`doctor_json`] serializes, for tests and future
171/// consumers. Operator warnings are suppressed, as `--json` promises.
172#[must_use]
173pub fn doctor_report_json(
174    harness: &Harness,
175    migrations_dir: &Path,
176    allow_no_captcha: Option<&str>,
177    sidecars: Option<&str>,
178) -> DoctorReport {
179    run_checks(
180        harness,
181        migrations_dir,
182        allow_no_captcha,
183        sidecars,
184        Output::Json,
185        Configured::from_env(),
186    )
187}
188
189fn run_checks(
190    harness: &Harness,
191    migrations_dir: &Path,
192    allow_no_captcha: Option<&str>,
193    sidecars: Option<&str>,
194    output: Output,
195    configured: Configured,
196) -> DoctorReport {
197    let mut failures: Vec<DoctorFailure> = Vec::new();
198
199    contract_failures(harness, &mut failures);
200    self_check_failures(harness, &mut failures);
201
202    // Harness::build already succeeded by construction; the production-only
203    // port rules (captcha, payments webhook) are checked against the runtime's
204    // provided ports.
205    //
206    // The environment is the deployment's, not only the compiled one
207    // (issue #143): the doctor runs where `ENV` is set, and a venture that
208    // never called `.env()` still ships to production.
209    let env = deployed_env(harness.venture().env, &EnvVars);
210    if let Some(note) = env_disagreement(harness.venture().env, env)
211        && output == Output::Human
212    {
213        eprintln!("fz: warning: {note}");
214    }
215    if env == VentureEnv::Production {
216        production_port_checks(
217            harness,
218            configured,
219            &EnvVars,
220            allow_no_captcha,
221            output,
222            &mut failures,
223        );
224    }
225
226    // The admin-token floor (issue #437) is deliberately *not*
227    // production-only: `HarnessConfig::from_config` refuses a set-but-short
228    // `ADMIN_TOKEN` in every environment (crates/core/src/config.rs), so a
229    // production gate here would leave the doctor more permissive than the
230    // boot it predicts. Absent stays legal here exactly as it does there —
231    // removing the token is how the admin plane stays disabled.
232    if let Some(message) = admin_token_failure(configured.admin_token_bytes) {
233        failures.push(DoctorFailure {
234            code: &CODES.admin_token_too_short,
235            message,
236        });
237    }
238
239    push_checks(harness, env, output, &mut failures);
240    lockfile_failures(harness, migrations_dir, &mut failures);
241    sidecar_failures(harness, sidecars, output, &mut failures);
242    lint_failures(harness, &mut failures);
243
244    DoctorReport { failures }
245}
246
247/// The contract-version re-check (issue #17). `Harness::build` already
248/// refuses a mismatched module, so a harness that reaches the doctor is
249/// coherent; the re-check keeps the rule visible here as well, and reports
250/// the module, its version and the core crate if it ever fires.
251fn contract_failures(harness: &Harness, failures: &mut Vec<DoctorFailure>) {
252    for module in harness.modules() {
253        if module.harness_api() != HARNESS_API {
254            failures.push(DoctorFailure {
255                code: &CODES.harness_api_mismatch,
256                message: harness_api_mismatch(module.as_ref()),
257            });
258        }
259    }
260}
261
262/// What each module says about its own embedded data
263/// ([`Module::self_check`](cratefield_core::Module::self_check)).
264///
265/// The doctor has no deploy config, so it cannot run a module's
266/// `validate_config` — those values live on the runtime `Env` and exist
267/// only per request. It *can* ask whether what the module compiled in is
268/// coherent, and that question has the same answer everywhere: the
269/// notifications module answers it with every translation its catalog is
270/// missing for a message the venture declared (issue #190).
271///
272/// Unconditional, not production-only. A missing translation is a person
273/// reading `booking-confirmed.title` on a lock screen in every
274/// environment, and finding it on staging is the point.
275fn self_check_failures(harness: &Harness, failures: &mut Vec<DoctorFailure>) {
276    for module in harness.modules() {
277        for message in module.self_check() {
278            failures.push(DoctorFailure {
279                code: &CODES.module_self_check,
280                message,
281            });
282        }
283    }
284}
285
286/// Lockfile consistency: every module migration locked, every locked
287/// file present and byte-identical.
288fn lockfile_failures(harness: &Harness, migrations_dir: &Path, failures: &mut Vec<DoctorFailure>) {
289    let lock = match read_lock(migrations_dir) {
290        Ok(lock) => lock,
291        Err(err) => {
292            failures.push(DoctorFailure {
293                code: &CODES.lockfile_unreadable,
294                message: err,
295            });
296            Lock::default()
297        }
298    };
299    for module in harness.modules() {
300        for migration in module.migrations().sqlite {
301            let key = format!("{}/{}", module.name(), migration.id);
302            match lock.get(&key) {
303                Some(entry) => {
304                    if let Err(err) = verify_locked(migrations_dir, &key, entry) {
305                        let code = match &err {
306                            LockedMigrationError::Missing { .. } => &CODES.locked_migration_missing,
307                            LockedMigrationError::Edited { .. } => &CODES.locked_migration_edited,
308                        };
309                        failures.push(DoctorFailure {
310                            code,
311                            message: err.to_string(),
312                        });
313                    }
314                }
315                None => failures.push(DoctorFailure {
316                    code: &CODES.migration_not_collected,
317                    message: format!(
318                        "migration {key} is not collected yet — run `fz migrations collect`"
319                    ),
320                }),
321            }
322        }
323    }
324}
325
326/// Sidecars (issue #66). The doctor sees only compiled-in modules, so
327/// it says plainly what it cannot check rather than reporting a clean
328/// bill for half the venture. A mount that shadows a compiled-in
329/// module is a real misconfiguration: the runtime ignores it and
330/// keeps the compiled-in module, so someone believes a sidecar is
331/// serving a prefix that it is not.
332fn sidecar_failures(
333    harness: &Harness,
334    sidecars: Option<&str>,
335    output: Output,
336    failures: &mut Vec<DoctorFailure>,
337) {
338    match crate::sidecars::parse(sidecars) {
339        Ok(mounts) if !mounts.is_empty() => {
340            for mount in crate::sidecars::shadowing(harness, &mounts) {
341                failures.push(DoctorFailure {
342                    code: &CODES.sidecar_shadows_module,
343                    message: format!(
344                        "sidecar mount `{}` names a module compiled into this venture; the runtime \
345                         ignores the mount and serves the compiled-in module. Remove it from \
346                         {HARNESS_SIDECARS} or remove the module from the composition",
347                        mount.name
348                    ),
349                });
350            }
351            if output == Output::Human {
352                eprintln!(
353                    "fz: warning: {} is sidecar-mounted, so its tables, migrations and SQL are not \
354                     checked here — they live in its own repository (docs/MOUNTING.md)",
355                    crate::sidecars::listed(&mounts)
356                );
357                // Gateway advisory (issue #131): without the shared secret the
358                // host stamps no token, so a sidecar that requires one refuses
359                // every forwarded request and a sidecar that does not cannot
360                // tell the host from the open internet.
361                if std::env::var(SIDECAR_GATEWAY_SECRET)
362                    .ok()
363                    .is_none_or(|value| value.trim().is_empty())
364                {
365                    eprintln!(
366                        "fz: warning: {SIDECAR_GATEWAY_SECRET} is unset — the host stamps no \
367                         gateway token, so a sidecar that requires one (SIDECAR_REQUIRE_GATEWAY) \
368                         refuses every forwarded request (issue #131)"
369                    );
370                }
371            }
372        }
373        Ok(_) => {}
374        Err(errors) => failures.extend(errors.into_iter().map(|message| DoctorFailure {
375            code: &CODES.sidecar_mount_invalid,
376            message,
377        })),
378    }
379}
380
381/// The SQL lints: the portable-SQL check and the card-data check (#44).
382/// The portable lint skips a module that ships a `postgres` set — it has
383/// declared where its SQL differs, the same rule
384/// `cratefield_adapter_postgres::select_set` applies when it chooses a set,
385/// and the doctor should not disagree with the runner. The card-data lint
386/// never skips: no dialect stores a card number, verification code or full
387/// expiry, unlike the portable lint above.
388fn lint_failures(harness: &Harness, failures: &mut Vec<DoctorFailure>) {
389    for module in harness.modules() {
390        if !module.migrations().postgres.is_empty() {
391            continue;
392        }
393        for migration in module.migrations().sqlite {
394            for (token, why) in banned_tokens(migration.sql) {
395                failures.push(DoctorFailure {
396                    code: &CODES.non_portable_sql,
397                    message: format!(
398                        "module `{}` migration {}: banned token `{token}` — {why}",
399                        module.name(),
400                        migration.id
401                    ),
402                });
403            }
404        }
405    }
406
407    for module in harness.modules() {
408        let migrations = module.migrations();
409        for migration in migrations.sqlite.iter().chain(migrations.postgres.iter()) {
410            for (fragment, why) in lint_card_data(migration.sql) {
411                failures.push(DoctorFailure {
412                    code: &CODES.card_data_in_migration,
413                    message: format!(
414                        "module `{}` migration {}: `{fragment}` {why}",
415                        module.name(),
416                        migration.id
417                    ),
418                });
419            }
420        }
421    }
422}
423
424/// The push-wiring section (issue #191). The doctor does not read the push
425/// environment itself: it calls the same `build_push` `serve()` and `fz push`
426/// call, through `inspect_push`, so the three cannot check different variable
427/// names.
428///
429/// Gated on what the harness declares, like every other production-only rule
430/// here: the process environment is shared, and a venture that mounts no push
431/// module at all must not fail its deploy because the env file it inherits
432/// exports someone else's `VAPID_SUBJECT`.
433fn push_checks(
434    harness: &Harness,
435    env: VentureEnv,
436    output: Output,
437    failures: &mut Vec<DoctorFailure>,
438) {
439    let declaration = PushDeclaration::of(harness.modules());
440    if !declaration.declared {
441        return;
442    }
443    push_wiring_checks(
444        &cratefield_push_wiring::inspect_push(&EnvVars),
445        env,
446        declaration,
447        output,
448        failures,
449    );
450}
451
452/// What this venture says about push, so the doctor's push rules key on the
453/// harness rather than on whatever the process environment happens to
454/// export (issue #191).
455#[derive(Debug, Clone, Copy, PartialEq, Eq)]
456struct PushDeclaration {
457    /// A module lists [`Port::Push`] in `requires()` or `optional()`. Only
458    /// then is the push environment this venture's business.
459    declared: bool,
460    /// A module lists it in `requires()`: this venture does not work
461    /// without a transport that can actually send.
462    required: bool,
463}
464
465impl PushDeclaration {
466    fn of(modules: &[std::sync::Arc<dyn cratefield_core::Module>]) -> Self {
467        Self::from_ports(
468            modules
469                .iter()
470                .map(|module| (module.requires(), module.optional())),
471        )
472    }
473
474    /// The rule itself, over nothing but the port lists — so it is unit
475    /// tested without composing a harness, which for a module that
476    /// *requires* a port would mean a runtime that provides it too.
477    fn from_ports<'a>(modules: impl Iterator<Item = (&'a [Port], &'a [Port])>) -> Self {
478        let mut declaration = Self {
479            declared: false,
480            required: false,
481        };
482        for (requires, optional) in modules {
483            if requires.contains(&Port::Push) {
484                declaration.declared = true;
485                declaration.required = true;
486            } else if optional.contains(&Port::Push) {
487                declaration.declared = true;
488            }
489        }
490        declaration
491    }
492}
493
494/// Turns a push wiring report into doctor output: failures in production,
495/// warnings below it (issue #191). Split out so the rules are unit tested
496/// without a process environment.
497///
498/// The severity rule itself is [`PushWiring::check`]'s, called rather than
499/// restated — the same rule encoded twice is the drift this crate exists to
500/// prevent, one copy short of a test.
501///
502/// The second rule is the build-time gate `push_from_env()` cannot keep. A
503/// runtime that assembles the port from the environment provides `Port::Push`
504/// whatever that environment holds — on Workers the `Env` does not exist
505/// until the first fetch, so `Harness::build` cannot know — and a module that
506/// requires push then compiles, deploys, and answers `NotConfigured` for
507/// every send. The doctor is where the deployment's environment *is* known,
508/// so the refusal lives here: in production, a venture whose modules require
509/// push and whose environment routes no transport at all fails.
510fn push_wiring_checks(
511    wiring: &PushWiring,
512    env: VentureEnv,
513    declaration: PushDeclaration,
514    output: Output,
515    failures: &mut Vec<DoctorFailure>,
516) {
517    match wiring.check(env) {
518        // Half-wired and refused-credentials are one code, not two: the
519        // message already names which transport and which variables, and
520        // `check` reports every misconfigured transport in one string, so a
521        // venture with one of each has a single failure to classify. The
522        // operator action is the same either way — finish or correct that
523        // transport's variables, or unset them to say it is deliberately off.
524        Err(problems) => failures.push(DoctorFailure {
525            code: &CODES.push_transport_misconfigured,
526            message: problems,
527        }),
528        // Below production the same problems are a warning: wiring a
529        // transport one variable at a time is what development looks like.
530        Ok(()) => {
531            if output == Output::Human {
532                for problem in wiring.problems() {
533                    eprintln!("fz: warning: {problem}");
534                }
535            }
536        }
537    }
538
539    if declaration.required && env == VentureEnv::Production && !wiring.any_routed() {
540        failures.push(DoctorFailure {
541            code: &CODES.push_required_but_unrouted,
542            message: "production venture has modules that require the Push port but no push \
543                      transport is configured — every send would answer NotConfigured. Set one \
544                      transport's variables (docs/PUSH-ENV.md), or drop the modules that require \
545                      push. If this venture provides Push from an adapter of its own rather than \
546                      from the environment, that adapter is not visible here (issue #191)"
547                .to_owned(),
548        });
549    }
550}
551
552/// The production-only port rules, gathered so `doctor` stays a flat list of
553/// checks: the captcha rule (with its override), the payments webhook rule,
554/// and the rate-limiter rule (with its override).
555///
556/// Read once and passed in, rather than each check reaching for
557/// `std::env` itself. Environment variables are process-global and tests
558/// run in one process, so a check that reads them directly can be tested
559/// as a pure function and never as a *called* one — falsifying the auth
560/// check showed exactly that: deleting the call left every test green.
561#[derive(Debug, Clone, Copy, PartialEq, Eq)]
562pub(crate) struct Configured {
563    pub(crate) auth_issuer: bool,
564    pub(crate) auth_client_id: bool,
565    pub(crate) stripe_webhook_secret: bool,
566    /// The `ADMIN_TOKEN`'s length, not its presence: the boot's floor is a
567    /// length (`MIN_SECRET_BYTES`, issue #437), so the doctor has to
568    /// measure the same thing the boot measures. `None` is unset — legal
569    /// everywhere, here as in `HarnessConfig::from_config`.
570    pub(crate) admin_token_bytes: Option<usize>,
571}
572
573impl Configured {
574    fn from_env() -> Self {
575        Self {
576            auth_issuer: set("AUTH_ISSUER"),
577            auth_client_id: set("AUTH_CLIENT_ID"),
578            stripe_webhook_secret: set("STRIPE_WEBHOOK_SECRET"),
579            // Read the way `EnvVars::get` reads — the read the boot's
580            // config makes — so a set-but-empty variable is unset here
581            // too, and the length is the raw value's, untrimmed: the boot
582            // measures the raw value, and the doctor may not be stricter.
583            admin_token_bytes: std::env::var("ADMIN_TOKEN")
584                .ok()
585                .filter(|value| !value.is_empty())
586                .map(|value| value.len()),
587        }
588    }
589}
590
591fn production_port_checks(
592    harness: &Harness,
593    configured: Configured,
594    config: &dyn cratefield_core::Config,
595    allow_no_captcha: Option<&str>,
596    output: Output,
597    failures: &mut Vec<DoctorFailure>,
598) {
599    let guards = cratefield_core::WriteGuards::collect(harness.modules());
600    if let Some(message) = captcha_production_failure(
601        &guards,
602        cratefield_core::captcha_effective(harness.runtime()),
603    ) {
604        // The same rule the gate in `route_policy` applies, from the
605        // same function: `--allow-no-captcha ""` is not an acceptance,
606        // so it does not downgrade the failure and does not get logged
607        // as though somebody had answered for it.
608        match cratefield_core::stated_reason(allow_no_captcha) {
609            Some(reason) => {
610                // An override of a production abuse control is a decision
611                // someone has to answer for later, so it is recorded, not
612                // just printed (issue #143). `tracing` is the audited sink
613                // — scrubbed by #135 and shipped wherever the operator
614                // sends logs — and fires in both disciplines, while the
615                // stderr line keeps the operator running the command
616                // informed.
617                tracing::warn!(
618                    control = "captcha",
619                    reason,
620                    modules = guards.captcha_modules.join(","),
621                    "production abuse control overridden by an operator"
622                );
623                if output == Output::Human {
624                    eprintln!("fz: warning: captcha override accepted ({reason}): {message}");
625                    eprintln!(
626                        "fz: note: this override is recorded; it is for previews only and must \
627                         not stand in for a Captcha port in production"
628                    );
629                }
630            }
631            None => failures.push(DoctorFailure {
632                code: &CODES.captcha_not_effective,
633                message,
634            }),
635        }
636    }
637
638    if let Some(message) = auth_production_failure(
639        harness
640            .modules()
641            .iter()
642            .any(|module| module.requires().contains(&Port::Auth)),
643        configured.auth_issuer,
644        configured.auth_client_id,
645    ) {
646        failures.push(DoctorFailure {
647            code: &CODES.auth_not_configured,
648            message,
649        });
650    }
651
652    // Name each Payments-verifying module with the routes it guards
653    // (issue #595): a module may verify one webhook through Payments and
654    // another with an HMAC secret, so the operator should see which route
655    // the missing `STRIPE_WEBHOOK_SECRET` blocks.
656    let payments: Vec<String> = guards
657        .payments_signature_modules
658        .iter()
659        .map(|module| {
660            guards.signature_module_named(module, cratefield_core::SignatureVerification::Payments)
661        })
662        .collect();
663    if let Some(message) = payments_webhook_failure(&payments, configured.stripe_webhook_secret) {
664        failures.push(DoctorFailure {
665            code: &CODES.payments_webhook_secret_missing,
666            message,
667        });
668    }
669
670    // The other half of the webhook rule (issues #533, #595): the key is
671    // per module, so this check takes composed keys and routes and a
672    // config rather than a `Configured` bool — still pure, the caller
673    // supplies the deployment config.
674    if let Some(message) = hmac_webhook_failure(&guards.hmac_signature_routes(), config) {
675        failures.push(DoctorFailure {
676            code: &CODES.hmac_webhook_secret_missing,
677            message,
678        });
679    }
680
681    // The limiter leg mirrors the captcha rule above, hatch and all: the
682    // same two answers the boot gate (`production_readiness`) reads —
683    // what the composition declares and whether the runtime's limiter is
684    // effective — with an operator's recorded reason downgrading the
685    // failure to a logged warning. `unlimited_public_routes_override`
686    // applies the blank-reason rule itself, so an empty acceptance never
687    // counts, exactly as `stated_reason` does for the captcha flag.
688    if let Some(message) = rate_limiter_production_failure(
689        guards.needs_rate_limiter(),
690        cratefield_core::rate_limiter_effective(harness.runtime()),
691        &guards
692            .rate_limiter_modules
693            .iter()
694            .map(|(module, routes)| format!("{module} ({})", routes.join(", ")))
695            .collect::<Vec<_>>(),
696    ) {
697        match cratefield_core::unlimited_public_routes_override(config) {
698            Some(reason) => {
699                tracing::warn!(
700                    control = "rate-limiter",
701                    reason,
702                    "production abuse control overridden by an operator"
703                );
704                if output == Output::Human {
705                    eprintln!("fz: warning: rate-limiter override accepted ({reason}): {message}");
706                    eprintln!(
707                        "fz: note: this override is recorded; resolve the RateLimiter binding \
708                         and remove HARNESS_ALLOW_UNLIMITED_PUBLIC_ROUTES before serving \
709                         production traffic"
710                    );
711                }
712            }
713            None => failures.push(DoctorFailure {
714                code: &CODES.rate_limiter_not_effective,
715                message,
716            }),
717        }
718    }
719}
720
721/// Whether an environment variable is set to something non-blank.
722fn set(key: &str) -> bool {
723    std::env::var(key).is_ok_and(|value| !value.trim().is_empty())
724}
725
726/// The production auth rule (issue #153): a module that declares
727/// `Port::Auth` is a module that must know who is calling, and a
728/// deployment with no issuer configured cannot tell it.
729///
730/// The runtime provides the port either way — on Workers the `Env` exists
731/// per request, so a runtime cannot know at compose time whether the
732/// issuer is set, and refusing to provide it would stop every such
733/// deployment booting, including the ones whose tables are all public.
734/// The cost of that choice is that the failure moves to request time: an
735/// unconfigured deployment serves its public routes and answers `503` on
736/// every route that needs a caller.
737///
738/// `fz doctor` runs where the operator's environment is, which is why the
739/// check belongs here — the same place `STRIPE_WEBHOOK_SECRET` is read,
740/// and for the same reason.
741fn auth_production_failure(
742    needs_auth: bool,
743    issuer_set: bool,
744    client_id_set: bool,
745) -> Option<String> {
746    if !needs_auth || (issuer_set && client_id_set) {
747        return None;
748    }
749    let mut missing = Vec::new();
750    if !issuer_set {
751        missing.push("AUTH_ISSUER");
752    }
753    if !client_id_set {
754        missing.push("AUTH_CLIENT_ID");
755    }
756    Some(format!(
757        "a module requires the Auth port and {} is not set; every route that needs a signed-in \
758         caller will answer 503",
759        missing.join(" and ")
760    ))
761}
762
763/// The production captcha rule (issue #133): captcha-guarded writes need a
764/// Captcha port that is *effectively* configured — provided **and** bound per
765/// the adapter's report — not merely present. The builder already refuses
766/// this composition (`Harness::build`); the doctor re-checks it so the rule
767/// stays visible in operator tooling.
768fn captcha_production_failure(
769    guards: &cratefield_core::WriteGuards,
770    captcha_effective: bool,
771) -> Option<String> {
772    if guards.needs_captcha() && !captcha_effective {
773        Some(format!(
774            "production venture has captcha-guarded public writes from [{}] but the Captcha port \
775             is not effectively configured — provide Turnstile with its secret and expected \
776             hostname (architecture section 11; issue #133)",
777            guards.captcha_modules.join(", ")
778        ))
779    } else {
780        None
781    }
782}
783
784/// The production-payments rule: a production venture that declares
785/// [`Signature`]-guarded routes without a webhook signing secret cannot
786/// verify Stripe webhooks, so it would process forged events. Pure so the
787/// truth table is unit-tested; the caller supplies the routes (naming each
788/// module and the `METHOD /path` it verifies through Payments, issue #595)
789/// and the env-derived boolean.
790///
791/// [`Signature`]: cratefield_core::RoutePolicy::Signature
792fn payments_webhook_failure(
793    payments_routes: &[String],
794    webhook_secret_present: bool,
795) -> Option<String> {
796    (!payments_routes.is_empty() && !webhook_secret_present).then(|| {
797        format!(
798            "production venture has signature-guarded routes [{}] but STRIPE_WEBHOOK_SECRET is \
799             unset — webhook signatures cannot be verified and forged events would be trusted \
800             (issue #102)",
801            payments_routes.join(", ")
802        )
803    })
804}
805
806/// The production webhook-secret rule for modules that verify through the
807/// core HMAC scheme (issues #533, #595): a deployment missing one has a
808/// module whose handler refuses every delivery, and the message names the
809/// route that reads the key so a two-verifier module says which endpoint
810/// is blocked. Keys are named, never values; pure so the truth table is
811/// unit-tested, the caller supplies the config.
812fn hmac_webhook_failure(
813    hmac_modules: &[(String, String, Vec<String>)],
814    config: &dyn cratefield_core::Config,
815) -> Option<String> {
816    let missing: Vec<String> = hmac_modules
817        .iter()
818        .filter(|(_, key, _)| config.get(key).is_none_or(|value| value.trim().is_empty()))
819        .map(|(_, key, routes)| {
820            if routes.is_empty() {
821                key.clone()
822            } else {
823                format!("{key} on {}", routes.join(", "))
824            }
825        })
826        .collect();
827    (!missing.is_empty()).then(|| {
828        format!(
829            "production venture verifies webhook signatures with the webhook_signature HMAC \
830             scheme but {} is not set — those handlers refuse every delivery until the \
831             provider's signing secret is configured (issue #533)",
832            missing.join(", ")
833        )
834    })
835}
836
837/// The production rate-limiter rule (issue #437): a venture that takes
838/// public writes or admin routes needs a limiter the runtime can actually
839/// consult — an admin bearer token is guessed rather than submitted and has
840/// no captcha or cooldown behind it, and every public write runs without a
841/// budget otherwise. The boot gate (`production_readiness`, via
842/// `Harness::build` and `Harness::router`) enforces the same rule from the
843/// same two answers; the doctor re-checks it so the operator learns before
844/// the deploy, which is where this command runs.
845fn rate_limiter_production_failure(
846    needs_rate_limiter: bool,
847    rate_limiter_effective: bool,
848    declaring: &[String],
849) -> Option<String> {
850    if needs_rate_limiter && !rate_limiter_effective {
851        // Modules that declare the port (issue #562) are named with their
852        // routes, the way the boot gate names them — the operator should
853        // be able to fix the named thing without re-deriving which route
854        // made the rule fire, and the consequence named is theirs, not a
855        // public-write venture's.
856        let (who, cost) = if declaring.is_empty() {
857            (
858                "takes public writes or admin routes".to_owned(),
859                "admin bearer routes have no brute-force backstop behind the limiter and \
860                 every public write runs without a budget"
861                    .to_owned(),
862            )
863        } else {
864            (
865                format!(
866                    "declares RateLimiter for public routes from [{}]",
867                    declaring.join(", ")
868                ),
869                "those routes — and any public write or admin route beside them — run \
870                 without a budget"
871                    .to_owned(),
872            )
873        };
874        Some(format!(
875            "production venture {who} but the RateLimiter port is not resolved: {cost} — \
876                 resolve the binding so the runtime actually hands over a limiter, or set \
877                 HARNESS_ALLOW_UNLIMITED_PUBLIC_ROUTES to a reason to serve unlimited \
878                 (issues #437, #562)"
879        ))
880    } else {
881        None
882    }
883}
884
885/// The admin-token floor (issue #437). `HarnessConfig::from_config`
886/// refuses a set-but-short `ADMIN_TOKEN` in **every** environment
887/// (`MIN_SECRET_BYTES`), so this rule runs unconditionally too: gated on
888/// production it would leave the doctor more permissive than the boot it
889/// predicts. Absent stays legal — the token is what turns the admin plane
890/// on, and removing it is the documented way to keep that plane disabled.
891fn admin_token_failure(admin_token_bytes: Option<usize>) -> Option<String> {
892    let bytes = admin_token_bytes?;
893    (bytes < MIN_SECRET_BYTES).then(|| {
894        format!(
895            "ADMIN_TOKEN is {bytes} bytes but must be at least {MIN_SECRET_BYTES} when set — \
896             the boot refuses it in every environment, so this deploy cannot start. Lengthen \
897             it, or remove it to keep the admin routes disabled (issue #437)"
898        )
899    })
900}
901
902#[cfg(test)]
903mod tests {
904    use super::{
905        CODES, DoctorFailure, DoctorReport, Output, PushDeclaration, admin_token_failure,
906        captcha_production_failure, hmac_webhook_failure, payments_webhook_failure,
907        push_wiring_checks, rate_limiter_production_failure,
908    };
909    use cratefield_core::{
910        Config, ConfigError, Harness, MapConfig, Migrations, Module, ModuleContext, Port, Runtime,
911        Venture, VentureEnv, WriteGuards,
912    };
913    use cratefield_push_wiring::{PUSH_ENV, PushKey, PushWiring, inspect_push};
914    use std::sync::Arc;
915
916    /// A module that must know who is calling.
917    pub(super) struct NeedsAuth;
918
919    /// One that does not.
920    pub(super) struct Nothing;
921
922    macro_rules! bare_module {
923        ($ty:ty, $name:literal, $ports:expr) => {
924            impl Module for $ty {
925                fn name(&self) -> &'static str {
926                    $name
927                }
928                fn version(&self) -> &'static str {
929                    "0.0.0"
930                }
931                fn requires(&self) -> &'static [Port] {
932                    $ports
933                }
934                fn migrations(&self) -> Migrations {
935                    Migrations::default()
936                }
937                fn validate_config(&self, _cfg: &dyn Config) -> Result<(), ConfigError> {
938                    Ok(())
939                }
940                fn router(&self, _ctx: ModuleContext) -> cratefield_core::axum::Router {
941                    cratefield_core::axum::Router::new()
942                }
943            }
944        };
945    }
946
947    /// A module with a signature-guarded write — a webhook receiver.
948    pub(super) struct TakesWebhooks;
949
950    bare_module!(NeedsAuth, "needs-auth", &[Port::Auth]);
951    bare_module!(Nothing, "nothing", &[]);
952    impl Module for TakesWebhooks {
953        fn name(&self) -> &'static str {
954            "takes-webhooks"
955        }
956        fn version(&self) -> &'static str {
957            "0.0.0"
958        }
959        fn requires(&self) -> &'static [Port] {
960            &[Port::Payments]
961        }
962        fn migrations(&self) -> Migrations {
963            Migrations::default()
964        }
965        fn validate_config(&self, _cfg: &dyn Config) -> Result<(), ConfigError> {
966            Ok(())
967        }
968        fn surface(&self) -> cratefield_core::Surface {
969            Self::surface()
970        }
971        fn router(&self, _ctx: ModuleContext) -> cratefield_core::axum::Router {
972            cratefield_core::axum::Router::new()
973        }
974    }
975
976    impl TakesWebhooks {
977        /// Declared through the surface, which is where `WriteGuards`
978        /// reads a route's policy from.
979        fn surface() -> cratefield_core::Surface {
980            cratefield_core::Surface::new().action(
981                cratefield_core::Action::post("receive", "/receive")
982                    .policy(cratefield_core::RoutePolicy::Signature)
983                    .outcome(cratefield_core::Outcome::Json),
984            )
985        }
986    }
987
988    /// A billing module with two webhooks and two verifiers (issue #595):
989    /// `POST /webhooks/stripe` verifies through `Payments`, and
990    /// `POST /webhooks/revenuecat` names its own HMAC secret — so the two
991    /// production webhook rules must name the route each one blocks.
992    pub(super) struct TwoVerifiers;
993
994    impl Module for TwoVerifiers {
995        fn name(&self) -> &'static str {
996            "billing"
997        }
998        fn version(&self) -> &'static str {
999            "0.0.0"
1000        }
1001        fn requires(&self) -> &'static [Port] {
1002            &[Port::Payments]
1003        }
1004        fn migrations(&self) -> Migrations {
1005            Migrations::default()
1006        }
1007        fn validate_config(&self, _cfg: &dyn Config) -> Result<(), ConfigError> {
1008            Ok(())
1009        }
1010        fn surface(&self) -> cratefield_core::Surface {
1011            Self::surface()
1012        }
1013        fn router(&self, _ctx: ModuleContext) -> cratefield_core::axum::Router {
1014            cratefield_core::axum::Router::new()
1015        }
1016    }
1017
1018    impl TwoVerifiers {
1019        fn surface() -> cratefield_core::Surface {
1020            cratefield_core::Surface::new()
1021                .action(
1022                    cratefield_core::Action::post("webhook-stripe", "/webhooks/stripe")
1023                        .policy(cratefield_core::RoutePolicy::Signature)
1024                        .outcome(cratefield_core::Outcome::Json),
1025                )
1026                .action(
1027                    cratefield_core::Action::post("webhook-revenuecat", "/webhooks/revenuecat")
1028                        .policy(cratefield_core::RoutePolicy::Signature)
1029                        .verification(cratefield_core::SignatureVerification::Hmac {
1030                            secret: "REVENUECAT_WEBHOOK_SECRET",
1031                        })
1032                        .outcome(cratefield_core::Outcome::Json),
1033                )
1034        }
1035    }
1036
1037    /// A runtime that provides everything, so composition succeeds and the
1038    /// production checks are what the test is about.
1039    struct AllPorts;
1040
1041    impl Runtime for AllPorts {
1042        fn provides(&self) -> Vec<Port> {
1043            Port::ALL.to_vec()
1044        }
1045    }
1046
1047    /// A composed harness mounting one module.
1048    pub(super) fn harness_of(module: Arc<dyn Module>) -> Harness {
1049        Harness::builder()
1050            .venture(
1051                Venture::new("acme", "acme.test")
1052                    .cors_origins(["https://acme.test"])
1053                    .env(VentureEnv::Production),
1054            )
1055            .module_arc(module)
1056            .runtime(AllPorts)
1057            .build()
1058            .expect("the fixture composes")
1059    }
1060
1061    /// A venture whose modules only *may* use push: the wiring is checked,
1062    /// but nothing has to be routed.
1063    const OPTIONAL: PushDeclaration = PushDeclaration {
1064        declared: true,
1065        required: false,
1066    };
1067
1068    /// A venture whose modules cannot work without a transport.
1069    const REQUIRED: PushDeclaration = PushDeclaration {
1070        declared: true,
1071        required: true,
1072    };
1073
1074    fn guards(needs_captcha: bool) -> WriteGuards {
1075        WriteGuards {
1076            captcha_modules: if needs_captcha {
1077                vec!["writer".to_owned()]
1078            } else {
1079                Vec::new()
1080            },
1081            signature_modules: Vec::new(),
1082            signed_link_modules: Vec::new(),
1083            // The rule under test reads only `captcha_modules`; the
1084            // limiter-leg answers come from `Default`.
1085            ..WriteGuards::default()
1086        }
1087    }
1088
1089    #[test]
1090    fn a_venture_that_needs_no_caller_is_not_asked_for_an_issuer() {
1091        // Most ventures do not mount a module that identifies callers.
1092        // Demanding an auth service from them would be demanding one they
1093        // have no use for.
1094        assert_eq!(super::auth_production_failure(false, false, false), None);
1095    }
1096
1097    #[test]
1098    fn a_module_that_must_know_who_is_calling_needs_an_issuer_in_production() {
1099        // The runtime provides the port either way, so the failure is not
1100        // at boot: it is every route that needs a caller answering 503,
1101        // which is the thing an operator wants told before the deploy.
1102        let refusal = super::auth_production_failure(true, false, false)
1103            .expect("a module needs a caller and nothing can name one");
1104        assert!(refusal.contains("AUTH_ISSUER"), "{refusal}");
1105        assert!(refusal.contains("AUTH_CLIENT_ID"), "{refusal}");
1106        assert!(refusal.contains("503"), "say what goes wrong: {refusal}");
1107    }
1108
1109    #[test]
1110    fn half_a_verifier_is_still_a_failure_and_names_the_half() {
1111        // Setting one of the two is the likely mistake, and a message
1112        // that said "auth is not configured" would send an operator to
1113        // check the one they already set.
1114        let missing_id = super::auth_production_failure(true, true, false).expect("half");
1115        assert!(missing_id.contains("AUTH_CLIENT_ID"), "{missing_id}");
1116        assert!(!missing_id.contains("AUTH_ISSUER"), "{missing_id}");
1117
1118        let missing_issuer = super::auth_production_failure(true, false, true).expect("half");
1119        assert!(missing_issuer.contains("AUTH_ISSUER"), "{missing_issuer}");
1120        assert!(
1121            !missing_issuer.contains("AUTH_CLIENT_ID"),
1122            "{missing_issuer}"
1123        );
1124    }
1125
1126    #[test]
1127    fn a_configured_verifier_is_no_failure() {
1128        assert_eq!(super::auth_production_failure(true, true, true), None);
1129    }
1130
1131    #[test]
1132    fn captcha_rule_keys_on_declared_policies_and_effectiveness() {
1133        // Guarded writes without an effective port fail — even if the port
1134        // is *present*, since effectiveness is what the gate measures.
1135        let failure = captcha_production_failure(&guards(true), false).expect("fails closed");
1136        assert!(failure.contains("writer"));
1137        assert!(failure.contains("effectively configured"));
1138        assert!(captcha_production_failure(&guards(true), true).is_none());
1139        assert!(captcha_production_failure(&guards(false), false).is_none());
1140
1141        // The waiver this failure is matched against is the *reason*,
1142        // not the flag. `--allow-no-captcha ""` used to reach the arm
1143        // that downgrades the failure to a warning and logs the
1144        // operator's acceptance — with an empty reason in the log.
1145        // `docs/SECURITY.md` says "A blank reason is not an
1146        // acceptance"; this is the half of that sentence the CLI owns.
1147        for nothing in ["", "   "] {
1148            assert!(
1149                cratefield_core::stated_reason(Some(nothing)).is_none(),
1150                "an empty reason counted as an acceptance: {nothing:?}"
1151            );
1152        }
1153        assert_eq!(
1154            cratefield_core::stated_reason(Some("  previewing the new form  ")),
1155            Some("previewing the new form"),
1156            "a real reason still overrides, trimmed"
1157        );
1158    }
1159
1160    #[test]
1161    fn payments_in_production_needs_a_webhook_secret() {
1162        // The route is named, so a module with two verifiers says which
1163        // endpoint the missing secret blocks (issue #595).
1164        let payments = vec!["billing (POST /v1/billing/webhooks/stripe)".to_owned()];
1165        let failure = payments_webhook_failure(&payments, false).expect("no secret fails");
1166        assert!(
1167            failure.contains("POST /v1/billing/webhooks/stripe"),
1168            "{failure}"
1169        );
1170        assert!(payments_webhook_failure(&payments, true).is_none());
1171        assert!(payments_webhook_failure(&[], false).is_none());
1172
1173        // The other half (issue #533): the module's own key, unset or
1174        // blank, fails; set passes; no such module never fails. The route
1175        // that reads the key is named too (issue #595).
1176        let modules = vec![(
1177            "pos".to_owned(),
1178            "POS_WEBHOOK_SECRET".to_owned(),
1179            vec!["POST /webhook".to_owned()],
1180        )];
1181        let failure = hmac_webhook_failure(&modules, &MapConfig::default()).expect("unset fails");
1182        assert!(failure.contains("POS_WEBHOOK_SECRET"), "{failure}");
1183        assert!(failure.contains("POST /webhook"), "{failure}");
1184        assert!(
1185            hmac_webhook_failure(
1186                &modules,
1187                &MapConfig::from_pairs([("POS_WEBHOOK_SECRET", "   ")])
1188            )
1189            .is_some()
1190        );
1191        assert!(
1192            hmac_webhook_failure(
1193                &modules,
1194                &MapConfig::from_pairs([("POS_WEBHOOK_SECRET", "x")])
1195            )
1196            .is_none()
1197        );
1198        assert!(hmac_webhook_failure(&[], &MapConfig::default()).is_none());
1199    }
1200
1201    #[test]
1202    fn a_venture_with_public_writes_or_admin_routes_needs_a_resolved_limiter() {
1203        // Fails closed on exactly the two answers the boot gate reads:
1204        // something writable or an admin plane, and no effective limiter.
1205        let failure =
1206            rate_limiter_production_failure(true, false, &[]).expect("an unlimited venture fails");
1207        assert!(failure.contains("RateLimiter"), "{failure}");
1208        assert!(
1209            failure.contains("HARNESS_ALLOW_UNLIMITED_PUBLIC_ROUTES"),
1210            "the hatch is named the way the captcha rule names its own: {failure}"
1211        );
1212        assert!(rate_limiter_production_failure(true, true, &[]).is_none());
1213        // Nothing writable, no admin plane: no budget is owed.
1214        assert!(rate_limiter_production_failure(false, false, &[]).is_none());
1215    }
1216
1217    #[test]
1218    fn a_doctor_limiter_failure_names_the_declaring_modules() {
1219        // Issue #562: when a module declares the port, the doctor message
1220        // names it and its routes, the way the boot gate's message does.
1221        let failure = rate_limiter_production_failure(
1222            true,
1223            false,
1224            &["waitlist (POST /v1/waitlist, GET /v1/waitlist/search)".to_owned()],
1225        )
1226        .expect("a declaring venture fails");
1227        assert!(failure.contains("declares RateLimiter"), "{failure}");
1228        assert!(
1229            failure.contains("waitlist (POST /v1/waitlist, GET /v1/waitlist/search)"),
1230            "{failure}"
1231        );
1232    }
1233
1234    #[test]
1235    fn an_absent_admin_token_is_not_a_failure() {
1236        // Absent is how the admin plane stays disabled, and the boot
1237        // treats it the same in every environment.
1238        assert_eq!(admin_token_failure(None), None);
1239    }
1240
1241    #[test]
1242    fn a_set_but_short_admin_token_fails_at_the_floor_the_boot_enforces() {
1243        let refusal = admin_token_failure(Some(8)).expect("a short token");
1244        assert!(refusal.contains("ADMIN_TOKEN"), "{refusal}");
1245        assert!(refusal.contains("32"), "name the floor: {refusal}");
1246        assert!(
1247            refusal.contains("every environment"),
1248            "the rule is not production-only, and says so: {refusal}"
1249        );
1250
1251        // At the floor and above is what the boot accepts, so the doctor
1252        // agrees at the boundary rather than one byte stricter.
1253        assert_eq!(admin_token_failure(Some(32)), None);
1254        assert_eq!(admin_token_failure(Some(64)), None);
1255    }
1256
1257    /// The `--json` wire contract (harness #140): field order, the schema
1258    /// number, and the code string straight from the catalogue.
1259    #[test]
1260    fn report_renders_the_json_contract() {
1261        let report = DoctorReport {
1262            failures: vec![
1263                DoctorFailure {
1264                    code: &CODES.captcha_not_effective,
1265                    message: "production venture has captcha-guarded writes".to_owned(),
1266                },
1267                DoctorFailure {
1268                    code: &CODES.payments_webhook_secret_missing,
1269                    message: "STRIPE_WEBHOOK_SECRET is unset".to_owned(),
1270                },
1271            ],
1272        };
1273        let payload = report.render_json().expect("serializes");
1274        assert_eq!(
1275            payload,
1276            "{\"schema\":1,\"ok\":false,\"failures\":[\
1277             {\"code\":\"captcha-not-effective\",\"message\":\"production venture has \
1278             captcha-guarded writes\"},\
1279             {\"code\":\"payments-webhook-secret-missing\",\"message\":\"STRIPE_WEBHOOK_SECRET \
1280             is unset\"}]}"
1281        );
1282        assert!(!report.ok());
1283
1284        let clean = DoctorReport {
1285            failures: Vec::new(),
1286        };
1287        assert_eq!(
1288            clean.render_json().expect("serializes"),
1289            "{\"schema\":1,\"ok\":true,\"failures\":[]}"
1290        );
1291        assert!(clean.ok());
1292    }
1293
1294    /// A half-wired push environment, built through the real reader so this
1295    /// test names no environment variable of its own — the property
1296    /// `cli-acceptance`'s guard enforces across the workspace.
1297    fn half_wired() -> PushWiring {
1298        let var = PUSH_ENV
1299            .iter()
1300            .find(|var| var.required)
1301            .expect("the table has a required variable");
1302        let wiring = inspect_push(&MapConfig::from_pairs([(
1303            var.name().to_owned(),
1304            "set-but-alone".to_owned(),
1305        )]));
1306        assert!(
1307            !wiring.problems().is_empty(),
1308            "one variable of a transport is a half-wired transport: {}",
1309            wiring.summary()
1310        );
1311        wiring
1312    }
1313
1314    #[test]
1315    fn a_half_wired_transport_fails_the_doctor_in_production_only() {
1316        let wiring = half_wired();
1317
1318        let mut failures = Vec::new();
1319        push_wiring_checks(
1320            &wiring,
1321            VentureEnv::Production,
1322            OPTIONAL,
1323            Output::Human,
1324            &mut failures,
1325        );
1326        assert_eq!(failures.len(), 1, "{failures:?}");
1327        for problem in wiring.problems() {
1328            assert!(failures[0].message.contains(&problem), "{failures:?}");
1329        }
1330        // The slug itself, written out: the catalogue's contract is that a
1331        // code is never renamed, and a test that reads it back off `CODES`
1332        // would rename with it.
1333        assert_eq!(failures[0].code.code, "push-transport-misconfigured");
1334
1335        // Below production it is a printed warning, not a failure: wiring a
1336        // transport one variable at a time is what development looks like.
1337        for env in [VentureEnv::Development, VentureEnv::Staging] {
1338            let mut failures = Vec::new();
1339            push_wiring_checks(&wiring, env, OPTIONAL, Output::Human, &mut failures);
1340            assert!(failures.is_empty(), "{env:?}: {failures:?}");
1341        }
1342    }
1343
1344    #[test]
1345    fn a_venture_that_wires_no_push_at_all_is_silent() {
1346        // Absent is a choice, not a defect — the doctor must not nag a
1347        // venture that sends no notifications, even in production.
1348        let wiring = inspect_push(&MapConfig::from_pairs(Vec::<(String, String)>::new()));
1349        let mut failures = Vec::new();
1350        push_wiring_checks(
1351            &wiring,
1352            VentureEnv::Production,
1353            OPTIONAL,
1354            Output::Human,
1355            &mut failures,
1356        );
1357        assert!(failures.is_empty(), "{failures:?}");
1358        assert!(wiring.problems().is_empty(), "{:?}", wiring.problems());
1359    }
1360
1361    #[test]
1362    fn a_module_that_requires_push_needs_a_transport_that_can_send() {
1363        // The build-time gate `push_from_env()` cannot keep: on Workers the
1364        // `Env` does not exist when `Harness::build` runs, so a module
1365        // requiring Push compiles and deploys against an environment that
1366        // configures nothing and answers NotConfigured for every send. The
1367        // doctor runs where the environment is known, so it refuses.
1368        let nothing = inspect_push(&MapConfig::from_pairs(Vec::<(String, String)>::new()));
1369        assert!(!nothing.any_routed());
1370
1371        let mut failures = Vec::new();
1372        push_wiring_checks(
1373            &nothing,
1374            VentureEnv::Production,
1375            REQUIRED,
1376            Output::Human,
1377            &mut failures,
1378        );
1379        assert_eq!(failures.len(), 1, "{failures:?}");
1380        assert!(
1381            failures[0].message.contains("require the Push port"),
1382            "{failures:?}"
1383        );
1384        // Its own code, not the half-wired one: nothing here is half-wired,
1385        // and the operator's move is to route a transport or to drop the
1386        // modules that require one.
1387        assert_eq!(failures[0].code.code, "push-required-but-unrouted");
1388
1389        // Only in production, and only when a module actually requires it.
1390        for env in [VentureEnv::Development, VentureEnv::Staging] {
1391            let mut failures = Vec::new();
1392            push_wiring_checks(&nothing, env, REQUIRED, Output::Human, &mut failures);
1393            assert!(failures.is_empty(), "{env:?}: {failures:?}");
1394        }
1395        let mut failures = Vec::new();
1396        push_wiring_checks(
1397            &nothing,
1398            VentureEnv::Production,
1399            OPTIONAL,
1400            Output::Human,
1401            &mut failures,
1402        );
1403        assert!(failures.is_empty(), "{failures:?}");
1404    }
1405
1406    #[test]
1407    fn the_push_rules_are_gated_on_what_the_venture_declares() {
1408        // The process environment is shared. A venture that mounts no push
1409        // module at all must not fail its production doctor run because the
1410        // env file it inherits exports somebody else's VAPID_SUBJECT — every
1411        // other production-only rule here keys on the harness, and this one
1412        // did not.
1413        let none = PushDeclaration::from_ports([(&[Port::Db][..], &[][..])].into_iter());
1414        assert!(!none.declared);
1415        assert!(!none.required);
1416
1417        let optional =
1418            PushDeclaration::from_ports([(&[Port::Db][..], &[Port::Push][..])].into_iter());
1419        assert!(optional.declared);
1420        assert!(
1421            !optional.required,
1422            "a module that only *may* use push does not make one mandatory"
1423        );
1424
1425        let required = PushDeclaration::from_ports(
1426            [(&[Port::Db][..], &[][..]), (&[Port::Push][..], &[][..])].into_iter(),
1427        );
1428        assert!(required.declared);
1429        assert!(required.required);
1430    }
1431
1432    #[test]
1433    fn a_configured_transport_satisfies_a_module_that_requires_push() {
1434        // The rule must pass on the deployment it is meant to allow, or it
1435        // is just a way to fail every production doctor run.
1436        //
1437        // The variables are named through `PushKey`, not written out: the
1438        // guard in `cli-acceptance` allows exactly one source in the
1439        // workspace to spell one of these names, and it is not this one.
1440        let key = "-----BEGIN PRIVATE KEY-----\nMIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQg\
1441                   cXMgRpW+eLn7ZvCx\nIuTdd8csWMZ69azlRzS0dy2FN6GhRANCAATJ6GazR2lhWcC3JYsazLR0\
1442                   uWOyDKrC\nmeP4HPWghRmfoa4z3Ux7mG3Ylz+auRaBukKGicSdSvVG+jGeQwr3fNag\n\
1443                   -----END PRIVATE KEY-----";
1444        let wiring = inspect_push(&MapConfig::from_pairs([
1445            (PushKey::VapidPrivateKey.name().to_owned(), key.to_owned()),
1446            (
1447                PushKey::VapidSubject.name().to_owned(),
1448                "mailto:ops@example.test".to_owned(),
1449            ),
1450        ]));
1451        assert!(wiring.any_routed(), "{}", wiring.summary());
1452
1453        let mut failures = Vec::new();
1454        push_wiring_checks(
1455            &wiring,
1456            VentureEnv::Production,
1457            REQUIRED,
1458            Output::Human,
1459            &mut failures,
1460        );
1461        assert!(failures.is_empty(), "{failures:?}");
1462    }
1463}
1464
1465#[cfg(test)]
1466mod production_wiring {
1467    //! That the production checks are *called*, not only correct.
1468    //!
1469    //! Falsifying the auth rule showed the gap: deleting its call from
1470    //! `production_port_checks` left every test green, because each check
1471    //! was only ever exercised as a pure function. The same was true of
1472    //! the payments rule, and both are covered here.
1473    //!
1474    //! The captcha rule is **not**, and cannot be: `Harness::build`
1475    //! refuses a production composition with a form-guarded write and an
1476    //! ineffective Captcha port, so a harness that would trip the
1477    //! doctor's copy of that rule cannot be composed to hand it. The
1478    //! doctor re-checks it deliberately — to keep the rule visible in
1479    //! operator tooling — and the only path that reaches it is a venture
1480    //! built with `--allow-no-captcha`, which is the override, not the
1481    //! failure. Its own pure test stands.
1482    //!
1483    //! The rate-limiter leg (issue #437) joins that club for the same
1484    //! reason: `Harness::build` runs `append_production_readiness` over
1485    //! the same two answers the doctor reads — `WriteGuards::collect`
1486    //! plus `rate_limiter_effective` — so a production harness that
1487    //! would trip the doctor's copy cannot be composed either (the
1488    //! escape is the `HARNESS_ALLOW_UNLIMITED_PUBLIC_ROUTES` env var,
1489    //! which a test must not export into a shared process). Its own
1490    //! pure test stands.
1491    //!
1492    //! The admin-token check is wired differently — it is called from
1493    //! `run_checks` directly, because the boot enforces it in *every*
1494    //! environment — so its proof runs the whole doctor against a
1495    //! crafted [`Configured`], no process environment involved.
1496
1497    use super::tests::{NeedsAuth, Nothing, harness_of};
1498    use super::{CODES, Configured, DoctorFailure, Output, production_port_checks, run_checks};
1499
1500    fn failures(
1501        module: std::sync::Arc<dyn cratefield_core::Module>,
1502        configured: Configured,
1503    ) -> Vec<String> {
1504        let harness = harness_of(module);
1505        let mut out = Vec::new();
1506        production_port_checks(
1507            &harness,
1508            configured,
1509            &cratefield_core::MapConfig::default(),
1510            None,
1511            Output::Json,
1512            &mut out,
1513        );
1514        out.iter()
1515            .map(|failure: &DoctorFailure| failure.code.code.to_owned())
1516            .collect()
1517    }
1518
1519    /// The `(code, message)` pairs, so a test can assert a message names
1520    /// the route a missing secret blocks (issue #595).
1521    fn messages(
1522        module: std::sync::Arc<dyn cratefield_core::Module>,
1523        configured: Configured,
1524        config: &dyn cratefield_core::Config,
1525    ) -> Vec<(String, String)> {
1526        let harness = harness_of(module);
1527        let mut out = Vec::new();
1528        production_port_checks(&harness, configured, config, None, Output::Json, &mut out);
1529        out.iter()
1530            .map(|failure| (failure.code.code.to_owned(), failure.message.clone()))
1531            .collect()
1532    }
1533
1534    const NONE_SET: Configured = Configured {
1535        auth_issuer: false,
1536        auth_client_id: false,
1537        stripe_webhook_secret: false,
1538        admin_token_bytes: None,
1539    };
1540
1541    #[test]
1542    fn a_module_needing_a_caller_with_no_issuer_is_reported() {
1543        assert!(
1544            failures(std::sync::Arc::new(NeedsAuth), NONE_SET)
1545                .contains(&CODES.auth_not_configured.code.to_owned()),
1546            "the rule is correct and nothing calls it"
1547        );
1548    }
1549
1550    #[test]
1551    fn the_same_module_with_an_issuer_is_not() {
1552        let configured = Configured {
1553            auth_issuer: true,
1554            auth_client_id: true,
1555            ..NONE_SET
1556        };
1557        assert!(
1558            !failures(std::sync::Arc::new(NeedsAuth), configured)
1559                .contains(&CODES.auth_not_configured.code.to_owned())
1560        );
1561    }
1562
1563    #[test]
1564    fn a_webhook_receiver_with_no_secret_is_reported() {
1565        // The same gap the auth rule had: the payments rule was correct
1566        // and only ever tested as a pure function, so deleting its call
1567        // left every test green.
1568        assert!(
1569            failures(std::sync::Arc::new(super::tests::TakesWebhooks), NONE_SET)
1570                .contains(&CODES.payments_webhook_secret_missing.code.to_owned()),
1571            "the payments rule is correct and nothing calls it"
1572        );
1573    }
1574
1575    #[test]
1576    fn the_same_receiver_with_a_secret_is_not() {
1577        let configured = Configured {
1578            stripe_webhook_secret: true,
1579            ..NONE_SET
1580        };
1581        assert!(
1582            !failures(std::sync::Arc::new(super::tests::TakesWebhooks), configured)
1583                .contains(&CODES.payments_webhook_secret_missing.code.to_owned())
1584        );
1585    }
1586
1587    #[test]
1588    fn a_module_needing_no_caller_is_never_asked() {
1589        assert!(
1590            !failures(std::sync::Arc::new(Nothing), NONE_SET)
1591                .contains(&CODES.auth_not_configured.code.to_owned())
1592        );
1593    }
1594
1595    #[test]
1596    fn a_two_verifier_module_names_the_route_each_webhook_rule_blocks() {
1597        // Issue #595: `billing` verifies `POST /webhooks/stripe` through
1598        // `Payments` and `POST /webhooks/revenuecat` with its own HMAC
1599        // secret, so each missing secret must name the mounted route it
1600        // blocks — `POST /v1/billing/webhooks/...`.
1601        use cratefield_core::MapConfig;
1602        let message = |pairs: &[(String, String)], code: &str| -> Option<String> {
1603            pairs
1604                .iter()
1605                .find(|(found, _)| found == code)
1606                .map(|(_, message)| message.clone())
1607        };
1608        let two = || std::sync::Arc::new(super::tests::TwoVerifiers);
1609        let stripe_set = Configured {
1610            stripe_webhook_secret: true,
1611            ..NONE_SET
1612        };
1613        let secret = MapConfig::from_pairs([("BILLING_REVENUECAT_WEBHOOK_SECRET", "whsec-dummy")]);
1614
1615        // Both secrets present: neither webhook rule fires.
1616        let both = messages(two(), stripe_set, &secret);
1617        assert!(message(&both, "payments-webhook-secret-missing").is_none());
1618        assert!(message(&both, "hmac-webhook-secret-missing").is_none());
1619
1620        // Stripe's secret missing: the payments rule names its route and
1621        // says nothing about the HMAC one.
1622        let no_stripe = messages(two(), NONE_SET, &secret);
1623        let payment = message(&no_stripe, "payments-webhook-secret-missing").expect("fires");
1624        assert!(
1625            payment.contains("POST /v1/billing/webhooks/stripe"),
1626            "{payment}"
1627        );
1628        assert!(
1629            !payment.contains("POST /v1/billing/webhooks/revenuecat"),
1630            "{payment}"
1631        );
1632
1633        // RevenueCat's secret missing: the HMAC rule names its route.
1634        let no_revenuecat = messages(two(), stripe_set, &MapConfig::default());
1635        let hmac = message(&no_revenuecat, "hmac-webhook-secret-missing").expect("fires");
1636        assert!(
1637            hmac.contains("POST /v1/billing/webhooks/revenuecat"),
1638            "{hmac}"
1639        );
1640        assert!(!hmac.contains("POST /v1/billing/webhooks/stripe"), "{hmac}");
1641    }
1642
1643    /// Runs the whole doctor — the path `fz doctor` actually takes — so a
1644    /// deleted call to the admin-token rule fails here instead of leaving
1645    /// the pure tests green. The module declares nothing, the migrations
1646    /// directory does not exist (`read_lock` treats that as an empty
1647    /// lock), and no sidecars are mounted: the report is empty unless the
1648    /// rule under test fires.
1649    fn run_report(configured: Configured) -> Vec<String> {
1650        let harness = harness_of(std::sync::Arc::new(Nothing));
1651        run_checks(
1652            &harness,
1653            std::path::Path::new("no-migrations-here"),
1654            None,
1655            None,
1656            Output::Json,
1657            configured,
1658        )
1659        .failures
1660        .iter()
1661        .map(|failure: &DoctorFailure| failure.code.code.to_owned())
1662        .collect()
1663    }
1664
1665    #[test]
1666    fn a_short_admin_token_reaches_the_report() {
1667        let configured = Configured {
1668            admin_token_bytes: Some(4),
1669            ..NONE_SET
1670        };
1671        assert_eq!(
1672            run_report(configured),
1673            vec![CODES.admin_token_too_short.code.to_owned()],
1674            "the rule is correct and nothing calls it"
1675        );
1676    }
1677
1678    #[test]
1679    fn an_absent_admin_token_leaves_the_report_clean() {
1680        assert!(
1681            run_report(NONE_SET).is_empty(),
1682            "{:?}",
1683            run_report(NONE_SET)
1684        );
1685    }
1686}