max / makenotwork
- Co-Authored-By
- Claude Opus 4.8 (1M context) <noreply@anthropic.com>
12 files changed,
+315 insertions,
-3 deletions
| @@ -121,6 +121,16 @@ | |||
| 121 | 121 | } | |
| 122 | 122 | } | |
| 123 | 123 | ||
| 124 | + | /// Read the cached `SessionUser` from a session, if one is logged in. | |
| 125 | + | /// | |
| 126 | + | /// Coarse read: it does NOT revalidate the session-tracking row (the way the | |
| 127 | + | /// `AuthUser` extractor does). Intended for middleware-level pre-filters like | |
| 128 | + | /// the site access gate, where the per-route `AuthUser` extractor still | |
| 129 | + | /// enforces full validation downstream. Returns `None` for anonymous sessions. | |
| 130 | + | pub async fn session_user(session: &Session) -> Option<SessionUser> { | |
| 131 | + | session.get::<SessionUser>(USER_SESSION_KEY).await.ok().flatten() | |
| 132 | + | } | |
| 133 | + | ||
| 124 | 134 | /// Extractor for authenticated users - returns error if not logged in. | |
| 125 | 135 | /// | |
| 126 | 136 | /// Specialized to `AppState` (not generic `S`) to access the DB pool for | |
| @@ -666,6 +676,7 @@ | |||
| 666 | 676 | internal_shared_secret: None, | |
| 667 | 677 | cli_service_token: None, | |
| 668 | 678 | wam_url: None, | |
| 679 | + | access_gate: crate::config::AccessGate::Open, | |
| 669 | 680 | }; | |
| 670 | 681 | assert!(require_admin(&user, &config).is_ok()); | |
| 671 | 682 | } | |
| @@ -733,6 +744,7 @@ | |||
| 733 | 744 | internal_shared_secret: None, | |
| 734 | 745 | cli_service_token: None, | |
| 735 | 746 | wam_url: None, | |
| 747 | + | access_gate: crate::config::AccessGate::Open, | |
| 736 | 748 | }; | |
| 737 | 749 | assert!(require_admin(&user, &config).is_err()); | |
| 738 | 750 | } |
| @@ -89,6 +89,24 @@ | |||
| 89 | 89 | /// Base URL of the WAM ticket manager (e.g., "http://100.x.x.x:7890"). | |
| 90 | 90 | /// When set, operational events create WAM tickets for human triage. | |
| 91 | 91 | pub wam_url: Option<String>, | |
| 92 | + | /// Site-wide access gate. `Open` (default) serves the public site as | |
| 93 | + | /// normal. `FanPlusOrCreator` restricts the whole site to logged-in users | |
| 94 | + | /// with a creator account or an active Fan+ subscription — used on the | |
| 95 | + | /// testnot.work staging mirror so it's reachable only by Fan+/creator | |
| 96 | + | /// accounts. Off in production. | |
| 97 | + | pub access_gate: AccessGate, | |
| 98 | + | } | |
| 99 | + | ||
| 100 | + | /// Site-wide access-gate mode (`ACCESS_GATE`). | |
| 101 | + | #[derive(Clone, Copy, Debug, PartialEq, Eq, Default)] | |
| 102 | + | pub enum AccessGate { | |
| 103 | + | /// No gate — the public site is served to everyone (production default). | |
| 104 | + | #[default] | |
| 105 | + | Open, | |
| 106 | + | /// Only logged-in creators or active Fan+ members may reach the site; | |
| 107 | + | /// everyone else is bounced to login. A coarse pre-filter — per-route auth | |
| 108 | + | /// still applies underneath. | |
| 109 | + | FanPlusOrCreator, | |
| 92 | 110 | } | |
| 93 | 111 | ||
| 94 | 112 | /// S3-compatible storage configuration (Hetzner Object Storage) | |
| @@ -276,6 +294,13 @@ | |||
| 276 | 294 | // WAM ticket manager URL (tailnet, e.g. "http://100.x.x.x:7890") | |
| 277 | 295 | let wam_url = std::env::var("WAM_URL").ok(); | |
| 278 | 296 | ||
| 297 | + | // Site-wide access gate. Only "fan_plus_or_creator" enables it; any | |
| 298 | + | // other value (or unset) leaves the site open. Staging-only knob. | |
| 299 | + | let access_gate = match std::env::var("ACCESS_GATE").as_deref() { | |
| 300 | + | Ok("fan_plus_or_creator") => AccessGate::FanPlusOrCreator, | |
| 301 | + | _ => AccessGate::Open, | |
| 302 | + | }; | |
| 303 | + | ||
| 279 | 304 | Ok(Config { | |
| 280 | 305 | host, | |
| 281 | 306 | port, | |
| @@ -307,6 +332,7 @@ | |||
| 307 | 332 | internal_shared_secret, | |
| 308 | 333 | cli_service_token, | |
| 309 | 334 | wam_url, | |
| 335 | + | access_gate, | |
| 310 | 336 | }) | |
| 311 | 337 | } | |
| 312 | 338 | ||
| @@ -477,6 +503,7 @@ | |||
| 477 | 503 | .field("internal_shared_secret", &self.internal_shared_secret.as_ref().map(|_| "[REDACTED]")) | |
| 478 | 504 | .field("cli_service_token", &self.cli_service_token.as_ref().map(|_| "[REDACTED]")) | |
| 479 | 505 | .field("wam_url", &self.wam_url) | |
| 506 | + | .field("access_gate", &self.access_gate) | |
| 480 | 507 | .finish() | |
| 481 | 508 | } | |
| 482 | 509 | } | |
| @@ -550,7 +577,7 @@ | |||
| 550 | 577 | "CREATOR_FOUNDER_WINDOW_OPEN", | |
| 551 | 578 | "BUILD_TRIGGER_TOKEN", "BUILD_HOST_LINUX", "BUILD_HOST_DARWIN", | |
| 552 | 579 | "CDN_BASE_URL", "POSTMARK_INBOUND_WEBHOOK_TOKEN", | |
| 553 | - | "INTERNAL_SHARED_SECRET", "CLI_SERVICE_TOKEN", | |
| 580 | + | "INTERNAL_SHARED_SECRET", "CLI_SERVICE_TOKEN", "WAM_URL", "ACCESS_GATE", | |
| 554 | 581 | ]; | |
| 555 | 582 | ||
| 556 | 583 | /// RAII guard that snapshots config-related env vars on creation and restores | |
| @@ -626,6 +653,7 @@ | |||
| 626 | 653 | internal_shared_secret: None, | |
| 627 | 654 | cli_service_token: None, | |
| 628 | 655 | wam_url: None, | |
| 656 | + | access_gate: AccessGate::Open, | |
| 629 | 657 | }; | |
| 630 | 658 | let addr = config.socket_addr(); | |
| 631 | 659 | assert_eq!(addr.port(), 8080); |
| @@ -1,5 +1,6 @@ | |||
| 1 | 1 | //! MakeNotWork library — shared between the binary and integration tests. | |
| 2 | 2 | ||
| 3 | + | pub mod access_gate; | |
| 3 | 4 | pub mod auth; | |
| 4 | 5 | pub mod background; | |
| 5 | 6 | pub mod build_runner; | |
| @@ -222,7 +223,8 @@ | |||
| 222 | 223 | ); | |
| 223 | 224 | } | |
| 224 | 225 | ||
| 225 | - | app.layer(middleware::from_fn_with_state(state.clone(), security_headers_middleware)) | |
| 226 | + | app.layer(middleware::from_fn_with_state(state.clone(), access_gate::access_gate_middleware)) | |
| 227 | + | .layer(middleware::from_fn_with_state(state.clone(), security_headers_middleware)) | |
| 226 | 228 | .layer(middleware::from_fn(metrics::cache_control_middleware)) | |
| 227 | 229 | .layer(middleware::from_fn(metrics::metrics_middleware)) | |
| 228 | 230 | .layer(middleware::from_fn_with_state(state.clone(), metrics::idempotency_middleware)) |
| @@ -118,6 +118,7 @@ | |||
| 118 | 118 | csrf_token: recall_csrf_token.clone(), | |
| 119 | 119 | prefill_login: submitted_login.clone(), | |
| 120 | 120 | error: Some(msg.to_string()), | |
| 121 | + | notice: None, | |
| 121 | 122 | }.into_response()) | |
| 122 | 123 | } | |
| 123 | 124 | }; |
| @@ -7,6 +7,7 @@ | |||
| 7 | 7 | {% block content %} | |
| 8 | 8 | <h1 class="brand-h1">Makenot<span class="dot">.</span>work</h1> | |
| 9 | 9 | <div class="login-container"> | |
| 10 | + | {% if let Some(note) = notice %}<div class="alert alert-note">{{ note }}</div>{% endif %} | |
| 10 | 11 | <div id="login-errors"> | |
| 11 | 12 | {% if let Some(msg) = error %}<div class="alert alert-error">{{ msg }}</div>{% endif %} | |
| 12 | 13 | </div> |
| @@ -76,6 +76,9 @@ | |||
| 76 | 76 | pub cli_service_token: Option<String>, | |
| 77 | 77 | pub mock_email: Option<Arc<email::MockEmailTransport>>, | |
| 78 | 78 | pub cdn_base_url: Option<String>, | |
| 79 | + | /// Site access gate. Defaults to `Open`; set to `FanPlusOrCreator` to test | |
| 80 | + | /// the testnot-style gate. | |
| 81 | + | pub access_gate: makenotwork::config::AccessGate, | |
| 79 | 82 | } | |
| 80 | 83 | ||
| 81 | 84 | /// Full test harness: isolated database, in-process app, cookie-aware client. | |
| @@ -301,6 +304,7 @@ | |||
| 301 | 304 | internal_shared_secret: opts.internal_shared_secret.clone(), | |
| 302 | 305 | cli_service_token: opts.cli_service_token.clone(), | |
| 303 | 306 | wam_url: None, | |
| 307 | + | access_gate: opts.access_gate, | |
| 304 | 308 | }; | |
| 305 | 309 | ||
| 306 | 310 | let mock_email_ref = opts.mock_email.clone(); |
| @@ -80,6 +80,7 @@ | |||
| 80 | 80 | internal_shared_secret: None, | |
| 81 | 81 | cli_service_token: None, | |
| 82 | 82 | wam_url: None, | |
| 83 | + | access_gate: makenotwork::config::AccessGate::Open, | |
| 83 | 84 | }; | |
| 84 | 85 | ||
| 85 | 86 | let email = EmailClient::new(EmailConfig { |
| @@ -1,3 +1,4 @@ | |||
| 1 | + | mod access_gate; | |
| 1 | 2 | mod auth; | |
| 2 | 3 | mod discover; | |
| 3 | 4 | mod embeds; |