max / makenotwork
- Co-Authored-By
- Claude Opus 5 (1M context) <noreply@anthropic.com>
6 files changed,
+174 insertions,
-64 deletions
| @@ -53,6 +53,7 @@ | |||
| 53 | 53 | pub mod scheduler; | |
| 54 | 54 | pub mod security_signals; | |
| 55 | 55 | pub mod seed; | |
| 56 | + | pub mod site_docs; | |
| 56 | 57 | pub mod storage; | |
| 57 | 58 | pub mod synckit_auth; | |
| 58 | 59 | pub mod synckit_billing; |
| @@ -12,7 +12,6 @@ | |||
| 12 | 12 | use tower_sessions_sqlx_store::PostgresStore; | |
| 13 | 13 | use tracing_subscriber::{Layer, layer::SubscriberExt, util::SubscriberInitExt}; | |
| 14 | 14 | ||
| 15 | - | use docengine::{DocLoader, DocLoaderConfig}; | |
| 16 | 15 | use makenotwork::config::Config; | |
| 17 | 16 | use makenotwork::constants; | |
| 18 | 17 | use makenotwork::email::{EmailClient, EmailConfig}; | |
| @@ -20,7 +19,6 @@ | |||
| 20 | 19 | use makenotwork::scanning::ScanPipeline; | |
| 21 | 20 | use makenotwork::storage::S3Client; | |
| 22 | 21 | use makenotwork::{AppState, AppStorage, build_app}; | |
| 23 | - | use mnw_assumptions::Assumptions; | |
| 24 | 22 | use webauthn_rs::WebauthnBuilder; | |
| 25 | 23 | ||
| 26 | 24 | #[tokio::main] | |
| @@ -95,14 +93,14 @@ | |||
| 95 | 93 | // (pre-existing collisions shouldn't red the pipeline). Prints a stable | |
| 96 | 94 | // sentinel line, like MNW_CHECK_CONFIG. | |
| 97 | 95 | if std::env::var("MNW_CHECK_DOCS").is_ok() { | |
| 98 | - | let assumptions = match load_assumptions() { | |
| 96 | + | let assumptions = match makenotwork::site_docs::load_assumptions() { | |
| 99 | 97 | Ok(a) => a, | |
| 100 | 98 | Err(e) => { | |
| 101 | 99 | eprintln!("MNW_CHECK_DOCS: error: {e}"); | |
| 102 | 100 | std::process::exit(1); | |
| 103 | 101 | } | |
| 104 | 102 | }; | |
| 105 | - | let docs = build_doc_loader(assumptions); | |
| 103 | + | let docs = makenotwork::site_docs::build_doc_loader(assumptions); | |
| 106 | 104 | let broken = docs.broken_links(); | |
| 107 | 105 | for b in broken { | |
| 108 | 106 | eprintln!( | |
| @@ -386,12 +384,14 @@ | |||
| 386 | 384 | ||
| 387 | 385 | // Load business assumptions (single source of truth for figures in the docs). | |
| 388 | 386 | // Validation failure aborts startup, we don't want to serve stale numbers. | |
| 389 | - | let assumptions = load_assumptions().unwrap_or_else(|e| panic!("{e}")); | |
| 387 | + | let assumptions = makenotwork::site_docs::load_assumptions().unwrap_or_else(|e| panic!("{e}")); | |
| 390 | 388 | ||
| 391 | 389 | // Load documentation pages from disk. The same builder backs the DB-free | |
| 392 | 390 | // MNW_CHECK_DOCS integrity check above, so what boots and what is checked | |
| 393 | 391 | // are the same corpus under the same config. | |
| 394 | - | let docs = std::sync::Arc::new(build_doc_loader(assumptions.clone())); | |
| 392 | + | let docs = std::sync::Arc::new(makenotwork::site_docs::build_doc_loader( | |
| 393 | + | assumptions.clone(), | |
| 394 | + | )); | |
| 395 | 395 | ||
| 396 | 396 | // Initialize file scanning pipeline (optional). If scanning is configured, | |
| 397 | 397 | // assert at least one AV layer is actually live, otherwise the FailOpen | |
| @@ -499,8 +499,9 @@ | |||
| 499 | 499 | let tier_prices = makenotwork::tier_prices::TierPrices::from_assumptions(&assumptions); | |
| 500 | 500 | tier_prices.clone().install_global(); | |
| 501 | 501 | let runway_config = makenotwork::tier_prices::RunwayConfig::from_assumptions(&assumptions); | |
| 502 | - | let pricing_comparison = | |
| 503 | - | makenotwork::pricing_comparison::PricingComparison::load(assumptions_path()); | |
| 502 | + | let pricing_comparison = makenotwork::pricing_comparison::PricingComparison::load( | |
| 503 | + | makenotwork::site_docs::assumptions_path(), | |
| 504 | + | ); | |
| 504 | 505 | ||
| 505 | 506 | let state = AppState::build(makenotwork::AppStateParts { | |
| 506 | 507 | db, | |
| @@ -745,52 +746,6 @@ | |||
| 745 | 746 | ||
| 746 | 747 | /// Filesystem path to the business-assumptions TOML, `ASSUMPTIONS_PATH` or its | |
| 747 | 748 | /// default. Single source for the default so every reader agrees. | |
| 748 | - | fn assumptions_path() -> String { | |
| 749 | - | std::env::var("ASSUMPTIONS_PATH") | |
| 750 | - | .unwrap_or_else(|_| "docs/business/assumptions.toml".to_string()) | |
| 751 | - | } | |
| 752 | - | ||
| 753 | - | /// Load and validate business assumptions. `Err` carries a human-readable | |
| 754 | - | /// message; normal startup turns it into a panic, the check modes into a | |
| 755 | - | /// sentinel line. Shared so the two paths validate identically. | |
| 756 | - | fn load_assumptions() -> Result<std::sync::Arc<Assumptions>, String> { | |
| 757 | - | let assumptions_path = assumptions_path(); | |
| 758 | - | let a = Assumptions::load(&assumptions_path) | |
| 759 | - | .map_err(|e| format!("failed to load assumptions from {assumptions_path}: {e}"))?; | |
| 760 | - | a.validate() | |
| 761 | - | .map_err(|e| format!("assumptions validation failed:\n{e}"))?; | |
| 762 | - | tracing::info!(path = %assumptions_path, "assumptions loaded and validated"); | |
| 763 | - | Ok(std::sync::Arc::new(a)) | |
| 764 | - | } | |
| 765 | - | ||
| 766 | - | /// Build the documentation loader from disk with the production config. | |
| 767 | - | /// | |
| 768 | - | /// Shared by normal startup and the `MNW_CHECK_DOCS` integrity check so both | |
| 769 | - | /// see the exact same sections, link prefix, examples path, and assumption | |
| 770 | - | /// substitution, so the broken-link report can't drift from what is served. | |
| 771 | - | fn build_doc_loader(assumptions: std::sync::Arc<Assumptions>) -> DocLoader { | |
| 772 | - | let docs_path = std::env::var("DOCS_PATH").unwrap_or_else(|_| "site-docs/public".to_string()); | |
| 773 | - | DocLoader::load( | |
| 774 | - | std::path::Path::new(&docs_path), | |
| 775 | - | &DocLoaderConfig { | |
| 776 | - | sections: vec![ | |
| 777 | - | ("about".to_string(), "About".to_string()), | |
| 778 | - | ("guide".to_string(), "Guide".to_string()), | |
| 779 | - | ("developer".to_string(), "Developer".to_string()), | |
| 780 | - | ("legal".to_string(), "Legal".to_string()), | |
| 781 | - | ("support".to_string(), "Support".to_string()), | |
| 782 | - | ("tech".to_string(), "Tech".to_string()), | |
| 783 | - | ], | |
| 784 | - | link_prefix: "/docs".to_string(), | |
| 785 | - | unpublished_pattern: Some("unpublished/".to_string()), | |
| 786 | - | examples_path: Some(std::path::Path::new(&docs_path).join("../examples")), | |
| 787 | - | pre_process: Some(Box::new(move |md: &str| { | |
| 788 | - | assumptions.substitute(md).map_err(|e| e.to_string()) | |
| 789 | - | })), | |
| 790 | - | }, | |
| 791 | - | ) | |
| 792 | - | } | |
| 793 | - | ||
| 794 | 749 | /// Shutdown budget, reconciled so the background-pool drain can't be truncated | |
| 795 | 750 | /// by the hard-exit deadline (audit Run 23 F4). The hard-exit timer arms at | |
| 796 | 751 | /// signal receipt and must outlast the in-flight request drain PLUS the |
| @@ -23,6 +23,24 @@ | |||
| 23 | 23 | } | |
| 24 | 24 | ||
| 25 | 25 | use docengine::DocLoader; | |
| 26 | + | ||
| 27 | + | /// The real published docs, parsed once per test binary. | |
| 28 | + | /// | |
| 29 | + | /// The harness used to build a loader with `sections: vec![]` against `"."`, so | |
| 30 | + | /// under test the site had no doc pages at all and nothing could cover `/docs` | |
| 31 | + | /// or the docs half of the sitemap. Loading the real tree costs a parse, which | |
| 32 | + | /// is why it is shared rather than rebuilt per `TestHarness::new`, and it goes | |
| 33 | + | /// through `site_docs::build_doc_loader` so the config cannot drift from the | |
| 34 | + | /// one production uses. | |
| 35 | + | fn site_docs() -> Arc<DocLoader> { | |
| 36 | + | static DOCS: std::sync::OnceLock<Arc<DocLoader>> = std::sync::OnceLock::new(); | |
| 37 | + | DOCS.get_or_init(|| { | |
| 38 | + | let assumptions = makenotwork::site_docs::load_assumptions() | |
| 39 | + | .expect("tests run from the crate root, where the assumptions file is"); | |
| 40 | + | Arc::new(makenotwork::site_docs::build_doc_loader(assumptions)) | |
| 41 | + | }) | |
| 42 | + | .clone() | |
| 43 | + | } | |
| 26 | 44 | use makenotwork::config::{ | |
| 27 | 45 | BuildConfig, Config, CreatorTierPricing, EmailWebhookConfig, IntegrationsConfig, ScanConfig, | |
| 28 | 46 | StripeConfig, | |
| @@ -462,16 +480,7 @@ | |||
| 462 | 480 | }, | |
| 463 | 481 | stripe: opts.stripe_client, | |
| 464 | 482 | email, | |
| 465 | - | docs: Arc::new(DocLoader::load( | |
| 466 | - | std::path::Path::new("."), | |
| 467 | - | &docengine::DocLoaderConfig { | |
| 468 | - | sections: vec![], | |
| 469 | - | link_prefix: "/docs".to_string(), | |
| 470 | - | unpublished_pattern: None, | |
| 471 | - | examples_path: None, | |
| 472 | - | pre_process: None, | |
| 473 | - | }, | |
| 474 | - | )), | |
| 483 | + | docs: site_docs(), | |
| 475 | 484 | tier_prices: { | |
| 476 | 485 | // Install the process-global TierPrices so handler code paths | |
| 477 | 486 | // that call CreatorTier::{price_cents,max_file_bytes, |
| @@ -47,6 +47,7 @@ | |||
| 47 | 47 | mod db_users_layer; | |
| 48 | 48 | mod db_webhook_events; | |
| 49 | 49 | mod discover; | |
| 50 | + | mod docs_site; | |
| 50 | 51 | mod embeds; | |
| 51 | 52 | mod enum_drift; | |
| 52 | 53 | mod exports; |
| @@ -1,0 +1,70 @@ | |||
| 1 | + | //! Loading the published documentation tree (`site-docs/public`). | |
| 2 | + | //! | |
| 3 | + | //! Lives in the library rather than in `main.rs` so the test harness builds the | |
| 4 | + | //! loader the same way production does. It used to be a private binary | |
| 5 | + | //! function, and the harness could only construct a `DocLoader` with | |
| 6 | + | //! `sections: vec![]`, so under test the site had no doc pages at all: nothing | |
| 7 | + | //! could cover `/docs`, and the two integration tests that would have proven | |
| 8 | + | //! the docs half of the sitemap and the search index had to be dropped. | |
| 9 | + | //! | |
| 10 | + | //! One definition means the harness cannot drift from the server the way an | |
| 11 | + | //! imitation of this config would. | |
| 12 | + | ||
| 13 | + | use docengine::{DocLoader, DocLoaderConfig}; | |
| 14 | + | use mnw_assumptions::Assumptions; | |
| 15 | + | use std::sync::Arc; | |
| 16 | + | ||
| 17 | + | /// Where the published docs live. `DOCS_PATH` overrides for a deployment whose | |
| 18 | + | /// working directory is not the crate root. | |
| 19 | + | pub fn docs_path() -> String { | |
| 20 | + | std::env::var("DOCS_PATH").unwrap_or_else(|_| "site-docs/public".to_string()) | |
| 21 | + | } | |
| 22 | + | ||
| 23 | + | /// Where the business assumptions live, substituted into doc bodies. | |
| 24 | + | pub fn assumptions_path() -> String { | |
| 25 | + | std::env::var("ASSUMPTIONS_PATH") | |
| 26 | + | .unwrap_or_else(|_| "docs/business/assumptions.toml".to_string()) | |
| 27 | + | } | |
| 28 | + | ||
| 29 | + | /// Load and validate business assumptions. `Err` carries a human-readable | |
| 30 | + | /// message; normal startup turns it into a panic, the check modes into a | |
| 31 | + | /// sentinel line. Shared so the two paths validate identically. | |
| 32 | + | pub fn load_assumptions() -> Result<Arc<Assumptions>, String> { | |
| 33 | + | let path = assumptions_path(); | |
| 34 | + | let a = Assumptions::load(&path) | |
| 35 | + | .map_err(|e| format!("failed to load assumptions from {path}: {e}"))?; | |
| 36 | + | a.validate() | |
| 37 | + | .map_err(|e| format!("assumptions validation failed:\n{e}"))?; | |
| 38 | + | tracing::info!(path = %path, "assumptions loaded and validated"); | |
| 39 | + | Ok(Arc::new(a)) | |
| 40 | + | } | |
| 41 | + | ||
| 42 | + | /// Build the documentation loader from disk with the production config. | |
| 43 | + | /// | |
| 44 | + | /// Shared by normal startup, the `MNW_CHECK_DOCS` integrity check, and the test | |
| 45 | + | /// harness, so all three see the exact same sections, link prefix, examples | |
| 46 | + | /// path, and assumption substitution. The broken-link report cannot drift from | |
| 47 | + | /// what is served, and a test asserting over `/docs` is asserting over the real | |
| 48 | + | /// tree. | |
| 49 | + | pub fn build_doc_loader(assumptions: Arc<Assumptions>) -> DocLoader { | |
| 50 | + | let docs_path = docs_path(); | |
| 51 | + | DocLoader::load( | |
| 52 | + | std::path::Path::new(&docs_path), | |
| 53 | + | &DocLoaderConfig { | |
| 54 | + | sections: vec![ | |
| 55 | + | ("about".to_string(), "About".to_string()), | |
| 56 | + | ("guide".to_string(), "Guide".to_string()), | |
| 57 | + | ("developer".to_string(), "Developer".to_string()), | |
| 58 | + | ("legal".to_string(), "Legal".to_string()), | |
| 59 | + | ("support".to_string(), "Support".to_string()), | |
| 60 | + | ("tech".to_string(), "Tech".to_string()), | |
| 61 | + | ], | |
| 62 | + | link_prefix: "/docs".to_string(), | |
| 63 | + | unpublished_pattern: Some("unpublished/".to_string()), | |
| 64 | + | examples_path: Some(std::path::Path::new(&docs_path).join("../examples")), | |
| 65 | + | pre_process: Some(Box::new(move |md: &str| { | |
| 66 | + | assumptions.substitute(md).map_err(|e| e.to_string()) | |
| 67 | + | })), | |
| 68 | + | }, | |
| 69 | + | ) | |
| 70 | + | } |
| @@ -1,0 +1,74 @@ | |||
| 1 | + | //! The published documentation tree, end to end. | |
| 2 | + | //! | |
| 3 | + | //! These two assertions were written when the docs half of the sitemap and the | |
| 4 | + | //! search index were fixed, then dropped: the harness built its `DocLoader` | |
| 5 | + | //! with `sections: vec![]`, so under test the site had no doc pages and neither | |
| 6 | + | //! test could pass. The harness now loads the real tree through the same | |
| 7 | + | //! `site_docs::build_doc_loader` production uses, so they hold again. | |
| 8 | + | //! | |
| 9 | + | //! Both are corpus-wide rather than example-based on purpose. A test naming one | |
| 10 | + | //! slug pins that slug; these fail when any page stops being reachable, which is | |
| 11 | + | //! the failure that actually happens when a doc is added or a section renamed. | |
| 12 | + | ||
| 13 | + | use crate::harness::TestHarness; | |
| 14 | + | use serde_json::Value; | |
| 15 | + | ||
| 16 | + | /// Every page in the search index must be reachable. The index is what the | |
| 17 | + | /// client-side filter searches, so a slug present here but not served is a | |
| 18 | + | /// result that 404s when clicked. | |
| 19 | + | #[tokio::test] | |
| 20 | + | async fn every_indexed_doc_slug_is_served() { | |
| 21 | + | let mut h = TestHarness::new().await; | |
| 22 | + | ||
| 23 | + | let resp = h.client.get("/docs/search.json").await; | |
| 24 | + | assert_eq!(resp.status, 200, "search index should be served"); | |
| 25 | + | let index: Vec<Value> = resp.json(); | |
| 26 | + | assert!( | |
| 27 | + | !index.is_empty(), | |
| 28 | + | "the search index is empty, which means the harness loaded no docs and \ | |
| 29 | + | this test is proving nothing" | |
| 30 | + | ); | |
| 31 | + | ||
| 32 | + | for entry in &index { | |
| 33 | + | let slug = entry["slug"].as_str().expect("each entry carries a slug"); | |
| 34 | + | let page = h.client.get(&format!("/docs/{slug}")).await; | |
| 35 | + | assert_eq!( | |
| 36 | + | page.status, 200, | |
| 37 | + | "/docs/{slug} is in the search index but is not served" | |
| 38 | + | ); | |
| 39 | + | } | |
| 40 | + | } | |
| 41 | + | ||
| 42 | + | /// The sitemap is how a crawler finds the docs at all. It carries one URL per | |
| 43 | + | /// indexed page, and a doc missing from it is a doc that is published but | |
| 44 | + | /// undiscoverable. | |
| 45 | + | #[tokio::test] | |
| 46 | + | async fn the_sitemap_carries_every_doc_url() { | |
| 47 | + | let mut h = TestHarness::new().await; | |
| 48 | + | ||
| 49 | + | let resp = h.client.get("/docs/search.json").await; | |
| 50 | + | let index: Vec<Value> = resp.json(); | |
| 51 | + | let slugs: Vec<String> = index | |
| 52 | + | .iter() | |
| 53 | + | .map(|e| e["slug"].as_str().expect("slug").to_string()) | |
| 54 | + | .collect(); | |
| 55 | + | assert!( | |
| 56 | + | !slugs.is_empty(), | |
| 57 | + | "no docs loaded, nothing is being asserted" | |
| 58 | + | ); | |
| 59 | + | ||
| 60 | + | let resp = h.client.get("/sitemap.xml").await; | |
| 61 | + | assert_eq!(resp.status, 200, "sitemap should be served"); | |
| 62 | + | let xml = resp.text; | |
| 63 | + | ||
| 64 | + | assert!( | |
| 65 | + | xml.contains("/docs</loc>") || xml.contains("/docs<"), | |
| 66 | + | "the docs index itself belongs in the sitemap" | |
| 67 | + | ); | |
| 68 | + | for slug in &slugs { | |
| 69 | + | assert!( | |
| 70 | + | xml.contains(&format!("/docs/{slug}</loc>")), | |
| 71 | + | "/docs/{slug} is published but missing from the sitemap" | |
| 72 | + | ); | |
| 73 | + | } | |
| 74 | + | } |