Skip to main content

max / makenotwork

server: site access gate for testnot (fan+/creator only) Add an ACCESS_GATE=fan_plus_or_creator middleware that restricts the whole site to logged-in creators or active Fan+ members — used by the testnot.work staging mirror (a read-only daily prod restore) so it is reachable only by Fan+/creator accounts. Default-off (AccessGate::Open) so production is unaffected; it is a coarse pre-filter, per-route AuthUser still enforces real auth underneath. Allowlists login/auth/oauth/static/rustdoc/health/metrics/webhooks so the login flow and infra stay reachable; /join (signup) stays gated (no new accounts on the mirror). Blocked requests get a 303 to /login with a notice. 3 unit + 2 integration tests; zero warnings.
Co-Authored-By
Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-06-07 19:50 UTC
Signed with PGP, not checked
Commit: 9cd5e497cd2e6c277f2e2b2488460327bb1c32d2
Parent: 14fd620
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;