Skip to main content

max / makenotwork

server: add MNW_CHECK_DOCS docs-integrity check mode DB-free, socket-free mode that loads the doc corpus with the exact production config and exits nonzero on broken internal links, mirroring the MNW_CHECK_CONFIG sentinel pattern. Slug collisions are reported but do not fail it. Factor load_assumptions() and build_doc_loader() so the check and normal startup share one config builder, so the report can't drift from what is served. Normal boot stays warn-only — a rotted internal link must not take down the site; hard-fail lives in the pipeline. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-23 15:38 UTC
Commit: 971b05a7b345f1bf9c800e50e0bb790ff8bb6cad
Parent: 6d74cac
1 file changed, +106 insertions, -43 deletions
M server/src/main.rs +106 -43
@@ -85,6 +85,47 @@ async fn main() {
85 85 }
86 86 }
87 87
88 + // Docs integrity check mode. Loads the doc corpus with the exact production
89 + // config and reports broken internal links, then exits — no DB, no socket,
90 + // so it is cheap. Sando's `code_smoke` gate runs `MNW_CHECK_DOCS=1 <binary>`
91 + // as its first step, before the throwaway-DB boot, so a rotted internal link
92 + // fails the pipeline early rather than serving a live 404 in prod. Broken
93 + // links fail the check; slug collisions are reported but do not fail it
94 + // (pre-existing collisions shouldn't red the pipeline). Prints a stable
95 + // sentinel line, like MNW_CHECK_CONFIG.
96 + if std::env::var("MNW_CHECK_DOCS").is_ok() {
97 + let assumptions = match load_assumptions() {
98 + Ok(a) => a,
99 + Err(e) => {
100 + eprintln!("MNW_CHECK_DOCS: error: {e}");
101 + std::process::exit(1);
102 + }
103 + };
104 + let docs = build_doc_loader(assumptions);
105 + let broken = docs.broken_links();
106 + for b in broken {
107 + eprintln!(
108 + " broken link: {} -> {} (no page serves this slug)",
109 + b.source_slug, b.target_slug
110 + );
111 + }
112 + for c in docs.collisions() {
113 + eprintln!(
114 + " slug collision: {} ({} displaced by {})",
115 + c.slug, c.displaced_section, c.winning_section
116 + );
117 + }
118 + if broken.is_empty() {
119 + println!(
120 + "MNW_CHECK_DOCS: ok ({} collision(s) reported)",
121 + docs.collisions().len()
122 + );
123 + std::process::exit(0);
124 + }
125 + println!("MNW_CHECK_DOCS: {} broken link(s)", broken.len());
126 + std::process::exit(1);
127 + }
128 +
88 129 let config = Config::from_env().expect("Failed to load configuration");
89 130 tracing::info!("Configuration loaded");
90 131
@@ -142,7 +183,7 @@ async fn main() {
142 183 // Best-effort WAM alert (DB is up, WAM may be reachable)
143 184 if let Ok(wam_url) = std::env::var("WAM_URL") {
144 185 let body = format!("Migration error on startup:\n{e}");
145 - let _ = reqwest::Client::new()
186 + let mut req = reqwest::Client::new()
146 187 .post(format!("{wam_url}/tickets"))
147 188 .json(&serde_json::json!({
148 189 "title": "Migration failure — server not starting",
@@ -150,9 +191,13 @@ async fn main() {
150 191 "priority": "critical",
151 192 "source": "migration-failure",
152 193 }))
153 - .timeout(std::time::Duration::from_secs(5))
154 - .send()
155 - .await;
194 + .timeout(std::time::Duration::from_secs(5));
195 + if let Ok(token) = std::env::var("WAM_TOKEN") {
196 + if !token.trim().is_empty() {
197 + req = req.bearer_auth(token.trim());
198 + }
199 + }
200 + let _ = req.send().await;
156 201 }
157 202
158 203 // Exit code 2 — systemd configured not to restart on this code
@@ -340,43 +385,12 @@ async fn main() {
340 385
341 386 // Load business assumptions (single source of truth for figures in the docs).
342 387 // Validation failure aborts startup — we don't want to serve stale numbers.
343 - let assumptions_path = std::env::var("ASSUMPTIONS_PATH")
344 - .unwrap_or_else(|_| "docs/business/assumptions.toml".to_string());
345 - let assumptions = std::sync::Arc::new(match Assumptions::load(&assumptions_path) {
346 - Ok(a) => {
347 - if let Err(e) = a.validate() {
348 - panic!("assumptions validation failed:\n{e}");
349 - }
350 - tracing::info!(path = %assumptions_path, "assumptions loaded and validated");
351 - a
352 - }
353 - Err(e) => panic!("failed to load assumptions from {assumptions_path}: {e}"),
354 - });
388 + let assumptions = load_assumptions().unwrap_or_else(|e| panic!("{e}"));
355 389
356 - // Load documentation pages from disk
357 - let docs_path = std::env::var("DOCS_PATH").unwrap_or_else(|_| "site-docs/public".to_string());
358 - let docs = std::sync::Arc::new(DocLoader::load(
359 - std::path::Path::new(&docs_path),
360 - &DocLoaderConfig {
361 - sections: vec![
362 - ("about".to_string(), "About".to_string()),
363 - ("guide".to_string(), "Guide".to_string()),
364 - ("developer".to_string(), "Developer".to_string()),
365 - ("legal".to_string(), "Legal".to_string()),
366 - ("support".to_string(), "Support".to_string()),
367 - ("tech".to_string(), "Tech".to_string()),
368 - ],
369 - link_prefix: "/docs".to_string(),
370 - unpublished_pattern: Some("unpublished/".to_string()),
371 - examples_path: Some(std::path::Path::new(&docs_path).join("../examples")),
372 - pre_process: {
373 - let assumptions = assumptions.clone();
374 - Some(Box::new(move |md: &str| {
375 - assumptions.substitute(md).map_err(|e| e.to_string())
376 - }))
377 - },
378 - },
379 - ));
390 + // Load documentation pages from disk. The same builder backs the DB-free
391 + // MNW_CHECK_DOCS integrity check above, so what boots and what is checked
392 + // are the same corpus under the same config.
393 + let docs = std::sync::Arc::new(build_doc_loader(assumptions.clone()));
380 394
381 395 // Initialize file scanning pipeline (optional). If scanning is configured,
382 396 // assert at least one AV layer is actually live — otherwise the FailOpen
@@ -447,10 +461,11 @@ async fn main() {
447 461 }
448 462 };
449 463
450 - // WAM ticket manager client (tailnet-only, for operational alerts)
464 + // WAM ticket manager client (tailnet-only, for operational alerts). The
465 + // optional WAM_TOKEN is the shared secret WAM enforces when set.
451 466 let wam = config.integrations.wam_url.as_ref().map(|url| {
452 467 tracing::info!(url = %url, "WAM integration enabled");
453 - makenotwork::wam_client::WamClient::new(url.clone())
468 + makenotwork::wam_client::WamClient::new(url.clone(), std::env::var("WAM_TOKEN").ok())
454 469 });
455 470
456 471 // Warm custom domain cache
@@ -484,7 +499,7 @@ async fn main() {
484 499 tier_prices.clone().install_global();
485 500 let runway_config = makenotwork::tier_prices::RunwayConfig::from_assumptions(&assumptions);
486 501 let pricing_comparison =
487 - makenotwork::pricing_comparison::PricingComparison::load(&assumptions_path);
502 + makenotwork::pricing_comparison::PricingComparison::load(assumptions_path());
488 503
489 504 let state = AppState::build(makenotwork::AppStateParts {
490 505 db,
@@ -699,6 +714,54 @@ async fn main() {
699 714 tracing::info!("Server shut down gracefully");
700 715 }
701 716
717 + /// Filesystem path to the business-assumptions TOML, `ASSUMPTIONS_PATH` or its
718 + /// default. Single source for the default so every reader agrees.
719 + fn assumptions_path() -> String {
720 + std::env::var("ASSUMPTIONS_PATH")
721 + .unwrap_or_else(|_| "docs/business/assumptions.toml".to_string())
722 + }
723 +
724 + /// Load and validate business assumptions. `Err` carries a human-readable
725 + /// message; normal startup turns it into a panic, the check modes into a
726 + /// sentinel line. Shared so the two paths validate identically.
727 + fn load_assumptions() -> Result<std::sync::Arc<Assumptions>, String> {
728 + let assumptions_path = assumptions_path();
729 + let a = Assumptions::load(&assumptions_path)
730 + .map_err(|e| format!("failed to load assumptions from {assumptions_path}: {e}"))?;
731 + a.validate()
732 + .map_err(|e| format!("assumptions validation failed:\n{e}"))?;
733 + tracing::info!(path = %assumptions_path, "assumptions loaded and validated");
734 + Ok(std::sync::Arc::new(a))
735 + }
736 +
737 + /// Build the documentation loader from disk with the production config.
738 + ///
739 + /// Shared by normal startup and the `MNW_CHECK_DOCS` integrity check so both
740 + /// see the exact same sections, link prefix, examples path, and assumption
741 + /// substitution — the broken-link report can't drift from what is served.
742 + fn build_doc_loader(assumptions: std::sync::Arc<Assumptions>) -> DocLoader {
743 + let docs_path = std::env::var("DOCS_PATH").unwrap_or_else(|_| "site-docs/public".to_string());
744 + DocLoader::load(
745 + std::path::Path::new(&docs_path),
746 + &DocLoaderConfig {
747 + sections: vec![
748 + ("about".to_string(), "About".to_string()),
749 + ("guide".to_string(), "Guide".to_string()),
750 + ("developer".to_string(), "Developer".to_string()),
751 + ("legal".to_string(), "Legal".to_string()),
752 + ("support".to_string(), "Support".to_string()),
753 + ("tech".to_string(), "Tech".to_string()),
754 + ],
755 + link_prefix: "/docs".to_string(),
756 + unpublished_pattern: Some("unpublished/".to_string()),
757 + examples_path: Some(std::path::Path::new(&docs_path).join("../examples")),
758 + pre_process: Some(Box::new(move |md: &str| {
759 + assumptions.substitute(md).map_err(|e| e.to_string())
760 + })),
761 + },
762 + )
763 + }
764 +
702 765 /// Shutdown budget, reconciled so the background-pool drain can't be truncated
703 766 /// by the hard-exit deadline (audit Run 23 F4). The hard-exit timer arms at
704 767 /// signal receipt and must outlast the in-flight request drain PLUS the