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
Commit: 9cd5e497cd2e6c277f2e2b2488460327bb1c32d2
Parent: 14fd620
12 files changed, +315 insertions, -3 deletions
@@ -0,0 +1,166 @@
1 + //! Site-wide access gate (the `ACCESS_GATE=fan_plus_or_creator` mode).
2 + //!
3 + //! When enabled, the entire site is reachable only by logged-in users who hold
4 + //! a creator account or an active Fan+ subscription; everyone else is bounced
5 + //! to `/login` with a notice. This backs the testnot.work staging mirror, whose
6 + //! data is a daily restore of production — gating it to Fan+/creator accounts
7 + //! keeps that mirror off the open internet (the "available to anyone with a
8 + //! Fan+ or creator account" rule), matching the testnot Fan+ perk.
9 + //!
10 + //! It is a COARSE pre-filter: it reads the cached session flags only (no DB
11 + //! query, no session-tracking revalidation). The per-route `AuthUser` extractor
12 + //! still enforces full auth underneath, so the gate never relaxes real
13 + //! authorization — it only narrows who reaches the routes at all. Default-off,
14 + //! so production (`AccessGate::Open`) is completely unaffected.
15 +
16 + use axum::{
17 + body::Body,
18 + extract::State,
19 + http::Request,
20 + middleware::Next,
21 + response::{IntoResponse, Redirect, Response},
22 + };
23 + use tower_sessions::Session;
24 +
25 + use crate::config::AccessGate;
26 + use crate::AppState;
27 +
28 + /// Paths that must stay reachable even when the gate is on, so an
29 + /// un-authenticated visitor can still log in and so infrastructure keeps
30 + /// working. Everything else requires a creator/Fan+ session.
31 + ///
32 + /// Matched by exact path or `<prefix>/` so `/login` and `/login/...` both pass
33 + /// but a hypothetical `/loginsomething` does not.
34 + fn path_is_exempt(path: &str) -> bool {
35 + /// Reachable as exactly `p` or as `p` followed by `/`.
36 + fn hit(path: &str, p: &str) -> bool {
37 + path == p || path.strip_prefix(p).is_some_and(|rest| rest.starts_with('/'))
38 + }
39 +
40 + // Authentication surface — without these the gate would lock out its own
41 + // login page and the assets/endpoints the login flow needs.
42 + hit(path, "/login")
43 + || hit(path, "/logout")
44 + || hit(path, "/auth") // /auth/me, /auth/2fa, /auth/passkey/*
45 + || hit(path, "/oauth") // MNW-as-OAuth-provider authorize/token/userinfo
46 + // Static assets + browser chrome (the login page pulls CSS/JS/images).
47 + || hit(path, "/static")
48 + || hit(path, "/rustdoc")
49 + || path == "/favicon.ico"
50 + || path == "/robots.txt"
51 + // Operational endpoints: the deploy smoke check and machine callers.
52 + || path == "/health"
53 + || path == "/metrics"
54 + || hit(path, "/stripe/webhook") // inert on testnot (Stripe stubbed) but never gate a webhook
55 + || hit(path, "/postmark") // inbound/webhook callbacks
56 + }
57 +
58 + /// Whether the gate permits this session through. Creators (`can_create_projects`)
59 + /// and active Fan+ members pass; anonymous and plain-fan sessions do not.
60 + fn session_is_allowed(user: Option<&crate::auth::SessionUser>) -> bool {
61 + user.is_some_and(|u| u.can_create_projects || u.is_fan_plus)
62 + }
63 +
64 + /// Axum middleware enforcing the site access gate. No-op when the gate is
65 + /// `Open` (production), so it adds a single enum comparison per request there.
66 + pub async fn access_gate_middleware(
67 + State(state): State<AppState>,
68 + request: Request<Body>,
69 + next: Next,
70 + ) -> Response {
71 + if state.config.access_gate == AccessGate::Open {
72 + return next.run(request).await;
73 + }
74 +
75 + let path = request.uri().path();
76 + if path_is_exempt(path) {
77 + return next.run(request).await;
78 + }
79 +
80 + // Session is installed by the session layer (outer to this middleware).
81 + let user = match request.extensions().get::<Session>() {
82 + Some(session) => crate::auth::session_user(session).await,
83 + None => None,
84 + };
85 +
86 + if session_is_allowed(user.as_ref()) {
87 + next.run(request).await
88 + } else {
89 + // Coarse redirect to login with a notice flag; the per-route auth still
90 + // governs anything the user reaches after authenticating.
91 + Redirect::to("/login?gate=fan_plus_or_creator").into_response()
92 + }
93 + }
94 +
95 + #[cfg(test)]
96 + mod tests {
97 + use super::*;
98 +
99 + #[test]
100 + fn exempts_auth_and_asset_paths() {
101 + for p in [
102 + "/login",
103 + "/login?gate=fan_plus_or_creator",
104 + "/logout",
105 + "/auth/me",
106 + "/auth/passkey/start",
107 + "/oauth/authorize",
108 + "/static/style.css",
109 + "/static/images/favicon.ico",
110 + "/rustdoc/index.html",
111 + "/favicon.ico",
112 + "/robots.txt",
113 + "/health",
114 + "/metrics",
115 + "/stripe/webhook",
116 + "/postmark/inbound",
117 + ] {
118 + // path_is_exempt sees the path only (no query), mirroring uri().path().
119 + let path = p.split('?').next().unwrap();
120 + assert!(path_is_exempt(path), "expected exempt: {p}");
121 + }
122 + }
123 +
124 + #[test]
125 + fn gates_content_paths() {
126 + for p in [
127 + "/",
128 + "/discover",
129 + "/u/someone",
130 + "/p/some-project",
131 + "/changelog",
132 + "/library",
133 + "/loginsomething", // prefix must not over-match
134 + "/authority", // ditto
135 + ] {
136 + assert!(!path_is_exempt(p), "expected gated: {p}");
137 + }
138 + }
139 +
140 + #[test]
141 + fn only_creator_or_fan_plus_passes() {
142 + use crate::auth::SessionUser;
143 + use crate::db::{UserId, Username};
144 +
145 + fn user(can_create_projects: bool, is_fan_plus: bool) -> SessionUser {
146 + SessionUser {
147 + id: UserId::default(),
148 + username: Username::from_trusted("t".into()),
149 + email: "t@example.com".into(),
150 + display_name: None,
151 + can_create_projects,
152 + suspended: false,
153 + is_admin: false,
154 + is_fan_plus,
155 + creator_tier: None,
156 + deactivated: false,
157 + is_sandbox: false,
158 + }
159 + }
160 +
161 + assert!(!session_is_allowed(None), "anonymous blocked");
162 + assert!(!session_is_allowed(Some(&user(false, false))), "plain fan blocked");
163 + assert!(session_is_allowed(Some(&user(true, false))), "creator allowed");
164 + assert!(session_is_allowed(Some(&user(false, true))), "fan+ allowed");
165 + }
166 + }
@@ -121,6 +121,16 @@ impl SessionUser {
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 @@ mod tests {
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 @@ mod tests {
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 @@ pub struct Config {
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 @@ impl Config {
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 @@ impl Config {
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 @@ impl std::fmt::Debug for Config {
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 @@ mod tests {
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 @@ mod tests {
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 @@ pub fn build_app(
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 @@ async fn login_handler(
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 };
@@ -370,13 +370,32 @@ pub(super) async fn library_tab_communities(
370 370 .into_response())
371 371 }
372 372
373 + /// Query params for the login page.
374 + #[derive(Deserialize)]
375 + pub(crate) struct LoginQuery {
376 + /// Set by the site access gate (`?gate=fan_plus_or_creator`) to explain why
377 + /// the visitor landed on login instead of the page they requested.
378 + pub gate: Option<String>,
379 + }
380 +
373 381 /// Render the login page.
374 382 #[tracing::instrument(skip_all, name = "landing::login_page")]
375 - pub(crate) async fn login_page(session: Session) -> impl IntoResponse {
383 + pub(crate) async fn login_page(
384 + session: Session,
385 + Query(query): Query<LoginQuery>,
386 + ) -> impl IntoResponse {
387 + let notice = match query.gate.as_deref() {
388 + Some("fan_plus_or_creator") => Some(
389 + "This is the testnot.work preview, open to creators and Fan+ members. Log in to continue."
390 + .to_string(),
391 + ),
392 + _ => None,
393 + };
376 394 LoginTemplate {
377 395 csrf_token: get_csrf_token(&session).await,
378 396 prefill_login: String::new(),
379 397 error: None,
398 + notice,
380 399 }
381 400 }
382 401
@@ -117,6 +117,10 @@ pub struct LoginTemplate {
117 117 pub prefill_login: String,
118 118 /// Shown inline above the form on a failed POST. None hides the banner.
119 119 pub error: Option<String>,
120 + /// Neutral informational notice above the form (e.g. the access-gate prompt
121 + /// on the testnot staging mirror). None hides it. Separate from `error` so
122 + /// it doesn't render as a failure.
123 + pub notice: Option<String>,
120 124 }
121 125
122 126 // ============================================================================
@@ -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 @@ pub struct BuildOptions {
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 @@ impl TestHarness {
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 @@ pub async fn run(config: LoadConfig) {
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 {
@@ -0,0 +1,73 @@
1 + //! Site access gate (`ACCESS_GATE=fan_plus_or_creator`): the testnot-style
2 + //! gate that restricts the whole site to creators and Fan+ members.
3 +
4 + use crate::harness::{BuildOptions, TestHarness};
5 + use makenotwork::config::AccessGate;
6 + use sqlx::PgPool;
7 +
8 + fn location(resp: &crate::harness::client::TestResponse) -> String {
9 + resp.headers
10 + .get("location")
11 + .and_then(|v| v.to_str().ok())
12 + .unwrap_or("")
13 + .to_string()
14 + }
15 +
16 + /// Insert a verified user directly (the HTTP signup flow is itself gated on
17 + /// testnot, so the test seeds the DB and authenticates via the exempt /login).
18 + async fn seed_user(pool: &PgPool, username: &str, can_create_projects: bool) {
19 + let hash = makenotwork::auth::hash_password("password123").expect("hash");
20 + sqlx::query(
21 + "INSERT INTO users (username, email, password_hash, email_verified, can_create_projects)
22 + VALUES ($1, $2, $3, true, $4)",
23 + )
24 + .bind(username)
25 + .bind(format!("{username}@example.com"))
26 + .bind(&hash)
27 + .bind(can_create_projects)
28 + .execute(pool)
29 + .await
30 + .expect("seed user");
31 + }
32 +
33 + #[tokio::test]
34 + async fn access_gate_restricts_to_fan_plus_or_creator() {
35 + let mut h = TestHarness::build(BuildOptions {
36 + access_gate: AccessGate::FanPlusOrCreator,
37 + ..Default::default()
38 + })
39 + .await;
40 + seed_user(&h.db, "gatecreator", true).await;
41 + seed_user(&h.db, "plainfan", false).await;
42 +
43 + // Anonymous visitor → bounced to login with the gate notice.
44 + let r = h.client.get("/").await;
45 + assert!(r.status.is_redirection(), "anon should be redirected, got {}", r.status);
46 + assert!(location(&r).starts_with("/login"), "anon should land on login, got {}", location(&r));
47 +
48 + // Exempt paths stay reachable while the gate is on, or login is impossible.
49 + assert_eq!(h.client.get("/login").await.status.as_u16(), 200, "login page must be reachable");
50 + assert_eq!(h.client.get("/health").await.status.as_u16(), 200, "health must be reachable");
51 +
52 + // A logged-in plain fan (no creator, no Fan+) is still blocked — the gate
53 + // is stricter than ordinary auth.
54 + h.login("plainfan", "password123").await;
55 + let r = h.client.get("/library").await;
56 + assert!(r.status.is_redirection(), "plain fan should be gated, got {}", r.status);
57 + assert!(location(&r).starts_with("/login"), "plain fan should land on login");
58 +
59 + // A creator passes the gate (library renders 200 for the logged-in owner).
60 + h.client.post_form("/logout", "").await;
61 + h.login("gatecreator", "password123").await;
62 + let r = h.client.get("/library").await;
63 + assert_eq!(r.status.as_u16(), 200, "creator should pass the gate: {} {}", r.status, r.text);
64 + }
65 +
66 + #[tokio::test]
67 + async fn access_gate_open_serves_public_site() {
68 + // Default (Open) — the public landing renders for an anonymous visitor,
69 + // proving the gate is off unless explicitly enabled.
70 + let mut h = TestHarness::new().await;
71 + let r = h.client.get("/").await;
72 + assert_eq!(r.status.as_u16(), 200, "open site should serve landing to anon: {}", r.status);
73 + }
@@ -1,3 +1,4 @@
1 + mod access_gate;
1 2 mod auth;
2 3 mod discover;
3 4 mod embeds;