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