Skip to main content

max / makenotwork

Multithreaded v0.3.5 + migration 026: Fan+ perks, signatures, refresh Denormalises Fan+ status onto MT's local users row so the forum can gate features without round-tripping to MNW on every render. Refreshed at OAuth callback, on POST /auth/refresh, and surfaced via OAuth userinfo's perks object. Migration 026: - users.is_fan_plus BOOLEAN - users.signature_html TEXT (NULL = no signature) User-visible Fan+ perks on MT: - Profile signatures rendered under each post (only for current Fan+ subscribers — sig disappears the period it lapses) - Markdown-Plus subset: image embeds in posts and replies, gated by is_fan_plus at render time. Non-subscribers see the markdown source unchanged so the post round-trips on cancel/resume. - "Fan+" badge next to the username on posts. New routes: - GET /account: signature editor + Fan+ status - POST /account/signature: update_signature_handler (with HTML render and storage) - POST /auth/refresh: pull fresh perks from MNW userinfo without a full re-OAuth. The "Issues" and "Patches" categories are now seeded as defaults when a project's community is auto-created via /api/community (matches the issue→MT bridge from MNW migration 115). Cargo: bump workspace to 0.3.5; add wiremock dev-dep for the refresh flow tests (170 lines added to workflows/auth.rs, 364-line fan_plus_perks workflow). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Author: Max J. <87768334+MaxJMath@users.noreply.github.com> · 2026-05-15 17:22 UTC
Commit: d35ab6a4b9b6ec92c1f72b45e26c079bb6f71d80
Parent: edfec9c
23 files changed, +1335 insertions, -162 deletions
@@ -88,6 +88,16 @@ dependencies = [
88 88 ]
89 89
90 90 [[package]]
91 + name = "assert-json-diff"
92 + version = "2.0.2"
93 + source = "registry+https://github.com/rust-lang/crates.io-index"
94 + checksum = "47e4f2b81832e72834d7518d8487a0396a28cc408186a2e8854c0f98011faf12"
95 + dependencies = [
96 + "serde",
97 + "serde_json",
98 + ]
99 +
100 + [[package]]
91 101 name = "async-trait"
92 102 version = "0.1.89"
93 103 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -985,6 +995,24 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
985 995 checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea"
986 996
987 997 [[package]]
998 + name = "deadpool"
999 + version = "0.12.3"
1000 + source = "registry+https://github.com/rust-lang/crates.io-index"
1001 + checksum = "0be2b1d1d6ec8d846f05e137292d0b89133caf95ef33695424c09568bdd39b1b"
1002 + dependencies = [
1003 + "deadpool-runtime",
1004 + "lazy_static",
1005 + "num_cpus",
1006 + "tokio",
1007 + ]
1008 +
1009 + [[package]]
1010 + name = "deadpool-runtime"
1011 + version = "0.1.4"
1012 + source = "registry+https://github.com/rust-lang/crates.io-index"
1013 + checksum = "092966b41edc516079bdf31ec78a2e0588d1d0c08f78b91d8307215928642b2b"
1014 +
1015 + [[package]]
988 1016 name = "der"
989 1017 version = "0.6.1"
990 1018 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1270,6 +1298,7 @@ checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d"
1270 1298 dependencies = [
1271 1299 "futures-channel",
1272 1300 "futures-core",
1301 + "futures-executor",
1273 1302 "futures-io",
1274 1303 "futures-sink",
1275 1304 "futures-task",
@@ -1355,6 +1384,7 @@ version = "0.3.32"
1355 1384 source = "registry+https://github.com/rust-lang/crates.io-index"
1356 1385 checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6"
1357 1386 dependencies = [
1387 + "futures-channel",
1358 1388 "futures-core",
1359 1389 "futures-io",
1360 1390 "futures-macro",
@@ -1540,6 +1570,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
1540 1570 checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
1541 1571
1542 1572 [[package]]
1573 + name = "hermit-abi"
1574 + version = "0.5.2"
1575 + source = "registry+https://github.com/rust-lang/crates.io-index"
1576 + checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c"
1577 +
1578 + [[package]]
1543 1579 name = "hex"
1544 1580 version = "0.4.3"
1545 1581 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2153,7 +2189,7 @@ dependencies = [
2153 2189
2154 2190 [[package]]
2155 2191 name = "mt-core"
2156 - version = "0.3.4"
2192 + version = "0.3.5"
2157 2193 dependencies = [
2158 2194 "chrono",
2159 2195 "serde",
@@ -2162,7 +2198,7 @@ dependencies = [
2162 2198
2163 2199 [[package]]
2164 2200 name = "mt-db"
2165 - version = "0.3.4"
2201 + version = "0.3.5"
2166 2202 dependencies = [
2167 2203 "chrono",
2168 2204 "mt-core",
@@ -2191,7 +2227,7 @@ dependencies = [
2191 2227
2192 2228 [[package]]
2193 2229 name = "multithreaded"
2194 - version = "0.3.4"
2230 + version = "0.3.5"
2195 2231 dependencies = [
2196 2232 "askama",
2197 2233 "axum",
@@ -2226,6 +2262,7 @@ dependencies = [
2226 2262 "tracing-subscriber",
2227 2263 "urlencoding",
2228 2264 "uuid",
2265 + "wiremock",
2229 2266 ]
2230 2267
2231 2268 [[package]]
@@ -2314,6 +2351,16 @@ dependencies = [
2314 2351 ]
2315 2352
2316 2353 [[package]]
2354 + name = "num_cpus"
2355 + version = "1.17.0"
2356 + source = "registry+https://github.com/rust-lang/crates.io-index"
2357 + checksum = "91df4bbde75afed763b708b7eee1e8e7651e02d97f6d5dd763e89367e957b23b"
2358 + dependencies = [
2359 + "hermit-abi",
2360 + "libc",
2361 + ]
2362 +
2363 + [[package]]
2317 2364 name = "once_cell"
2318 2365 version = "1.21.4"
2319 2366 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2765,6 +2812,18 @@ dependencies = [
2765 2812 ]
2766 2813
2767 2814 [[package]]
2815 + name = "regex"
2816 + version = "1.12.3"
2817 + source = "registry+https://github.com/rust-lang/crates.io-index"
2818 + checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276"
2819 + dependencies = [
2820 + "aho-corasick",
2821 + "memchr",
2822 + "regex-automata",
2823 + "regex-syntax",
2824 + ]
2825 +
2826 + [[package]]
2768 2827 name = "regex-automata"
2769 2828 version = "0.4.14"
2770 2829 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -4570,6 +4629,29 @@ dependencies = [
4570 4629 ]
4571 4630
4572 4631 [[package]]
4632 + name = "wiremock"
4633 + version = "0.6.5"
4634 + source = "registry+https://github.com/rust-lang/crates.io-index"
4635 + checksum = "08db1edfb05d9b3c1542e521aea074442088292f00b5f28e435c714a98f85031"
4636 + dependencies = [
4637 + "assert-json-diff",
4638 + "base64",
4639 + "deadpool",
4640 + "futures",
4641 + "http 1.4.0",
4642 + "http-body-util",
4643 + "hyper 1.8.1",
4644 + "hyper-util",
4645 + "log",
4646 + "once_cell",
4647 + "regex",
4648 + "serde",
4649 + "serde_json",
4650 + "tokio",
4651 + "url",
4652 + ]
4653 +
4654 + [[package]]
4573 4655 name = "wit-bindgen"
4574 4656 version = "0.51.0"
4575 4657 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -7,7 +7,7 @@ members = [
7 7 default-members = ["."]
8 8
9 9 [workspace.package]
10 - version = "0.3.4"
10 + version = "0.3.5"
11 11 edition = "2024"
12 12 license-file = "LICENSE"
13 13
@@ -96,3 +96,4 @@ time = "0.3"
96 96
97 97 [dev-dependencies]
98 98 http-body-util = "0.1"
99 + wiremock = "0.6"
@@ -71,6 +71,14 @@ pub struct PostWithAuthor {
71 71 pub edited_at: Option<DateTime<Utc>>,
72 72 pub deleted_at: Option<DateTime<Utc>>,
73 73 pub removed_at: Option<DateTime<Utc>>,
74 + /// Author's current Fan+ status (denormalised on users; refreshed at
75 + /// OAuth callback / `POST /auth/refresh`). Used for the + badge and
76 + /// for signature visibility — signatures only render for current
77 + /// Fan+ subscribers.
78 + pub author_is_fan_plus: bool,
79 + /// Author's saved signature HTML (rendered at save time). Render only
80 + /// when `author_is_fan_plus` is true.
81 + pub author_signature_html: Option<String>,
74 82 }
75 83
76 84 #[derive(sqlx::FromRow)]
@@ -415,7 +423,9 @@ pub async fn list_posts_in_thread(
415 423 COALESCE(u.display_name, u.username) AS author_name,
416 424 u.username AS author_username,
417 425 p.body_html, p.created_at, p.edited_at, p.deleted_at,
418 - p.removed_at
426 + p.removed_at,
427 + u.is_fan_plus AS author_is_fan_plus,
428 + u.signature_html AS author_signature_html
419 429 FROM posts p
420 430 JOIN users u ON u.mnw_account_id = p.author_id
421 431 WHERE p.thread_id = $1
@@ -438,7 +448,9 @@ pub async fn list_posts_in_thread_paginated(
438 448 COALESCE(u.display_name, u.username) AS author_name,
439 449 u.username AS author_username,
440 450 p.body_html, p.created_at, p.edited_at, p.deleted_at,
441 - p.removed_at
451 + p.removed_at,
452 + u.is_fan_plus AS author_is_fan_plus,
453 + u.signature_html AS author_signature_html
442 454 FROM posts p
443 455 JOIN users u ON u.mnw_account_id = p.author_id
444 456 WHERE p.thread_id = $1
@@ -13,31 +13,47 @@ v0.3.4. Audit grade A. 228 tests.
13 13
14 14 ## Platform Integration (Post-Beta)
15 15
16 - ### Default Categories — Remaining
17 - - [ ] Issues (git issue tracker replacement — see MNW G8-issues)
18 - - [ ] Patches (inbound email patches — see MNW G7B-patches)
16 + ### Default Categories
17 + Done:
18 + - [x] Issues: MT seeds an "issues" category in default communities; MNW `routes/postmark/issues.rs` spawns an MT thread per inbound issue (project-linked repos only) and routes email replies into that thread as posts. Issue row stores `mt_thread_id` for direct lookup.
19 + - [x] Patches: MT seeds a "patches" category in default communities; MNW `routes/postmark/patches.rs` already wired (auto-creates the category on demand for pre-step-6 communities).
20 +
21 + Still blocked on MNW Developer Services (crash reporting / feedback / dashboard, not yet built):
19 22 - [ ] Crashes (crash reports from DS2)
20 23 - [ ] Feedback (user feedback from DS3)
21 24
22 25 ### Fan+ Feature Gating
23 - - [ ] Signatures (text + image, rendered on every post) — Fan+ only
24 - - [ ] Custom / larger profile images — Fan+ only (free accounts get generated avatar)
25 - - [ ] Image and video embeds in posts — Fan+ only (free accounts post text only)
26 - - [ ] Creator auto-grant: creators get all Fan+ forum perks in own communities (no + badge)
27 - - [ ] + badge rendering in post author display
28 26
29 - ### Private Communities (Fan+)
30 - - [ ] Community visibility flag (public/private)
31 - - [ ] Membership gating: restrict join to Fan+ subscribers or item buyers
32 - - [ ] Hidden from public listing, accessible only via direct link or MNW project page
27 + Depends on MNW shipping the `perks` object in `/oauth/userinfo` (see MNW server todo: "OAuth userinfo perks object"). Gating predicate: `user.perks.fan_plus || user.perks.is_creator`. Creator auto-grant falls out of the `is_creator` branch — no separate code path.
28 +
29 + Plumbing:
30 + - [x] Extend `SessionUser` with `perks: UserPerks { fan_plus, is_creator, creator_tier: Option<{ tier, features }> }` and `effective_plus()` helper
31 + - [x] `auth::refresh_session(state, session)` — reads cached access token, re-hits `/oauth/userinfo`, overwrites session perks. Flushes session on `401`, leaves intact on transient errors
32 + - [x] `POST /auth/refresh` route — JSON response with refreshed perks; `401` if not logged in, `502` on MNW transport/parse error
33 + - [x] Refactored userinfo fetch into reusable `fetch_userinfo`; callback handler now retries on transport only
34 +
35 + Gated features:
36 + - [x] Signatures (markdown + image, 1024 char cap, rendered below post body). Edit form at `/account`. Render-time visibility gated on current `users.is_fan_plus` — lapsed users keep the row but the signature hides until they renew.
37 + - [x] Image embeds in posts (markdown `![](...)`), gated via `render_markdown_plus` (strict + images permitted). Non-plus users get a 422 with a clear "Fan+ feature" message at submit time. Applies to thread bodies, replies, and footnotes.
38 + - [x] + badge in author display: shown only for users with active Fan+ subscription (creators with auto-grant do NOT get the badge — auto-grant covers editor capabilities, not the public badge).
39 + - [x] Denormalised `users.is_fan_plus` / `is_creator` columns (migration 026) mirror MNW perks; refreshed on login + `POST /auth/refresh`. Post-author lookup uses these via SQL JOIN — no per-post HTTP call.
40 + - [ ] Custom / larger profile images — **deferred**: MT pulls `avatar_url` from MNW. Forum-local avatar storage would be a separate feature; out of scope for current launch.
33 41
34 42 ### Community Moderation Enforcement
35 - - [ ] Restricted state: disable new thread creation for non-moderators
36 - - [ ] Frozen state: community goes read-only, mods can still take mod actions to unfreeze
37 - - [ ] Clean slate mechanism: clear all threads/posts, preserve settings/categories, post system notice
38 - - [ ] Archived state with reactivation path
39 - - [ ] PoM integration: flag age monitoring, flag-to-action ratio alerts
40 - - [ ] Document moderation policy publicly (see `docs/internal/moderation_policy.md` for internal version)
43 +
44 + Two-layer auth model already exists: superadmin = `PLATFORM_ADMIN_ID` (single user), forum-level = `CommunityRole::{Owner, Moderator}`. State changes and clean-slate authorized by `is_mod_or_owner(role) || is_platform_admin(user)` — wrap as `require_mod_or_superadmin` helper to keep boilerplate down. No new permission concepts; more robust system deferred.
45 +
46 + - [x] Add `community.state` enum column: `Active | Restricted | Frozen | Archived` (migration 025, `CommunityState` in mt-core, `set_community_state` mutation)
47 + - [x] `require_mod_or_superadmin` / `is_mod_or_superadmin` / `is_platform_admin` helpers + `WriteScope` + `check_write_state` enforcement helper
48 + - [x] Restricted state: block new thread creation for non-mods; existing threads still accept replies
49 + - [x] Frozen state: read-only for everyone except mods/superadmin (blocks new threads, replies, footnotes, endorsements)
50 + - [x] Archived state: Frozen behavior + hidden from default `/` listing; exposed under `?filter=archived`; reactivation sets state back to Active
51 + - [x] State-change route: `POST /p/{slug}/settings/state` (owner/mod/superadmin); rejects unknown values with 422; logs `ModAction::ChangeCommunityState`
52 + - [x] Clean-slate mutation: transactional delete of all threads/posts (cascades through endorsements/flags/footnotes/etc.), preserves community/categories/memberships/bans/tags; posts a pinned+locked "Community reset by &lt;actor&gt; on &lt;date&gt;" thread in the first category
53 + - [x] Clean-slate UX: typed-phrase confirmation matching community slug (GitHub repo-delete style); 422 on mismatch
54 + - [x] Superadmin UX: dedicated `GET /_admin/communities/{slug}` view with state-change form + clean-slate danger zone; linked from the admin dashboard community table
55 + - [x] Moderation policy published at `MNW/server/site-docs/public/guide/moderation.md`; linked from MT footer
56 + - [x] `ModAction::CleanSlateCommunity` logged with deleted thread count + system thread ID for audit
41 57
42 58 ### Notification Integration
43 59 - [ ] Push mentions, replies, endorsements, flags to MNW notifications API
@@ -47,9 +63,9 @@ v0.3.4. Audit grade A. 228 tests.
47 63
48 64 ## Deferred (Post-Beta)
49 65
66 + - [ ] Private communities (visibility flag, membership gating, hidden listing) — tabled; focus is project-oriented and creator-oriented public forums
50 67 - [ ] E2E encrypted live chat (OpenMLS integration, WebSocket gateway)
51 68 - [ ] Real-time thread updates via shared WebSocket gateway (shared with SyncKit realtime sync — single service)
52 - - [ ] Community creation by users (currently admin-seeded only; MNW auto-provisioning handles project communities)
53 69 - [ ] Federation (ActivityPub or custom protocol)
54 70 - [ ] Subcategories / nested categories
55 71 - [ ] Similar thread detection on new thread creation
@@ -0,0 +1,17 @@
1 + -- Fan+ perks: signature columns + denormalised perk flags.
2 + --
3 + -- `is_fan_plus` and `is_creator` mirror the latest MNW `/oauth/userinfo`
4 + -- `perks` snapshot for this user. They're cached here so post rendering can
5 + -- gate the + badge and signature display by JOINing against users instead of
6 + -- making per-post calls to MNW. Refreshed on login + `POST /auth/refresh`
7 + -- (see `src/auth.rs`).
8 + --
9 + -- Signature: free-text markdown shown beneath each post by Fan+ subscribers.
10 + -- We store both the source markdown and pre-rendered HTML, matching the
11 + -- pattern used for posts. Render at save time only.
12 +
13 + ALTER TABLE users
14 + ADD COLUMN signature_markdown TEXT,
15 + ADD COLUMN signature_html TEXT,
16 + ADD COLUMN is_fan_plus BOOLEAN NOT NULL DEFAULT FALSE,
17 + ADD COLUMN is_creator BOOLEAN NOT NULL DEFAULT FALSE;
@@ -1,13 +1,19 @@
1 1 //! OAuth client for "Log in with Makenot.work" and session user extraction.
2 + //!
3 + //! Perks (Fan+, creator tier, capabilities) come from MNW's `/oauth/userinfo`
4 + //! `perks` object. We cache them in the session and refresh on three triggers:
5 + //! (1) login, (2) session cycle, (3) on-demand via `POST /auth/refresh`. See
6 + //! `MNW/server/docs/oauth_integration.md` for the contract.
2 7
3 8 use axum::{
4 9 extract::{FromRequestParts, Query, State},
5 10 http::{request::Parts, StatusCode},
6 11 response::{IntoResponse, Redirect},
12 + Json,
7 13 };
8 14 use base64::Engine;
9 15 use rand::RngCore;
10 - use serde::Deserialize;
16 + use serde::{Deserialize, Serialize};
11 17 use sha2::{Digest, Sha256};
12 18 use tokio::time::sleep;
13 19 use tower_sessions::Session;
@@ -37,17 +43,52 @@ fn generate_state_nonce() -> String {
37 43
38 44 // ── Session user ──
39 45
40 - /// Minimal user info stored in the session after OAuth login.
46 + /// User info cached in the session after OAuth login.
47 + ///
48 + /// `perks` reflects MNW state at the last refresh (login, session cycle, or
49 + /// explicit `POST /auth/refresh`). Use [`UserPerks::effective_plus`] for the
50 + /// canonical Fan+ gate.
41 51 #[derive(Clone, Debug)]
42 52 pub struct SessionUser {
43 53 pub user_id: uuid::Uuid,
44 54 pub username: String,
45 55 pub display_name: Option<String>,
56 + pub perks: UserPerks,
57 + }
58 +
59 + /// Capability snapshot from MNW's `/oauth/userinfo` `perks` object.
60 + ///
61 + /// Default = no perks; this is what unknown / not-yet-refreshed sessions see.
62 + #[derive(Clone, Debug, Default, Serialize, Deserialize)]
63 + pub struct UserPerks {
64 + #[serde(default)]
65 + pub fan_plus: bool,
66 + #[serde(default)]
67 + pub is_creator: bool,
68 + #[serde(default)]
69 + pub creator_tier: Option<CreatorTierInfo>,
70 + }
71 +
72 + #[derive(Clone, Debug, Serialize, Deserialize)]
73 + pub struct CreatorTierInfo {
74 + pub tier: String,
75 + pub features: Vec<String>,
76 + }
77 +
78 + impl UserPerks {
79 + /// Canonical "should this user see + features" check. True for active Fan+
80 + /// subscribers and for any creator (auto-grant: creators get + perks without
81 + /// paying for Fan+ separately).
82 + pub fn effective_plus(&self) -> bool {
83 + self.fan_plus || self.is_creator
84 + }
46 85 }
47 86
48 87 const SESSION_USER_ID: &str = "user_id";
49 88 const SESSION_USERNAME: &str = "username";
50 89 const SESSION_DISPLAY_NAME: &str = "display_name";
90 + const SESSION_PERKS: &str = "perks";
91 + const SESSION_ACCESS_TOKEN: &str = "mnw_access_token";
51 92 const SESSION_OAUTH_STATE: &str = "oauth_state";
52 93 const SESSION_PKCE_VERIFIER: &str = "pkce_verifier";
53 94
@@ -74,10 +115,17 @@ impl SessionUser {
74 115 None
75 116 }
76 117 };
118 + // Perks default to empty — sessions predating the perks change still load.
119 + let perks: UserPerks = session
120 + .get(SESSION_PERKS)
121 + .await
122 + .unwrap_or_default()
123 + .unwrap_or_default();
77 124 Some(Self {
78 125 user_id,
79 126 username,
80 127 display_name,
128 + perks,
81 129 })
82 130 }
83 131
@@ -91,6 +139,9 @@ impl SessionUser {
91 139 if let Err(e) = session.insert(SESSION_DISPLAY_NAME, &self.display_name).await {
92 140 tracing::error!(error = %e, "failed to save display_name to session");
93 141 }
142 + if let Err(e) = session.insert(SESSION_PERKS, &self.perks).await {
143 + tracing::error!(error = %e, "failed to save perks to session");
144 + }
94 145 }
95 146 }
96 147
@@ -157,6 +208,111 @@ struct UserinfoResponse {
157 208 username: String,
158 209 display_name: Option<String>,
159 210 avatar_url: Option<String>,
211 + #[serde(default)]
212 + perks: UserPerks,
213 + }
214 +
215 + #[derive(Debug)]
216 + pub enum UserinfoError {
217 + Unauthorized,
218 + Transport,
219 + BadResponse,
220 + }
221 +
222 + /// Single-attempt userinfo fetch against MNW. Callers decide retry policy.
223 + ///
224 + /// `Unauthorized` means the bearer token is invalid or the user is gone.
225 + /// `Transport` covers network and 5xx. `BadResponse` covers other 4xx and parse
226 + /// errors. The login callback retries on `Transport`; `refresh_session` does
227 + /// not — the client can retry.
228 + async fn fetch_userinfo(
229 + http: &reqwest::Client,
230 + base_url: &str,
231 + access_token: &str,
232 + ) -> Result<UserinfoResponse, UserinfoError> {
233 + let url = format!("{}/oauth/userinfo", base_url);
234 + let res = http
235 + .get(&url)
236 + .bearer_auth(access_token)
237 + .send()
238 + .await
239 + .map_err(|e| {
240 + tracing::warn!(error = %e, "userinfo transport error");
241 + UserinfoError::Transport
242 + })?;
243 +
244 + let status = res.status();
245 + if status == reqwest::StatusCode::UNAUTHORIZED {
246 + return Err(UserinfoError::Unauthorized);
247 + }
248 + if status.is_server_error() {
249 + return Err(UserinfoError::Transport);
250 + }
251 + if !status.is_success() {
252 + let body = res.text().await.unwrap_or_default();
253 + tracing::warn!(%status, %body, "userinfo non-success");
254 + return Err(UserinfoError::BadResponse);
255 + }
256 +
257 + res.json::<UserinfoResponse>().await.map_err(|e| {
258 + tracing::warn!(error = %e, "userinfo parse failed");
259 + UserinfoError::BadResponse
260 + })
261 + }
262 +
263 + /// Refresh the cached perks for the current session by re-hitting MNW.
264 + ///
265 + /// Caller must have a logged-in session (access token stored at login). On
266 + /// `Unauthorized` the session is flushed — the access token is gone for good
267 + /// and the user needs to log in again. Other errors leave the session intact.
268 + pub async fn refresh_session(
269 + state: &AppState,
270 + session: &Session,
271 + ) -> Result<UserPerks, UserinfoError> {
272 + let token: String = session
273 + .get(SESSION_ACCESS_TOKEN)
274 + .await
275 + .unwrap_or(None)
276 + .ok_or(UserinfoError::Unauthorized)?;
277 +
278 + match fetch_userinfo(&state.http, &state.config.mnw_base_url, &token).await {
279 + Ok(info) => {
280 + if let Err(e) = session.insert(SESSION_PERKS, &info.perks).await {
281 + tracing::error!(error = %e, "failed to save refreshed perks");
282 + }
283 + // Username/display can drift on MNW too — sync them while we're here.
284 + if let Err(e) = session.insert(SESSION_USERNAME, &info.username).await {
285 + tracing::error!(error = %e, "failed to save refreshed username");
286 + }
287 + if let Err(e) = session.insert(SESSION_DISPLAY_NAME, &info.display_name).await {
288 + tracing::error!(error = %e, "failed to save refreshed display_name");
289 + }
290 + // Mirror perks into users table so post rendering sees the change
291 + // without consulting MNW per-post. Best-effort: rendering tolerates
292 + // a stale row, so DB errors here are logged but non-fatal.
293 + if let Err(e) = sqlx::query(
294 + "UPDATE users SET is_fan_plus = $2, is_creator = $3 WHERE mnw_account_id = $1",
295 + )
296 + .bind(info.user_id)
297 + .bind(info.perks.fan_plus)
298 + .bind(info.perks.is_creator)
299 + .execute(&state.db)
300 + .await
301 + {
302 + tracing::warn!(error = %e, "failed to mirror refreshed perks to users table");
303 + }
304 + let _ = info.avatar_url; // not stored in session yet
305 + Ok(info.perks)
306 + }
307 + Err(UserinfoError::Unauthorized) => {
308 + // Token revoked, expired, or user deleted — drop the session.
309 + if let Err(e) = session.flush().await {
310 + tracing::warn!(error = %e, "failed to flush session after auth failure");
311 + }
312 + Err(UserinfoError::Unauthorized)
313 + }
314 + Err(e) => Err(e),
315 + }
160 316 }
161 317
162 318 // ── Handlers ──
@@ -288,77 +444,56 @@ pub async fn callback(
288 444 }
289 445 };
290 446
291 - // Fetch userinfo (retry up to 2 attempts on network/5xx errors)
292 - let userinfo_url = format!("{}/oauth/userinfo", state.config.mnw_base_url);
293 - tracing::info!(%userinfo_url, "fetching userinfo");
294 - let mut userinfo_res = None;
447 + // Fetch userinfo (retry up to 2 attempts on transport / 5xx errors).
448 + tracing::info!(base_url = %state.config.mnw_base_url, "fetching userinfo");
449 + let mut info: Option<UserinfoResponse> = None;
295 450 for attempt in 0..=backoffs.len() {
296 - let res = state
297 - .http
298 - .get(&userinfo_url)
299 - .bearer_auth(&token.access_token)
300 - .send()
301 - .await;
302 -
303 - match res {
304 - Ok(r) if r.status().is_server_error() => {
305 - let status = r.status();
306 - if attempt < backoffs.len() {
307 - tracing::warn!(%status, attempt, "userinfo got 5xx, retrying");
308 - sleep(backoffs[attempt]).await;
309 - continue;
310 - }
311 - let body = r.text().await.unwrap_or_default();
312 - tracing::error!(%status, %body, "userinfo fetch failed after retries");
313 - return Redirect::to("/?error=userinfo_fetch_failed");
451 + match fetch_userinfo(&state.http, &state.config.mnw_base_url, &token.access_token).await {
452 + Ok(i) => {
453 + info = Some(i);
454 + break;
314 455 }
315 - Ok(r) if !r.status().is_success() => {
316 - let status = r.status();
317 - let body = r.text().await.unwrap_or_default();
318 - tracing::error!(%status, %body, "userinfo fetch failed");
456 + Err(UserinfoError::Transport) if attempt < backoffs.len() => {
457 + tracing::warn!(attempt, "userinfo transport error, retrying");
458 + sleep(backoffs[attempt]).await;
459 + continue;
460 + }
461 + Err(UserinfoError::Transport) => {
462 + tracing::error!("userinfo transport failed after retries");
319 463 return Redirect::to("/?error=userinfo_fetch_failed");
320 464 }
321 - Ok(r) => {
322 - userinfo_res = Some(r);
323 - break;
465 + Err(UserinfoError::Unauthorized) => {
466 + tracing::error!("userinfo unauthorized — token rejected");
467 + return Redirect::to("/?error=userinfo_fetch_failed");
324 468 }
325 - Err(e) => {
326 - if attempt < backoffs.len() {
327 - tracing::warn!(error = %e, attempt, "userinfo request failed, retrying");
328 - sleep(backoffs[attempt]).await;
329 - continue;
330 - }
331 - tracing::error!(error = %e, "userinfo request failed after retries");
332 - return Redirect::to("/?error=userinfo_request_failed");
469 + Err(UserinfoError::BadResponse) => {
470 + tracing::error!("userinfo bad response");
471 + return Redirect::to("/?error=userinfo_parse_failed");
333 472 }
334 473 }
335 474 }
336 - // Safety: loop always either sets userinfo_res or returns early
337 - let userinfo_res = userinfo_res.unwrap();
338 -
339 - let info: UserinfoResponse = match userinfo_res.json().await {
340 - Ok(i) => i,
341 - Err(e) => {
342 - tracing::error!(error = %e, "userinfo parse failed");
343 - return Redirect::to("/?error=userinfo_parse_failed");
344 - }
345 - };
475 + let info = info.expect("userinfo loop always sets value or returns");
346 476
347 477 tracing::info!(user_id = %info.user_id, username = %info.username, "OAuth login successful");
348 478
349 - // Upsert local user
479 + // Upsert local user. `is_fan_plus`/`is_creator` are denormalised here so
480 + // post rendering can look up the post author's perks via JOIN — see
481 + // migration 026.
350 482 let upsert_result = sqlx::query(
351 483 r#"
352 - INSERT INTO users (mnw_account_id, username, display_name, avatar_url)
353 - VALUES ($1, $2, $3, $4)
484 + INSERT INTO users (mnw_account_id, username, display_name, avatar_url, is_fan_plus, is_creator)
485 + VALUES ($1, $2, $3, $4, $5, $6)
354 486 ON CONFLICT (mnw_account_id) DO UPDATE
355 - SET username = $2, display_name = $3, avatar_url = $4, updated_at = now()
487 + SET username = $2, display_name = $3, avatar_url = $4,
488 + is_fan_plus = $5, is_creator = $6, updated_at = now()
356 489 "#,
357 490 )
358 491 .bind(info.user_id)
359 492 .bind(&info.username)
360 493 .bind(&info.display_name)
361 494 .bind(&info.avatar_url)
495 + .bind(info.perks.fan_plus)
496 + .bind(info.perks.is_creator)
362 497 .execute(&state.db)
363 498 .await;
364 499
@@ -386,13 +521,21 @@ pub async fn callback(
386 521 return Redirect::to("/?error=account_suspended");
387 522 }
388 523
389 - // Save session
524 + // Save session — perks come from the same userinfo response, no second roundtrip.
390 525 let session_user = SessionUser {
391 526 user_id: info.user_id,
392 527 username: info.username,
393 528 display_name: info.display_name,
529 + perks: info.perks,
394 530 };
395 531 session_user.save_to_session(&session).await;
532 + // Stash the access token so `refresh_session` can re-hit userinfo without
533 + // forcing the user through another OAuth round trip. Token lifetime is set
534 + // by MNW (7d as of writing); after expiry, refresh returns Unauthorized and
535 + // the session is flushed.
536 + if let Err(e) = session.insert(SESSION_ACCESS_TOKEN, &token.access_token).await {
537 + tracing::error!(error = %e, "failed to save access token to session");
538 + }
396 539 if let Err(e) = session.cycle_id().await {
397 540 tracing::warn!(error = %e, "Failed to cycle session ID");
398 541 }
@@ -401,6 +544,30 @@ pub async fn callback(
401 544 Redirect::to("/")
402 545 }
403 546
547 + /// `POST /auth/refresh` — re-fetch MNW userinfo and overwrite cached perks.
548 + ///
549 + /// Useful after the user takes an action that changed their MNW entitlements
550 + /// (e.g., subscribing to Fan+, upgrading a creator tier) so they don't have to
551 + /// log out and back in to see the new perks. Returns the refreshed perks as
552 + /// JSON.
553 + #[tracing::instrument(skip_all)]
554 + pub async fn refresh(
555 + State(state): State<AppState>,
556 + session: Session,
557 + ) -> Result<Json<RefreshResponse>, StatusCode> {
558 + match refresh_session(&state, &session).await {
559 + Ok(perks) => Ok(Json(RefreshResponse { perks })),
560 + Err(UserinfoError::Unauthorized) => Err(StatusCode::UNAUTHORIZED),
561 + Err(UserinfoError::Transport) => Err(StatusCode::BAD_GATEWAY),
562 + Err(UserinfoError::BadResponse) => Err(StatusCode::BAD_GATEWAY),
563 + }
564 + }
565 +
566 + #[derive(Serialize)]
567 + pub struct RefreshResponse {
568 + pub perks: UserPerks,
569 + }
570 +
404 571 /// `POST /auth/logout` — flush session, redirect home.
405 572 #[tracing::instrument(skip_all)]
406 573 pub async fn logout(session: Session) -> impl IntoResponse {
@@ -0,0 +1,134 @@
1 + //! Per-user account settings (signature + Fan+ status display).
2 + //!
3 + //! All routes require a logged-in session. The signature editor is gated on
4 + //! [`UserPerks::effective_plus`]: non-Fan+ users see the upsell instead of an
5 + //! input. Lapsed Fan+ users retain their saved markdown (we don't auto-delete
6 + //! it) but the post-rendering layer hides their signature until they renew.
7 +
8 + use axum::{
9 + extract::{Form, State},
10 + http::StatusCode,
11 + response::{IntoResponse, Redirect, Response},
12 + };
13 + use tower_sessions::Session;
14 +
15 + use crate::auth::MaybeUser;
16 + use crate::csrf;
17 + use crate::templates::*;
18 + use crate::AppState;
19 +
20 + use super::{
21 + render_markdown, render_markdown_plus, template_user, SignatureForm,
22 + };
23 +
24 + const SIGNATURE_MAX: usize = 1024;
25 +
26 + #[tracing::instrument(skip_all)]
27 + pub(super) async fn account_settings(
28 + State(state): State<AppState>,
29 + session: Session,
30 + MaybeUser(session_user): MaybeUser,
31 + ) -> Result<AccountSettingsTemplate, Response> {
32 + let csrf_token = Some(csrf::get_or_create_token(&session).await);
33 + let user = session_user
34 + .ok_or_else(|| Redirect::to("/auth/login").into_response())?;
35 +
36 + let row: Option<(Option<String>, Option<String>)> = sqlx::query_as(
37 + "SELECT signature_markdown, signature_html FROM users WHERE mnw_account_id = $1",
38 + )
39 + .bind(user.user_id)
40 + .fetch_optional(&state.db)
41 + .await
42 + .map_err(|e| {
43 + tracing::error!(error = ?e, "db error loading signature");
44 + StatusCode::INTERNAL_SERVER_ERROR.into_response()
45 + })?;
46 + let (signature_markdown, signature_html) = row.unwrap_or((None, None));
47 +
48 + Ok(AccountSettingsTemplate {
49 + csrf_token,
50 + session_user: Some(template_user(&user, state.config.platform_admin_id)),
51 + mnw_base_url: state.config.mnw_base_url.clone(),
52 + has_plus: user.perks.effective_plus(),
53 + fan_plus: user.perks.fan_plus,
54 + signature_markdown,
55 + signature_html,
56 + error: None,
57 + })
58 + }
59 +
60 + #[tracing::instrument(skip_all)]
61 + pub(super) async fn update_signature_handler(
62 + State(state): State<AppState>,
63 + MaybeUser(session_user): MaybeUser,
64 + Form(form): Form<SignatureForm>,
65 + ) -> Result<Redirect, Response> {
66 + let user = session_user
67 + .ok_or_else(|| Redirect::to("/auth/login").into_response())?;
68 +
69 + if !user.perks.effective_plus() {
70 + return Err((
71 + StatusCode::FORBIDDEN,
72 + "Signatures are a Fan+ feature.",
73 + )
74 + .into_response());
75 + }
76 +
77 + // "Clear signature" button submits with `clear=1`; takes precedence over
78 + // the textarea content.
79 + if form.clear.as_deref() == Some("1") {
80 + sqlx::query(
81 + "UPDATE users SET signature_markdown = NULL, signature_html = NULL \
82 + WHERE mnw_account_id = $1",
83 + )
84 + .bind(user.user_id)
85 + .execute(&state.db)
86 + .await
87 + .map_err(|e| {
88 + tracing::error!(error = ?e, "db error clearing signature");
89 + StatusCode::INTERNAL_SERVER_ERROR.into_response()
90 + })?;
91 + return Ok(Redirect::to("/account?toast=Signature+cleared"));
92 + }
93 +
94 + let trimmed = form.signature.trim();
95 + if trimmed.is_empty() {
96 + // Treat empty submit as a no-op rather than implicit clear — there's
97 + // an explicit Clear button for that.
98 + return Ok(Redirect::to("/account"));
99 + }
100 + if trimmed.chars().count() > SIGNATURE_MAX {
101 + return Err((
102 + StatusCode::UNPROCESSABLE_ENTITY,
103 + format!("Signature must be at most {SIGNATURE_MAX} characters."),
104 + )
105 + .into_response());
106 + }
107 +
108 + // Render with the same plus-aware paths as posts: creators get image
109 + // embeds via auto-grant, matching post-body behaviour. Render-time
110 + // visibility is gated by `users.is_fan_plus` so creator signatures
111 + // still only surface when they're also a Fan+ subscriber — auto-grant
112 + // covers editing capability, not the public + badge / signature display.
113 + let signature_html = if user.perks.effective_plus() {
114 + render_markdown_plus(trimmed)
115 + } else {
116 + render_markdown(trimmed)
117 + };
118 +
119 + sqlx::query(
120 + "UPDATE users SET signature_markdown = $2, signature_html = $3 \
121 + WHERE mnw_account_id = $1",
122 + )
123 + .bind(user.user_id)
124 + .bind(trimmed)
125 + .bind(&signature_html)
126 + .execute(&state.db)
127 + .await
128 + .map_err(|e| {
129 + tracing::error!(error = ?e, "db error saving signature");
130 + StatusCode::INTERNAL_SERVER_ERROR.into_response()
131 + })?;
132 +
133 + Ok(Redirect::to("/account?toast=Signature+saved"))
134 + }
@@ -11,7 +11,7 @@ use crate::auth::MaybeUser;
11 11 use crate::AppState;
12 12
13 13 use super::super::{
14 - check_community_access, check_user_post_rate, check_write_access, get_community,
14 + check_community_access, check_user_post_rate, check_write_access, check_write_state, get_community, WriteScope,
15 15 parse_uuid, validate_body, FootnoteForm,
16 16 };
17 17 use super::posts::{resolve_and_render_mentions, MAX_FOOTNOTES_PER_POST};
@@ -77,6 +77,7 @@ pub(in crate::routes) async fn add_footnote_handler(
77 77 let community = get_community(&state.db, &slug).await?;
78 78
79 79 check_write_access(&state.db, community.id, user.user_id, community.suspended_at.is_some()).await?;
80 + check_write_state(&state, &community, &user, WriteScope::ContinueExisting).await?;
80 81 mt_db::mutations::ensure_membership(&state.db, user.user_id, community.id)
81 82 .await
82 83 .map_err(|e| {
@@ -86,9 +87,13 @@ pub(in crate::routes) async fn add_footnote_handler(
86 87 check_user_post_rate(&state.db, user.user_id).await?;
87 88
88 89 let body = validate_body(&form.body, 65536, "Footnote")?;
90 + let author_plus = user.perks.effective_plus();
91 + if !author_plus {
92 + crate::routes::reject_embeds_for_free_user(body)?;
93 + }
89 94
90 95 let (body_html, _mention_ids) = resolve_and_render_mentions(
91 - &state.db, body, community.id, &slug, user.user_id,
96 + &state.db, body, community.id, &slug, user.user_id, author_plus,
92 97 ).await?;
93 98
94 99 mt_db::mutations::insert_footnote(&state.db, post_id, user.user_id, body, &body_html)
@@ -17,9 +17,10 @@ use crate::AppState;
17 17 use mt_core::types::ModAction;
18 18
19 19 use super::super::{
20 - check_user_post_rate, check_write_access, get_community, get_thread, is_mod_or_owner,
21 - log_mod_action, parse_uuid, render_markdown, render_markdown_with_mentions,
22 - template_user, validate_body, validate_title, get_role,
20 + check_user_post_rate, check_write_access, check_write_state, get_community, get_thread,
21 + is_mod_or_owner, log_mod_action, parse_uuid, reject_embeds_for_free_user, render_markdown,
22 + render_markdown_plus, render_markdown_with_mentions, template_user, validate_body,
23 + validate_title, get_role, WriteScope,
23 24 CreateReplyForm, CreateThreadForm,
24 25 };
25 26
@@ -119,6 +120,7 @@ pub(super) async fn verify_quotes(
119 120
120 121 /// Resolve mentions in a post body and return (rendered_html, mentioned_user_ids).
121 122 /// `author_id` is excluded from the mention list (self-mention not stored).
123 + /// `allow_images` enables image embeds (Fan+ subscribers only).
122 124 #[tracing::instrument(skip_all)]
123 125 pub(super) async fn resolve_and_render_mentions(
124 126 db: &sqlx::PgPool,
@@ -126,10 +128,16 @@ pub(super) async fn resolve_and_render_mentions(
126 128 community_id: Uuid,
127 129 community_slug: &str,
128 130 author_id: Uuid,
131 + allow_images: bool,
129 132 ) -> Result<(String, Vec<Uuid>), Response> {
130 133 let usernames = docengine::extract_mentions(body);
131 134 if usernames.is_empty() {
132 - return Ok((render_markdown(body), Vec::new()));
135 + let rendered = if allow_images {
136 + render_markdown_plus(body)
137 + } else {
138 + render_markdown(body)
139 + };
140 + return Ok((rendered, Vec::new()));
133 141 }
134 142
135 143 let resolved = mt_db::queries::resolve_usernames_in_community(db, community_id, &usernames)
@@ -140,7 +148,7 @@ pub(super) async fn resolve_and_render_mentions(
140 148 })?;
141 149
142 150 let valid_set: std::collections::HashSet<String> = resolved.keys().cloned().collect();
143 - let body_html = render_markdown_with_mentions(body, community_slug, &valid_set);
151 + let body_html = render_markdown_with_mentions(body, community_slug, &valid_set, allow_images);
144 152
145 153 // Collect user IDs, excluding self
146 154 let mention_ids: Vec<Uuid> = resolved
@@ -208,6 +216,7 @@ pub(in crate::routes) async fn create_thread_handler(
208 216 let community = get_community(&state.db, &slug).await?;
209 217
210 218 check_write_access(&state.db, community.id, user.user_id, community.suspended_at.is_some()).await?;
219 + check_write_state(&state, &community, &user, WriteScope::NewThread).await?;
211 220 mt_db::mutations::ensure_membership(&state.db, user.user_id, community.id)
212 221 .await
213 222 .map_err(|e| {
@@ -218,6 +227,10 @@ pub(in crate::routes) async fn create_thread_handler(
218 227
219 228 let title = validate_title(&form.title)?;
220 229 let body = validate_body(&form.body, 65536, "Body")?;
230 + let author_plus = user.perks.effective_plus();
231 + if !author_plus {
232 + reject_embeds_for_free_user(body)?;
233 + }
221 234
222 235 let category_id = mt_db::mutations::get_category_id_by_slugs(&state.db, &slug, &category_slug)
223 236 .await
@@ -230,7 +243,7 @@ pub(in crate::routes) async fn create_thread_handler(
230 243 verify_quotes(&state.db, body).await?;
231 244
232 245 let (body_html, mention_ids) = resolve_and_render_mentions(
233 - &state.db, body, community.id, &slug, user.user_id,
246 + &state.db, body, community.id, &slug, user.user_id, author_plus,
234 247 ).await?;
235 248
236 249 let thread_id = mt_db::mutations::create_thread(&state.db, category_id, user.user_id, title)
@@ -295,6 +308,7 @@ pub(in crate::routes) async fn create_reply_handler(
295 308 let community = get_community(&state.db, &slug).await?;
296 309
297 310 check_write_access(&state.db, community.id, user.user_id, community.suspended_at.is_some()).await?;
311 + check_write_state(&state, &community, &user, WriteScope::ContinueExisting).await?;
298 312 mt_db::mutations::ensure_membership(&state.db, user.user_id, community.id)
299 313 .await
300 314 .map_err(|e| {
@@ -308,11 +322,15 @@ pub(in crate::routes) async fn create_reply_handler(
308 322 }
309 323
310 324 let body = validate_body(&form.body, 65536, "Body")?;
325 + let author_plus = user.perks.effective_plus();
326 + if !author_plus {
327 + reject_embeds_for_free_user(body)?;
328 + }
311 329
312 330 verify_quotes(&state.db, body).await?;
313 331
314 332 let (body_html, mention_ids) = resolve_and_render_mentions(
315 - &state.db, body, community.id, &slug, user.user_id,
333 + &state.db, body, community.id, &slug, user.user_id, author_plus,
316 334 ).await?;
317 335
318 336 let thread_id = parse_uuid(&thread_id_str)?;
@@ -170,6 +170,14 @@ pub(in crate::routes) async fn thread(
170 170 let can_flag = !is_removed
171 171 && session_user.as_ref().is_some_and(|u| u.user_id != p.author_id);
172 172
173 + // Signatures are gated on current Fan+ status: a lapsed Fan+ user's
174 + // saved signature is hidden until they renew. Same for the + badge.
175 + let author_signature_html = if p.author_is_fan_plus {
176 + p.author_signature_html
177 + } else {
178 + None
179 + };
180 +
173 181 PostRow {
174 182 id: post_id_str,
175 183 author_name: p.author_name,
@@ -186,6 +194,8 @@ pub(in crate::routes) async fn thread(
186 194 endorsement_count,
187 195 is_endorsed,
188 196 can_endorse,
197 + author_has_plus_badge: p.author_is_fan_plus,
198 + author_signature_html,
189 199 }
190 200 })
191 201 .collect();
@@ -26,19 +26,60 @@ pub(crate) const USER_POST_RATE_WINDOW_SECS: i64 = 60;
26 26 // ============================================================================
27 27
28 28 /// Render markdown to HTML, stripping raw HTML events to prevent XSS.
29 + ///
30 + /// Strict preset: no images, no raw HTML, dangerous-scheme filtering. Use this
31 + /// for content from non-Fan+ users. Fan+ subscribers get image embeds via
32 + /// [`render_markdown_plus`].
29 33 pub(crate) fn render_markdown(input: &str) -> String {
30 34 docengine::render_strict(input)
31 35 }
32 36
37 + /// Render markdown to HTML with image embeds permitted. Otherwise identical to
38 + /// the strict preset — raw HTML is still stripped, dangerous schemes filtered,
39 + /// links get `nofollow`. Use for Fan+ subscriber content.
40 + pub(crate) fn render_markdown_plus(input: &str) -> String {
41 + docengine::Renderer::strict()
42 + .with_strip_images(false)
43 + .render(input)
44 + }
45 +
33 46 /// Render markdown to HTML, resolving `@mentions` to profile links for valid community members.
34 47 pub(crate) fn render_markdown_with_mentions(
35 48 input: &str,
36 49 community_slug: &str,
37 50 valid_usernames: &std::collections::HashSet<String>,
51 + allow_images: bool,
38 52 ) -> String {
39 53 let template = format!("/p/{community_slug}/u/{{username}}");
40 54 let resolved = docengine::resolve_mentions(input, valid_usernames, &template);
41 - docengine::render_strict(&resolved)
55 + if allow_images {
56 + render_markdown_plus(&resolved)
57 + } else {
58 + docengine::render_strict(&resolved)
59 + }
60 + }
61 +
62 + /// Reject submissions that contain image embeds. Used to give non-Fan+ users
63 + /// a clear error rather than silently stripping their image at render time.
64 + ///
65 + /// Only matches the markdown image syntax `![alt](url)`. Raw HTML `<img>` /
66 + /// `<video>` / `<iframe>` are stripped by all renderers (`strip_raw_html` is
67 + /// true in both `render_strict` and `render_markdown_plus`), so we don't need
68 + /// to reject them here — and rejecting them would surprise users pasting code
69 + /// blocks containing HTML.
70 + #[allow(clippy::result_large_err)]
71 + pub(crate) fn reject_embeds_for_free_user(body: &str) -> Result<(), Response> {
72 + static EMBED_RE: std::sync::LazyLock<regex_lite::Regex> = std::sync::LazyLock::new(|| {
73 + regex_lite::Regex::new(r"!\[[^\]]*\]\([^\)]+\)").unwrap()
74 + });
75 + if EMBED_RE.is_match(body) {
76 + return Err((
77 + StatusCode::UNPROCESSABLE_ENTITY,
78 + "Image embeds are a Fan+ feature.",
79 + )
80 + .into_response());
81 + }
82 + Ok(())
42 83 }
43 84
44 85 // ============================================================================
@@ -114,12 +114,17 @@ async fn create_community(
114 114 .await
115 115 .map_err(db_error)?;
116 116
117 - // Create default categories
117 + // Create default categories. Issues + Patches are seeded empty so they
118 + // surface in the directory before the first email lands — the internal
119 + // API also auto-creates any missing category on demand (see thread
120 + // handler below), so this is for discoverability, not correctness.
118 121 let categories = [
119 122 ("Items", "items", 0),
120 123 ("Blog", "blog", 1),
121 124 ("Devlog", "devlog", 2),
122 125 ("Discussion", "discussion", 3),
126 + ("Issues", "issues", 4),
127 + ("Patches", "patches", 5),
123 128 ];
124 129 for (name, slug, order) in categories {
125 130 mt_db::mutations::create_category(&state.db, community_id, name, slug, None, order)
@@ -1,5 +1,6 @@
1 1 //! Route handlers — MNW-integrated forum.
2 2
3 + mod account;
3 4 mod admin;
4 5 mod flagging;
5 6 mod forum;
@@ -60,6 +61,8 @@ pub fn forum_routes(state: AppState) -> Router {
60 61 .route("/p/{slug}/settings/categories/{cat_id}/move", post(settings::move_category_handler))
61 62 .route("/p/{slug}/settings/tags/new", post(settings::create_tag_handler))
62 63 .route("/p/{slug}/settings/tags/delete", post(settings::delete_tag_handler))
64 + .route("/p/{slug}/settings/state", post(settings::set_community_state_handler))
65 + .route("/account/signature", post(account::update_signature_handler))
63 66 .route("/p/{slug}/moderation/ban", post(moderation::ban_user_handler))
64 67 .route("/p/{slug}/moderation/unban", post(moderation::unban_user_handler))
65 68 .route("/p/{slug}/moderation/mute", post(moderation::mute_user_handler))
@@ -81,6 +84,7 @@ pub fn forum_routes(state: AppState) -> Router {
81 84 .route("/tracked/stop-all", post(tracking::untrack_all_handler))
82 85 .route("/_admin/communities/{id}/suspend", post(admin::suspend_community_handler))
83 86 .route("/_admin/communities/{id}/unsuspend", post(admin::unsuspend_community_handler))
87 + .route("/_admin/communities/{slug}/clean-slate", post(admin::admin_community_clean_slate_handler))
84 88 .route("/_admin/users/{id}/suspend", post(admin::suspend_user_handler))
85 89 .route("/_admin/users/{id}/unsuspend", post(admin::unsuspend_user_handler))
86 90 .route("/p/{slug}/upload", post(uploads::upload_image_handler))
@@ -111,6 +115,7 @@ pub fn forum_routes(state: AppState) -> Router {
111 115 .route("/p/{slug}", get(forum::project_forum))
112 116 .route("/p/{slug}/members", get(forum::community_members))
113 117 .route("/p/{slug}/u/{username}", get(forum::user_profile))
118 + .route("/account", get(account::account_settings))
114 119 .route("/p/{slug}/settings", get(settings::community_settings))
115 120 .route("/p/{slug}/settings/categories/{cat_id}/edit", get(settings::edit_category_form))
116 121 .route("/p/{slug}/moderation", get(moderation::moderation_page))
@@ -122,9 +127,11 @@ pub fn forum_routes(state: AppState) -> Router {
122 127 .route("/tracked", get(tracking::tracked_threads_page))
123 128 .route("/about/tracking", get(tracking::tracking_info_page))
124 129 .route("/_admin", get(admin::admin_dashboard))
130 + .route("/_admin/communities/{slug}", get(admin::admin_community_detail))
125 131 .route("/auth/login", get(auth::login))
126 132 .route("/auth/callback", get(auth::callback))
127 133 .route("/auth/logout", post(auth::logout))
134 + .route("/auth/refresh", post(auth::refresh))
128 135 .route("/api/user/{user_id}/summary", get(forum::user_summary_api))
129 136 .route("/uploads/{id}", get(uploads::serve_image_handler))
130 137 .route("/api/health", get(health));
@@ -201,6 +208,26 @@ pub(super) struct UpdateCommunityForm {
201 208 pub(super) auto_hide_threshold: Option<String>,
202 209 }
203 210
211 + /// `POST /_admin/communities/{slug}/clean-slate` confirmation form.
212 + /// `confirm` must exactly match the community slug (typed-phrase pattern).
213 + #[derive(Deserialize)]
214 + pub(super) struct CleanSlateForm {
215 + pub(super) confirm: String,
216 + }
217 +
218 + #[derive(Deserialize)]
219 + pub(super) struct SignatureForm {
220 + pub(super) signature: String,
221 + /// `Some("1")` when the Clear button is pressed.
222 + pub(super) clear: Option<String>,
223 + }
224 +
225 + #[derive(Deserialize)]
226 + pub(super) struct SetCommunityStateForm {
227 + /// Target state: `"active" | "restricted" | "frozen" | "archived"`.
228 + pub(super) state: String,
229 + }
230 +
204 231 #[derive(Deserialize)]
205 232 pub(super) struct CreateCategoryForm {
206 233 pub(super) name: String,
@@ -224,6 +251,14 @@ pub(super) struct PageQuery {
224 251 pub(super) page: Option<u32>,
225 252 }
226 253
254 + /// Query for `/` forum directory. `filter=archived` shows only archived
255 + /// communities; otherwise default listing (archived hidden).
256 + #[derive(Deserialize)]
257 + pub(super) struct ForumDirectoryQuery {
258 + pub(super) page: Option<u32>,
259 + pub(super) filter: Option<String>,
260 + }
261 +
227 262 #[derive(Deserialize)]
228 263 pub(super) struct CategoryQuery {
229 264 pub(super) page: Option<u32>,
@@ -43,6 +43,8 @@ macro_rules! impl_into_response {
43 43 }
44 44
45 45 impl_into_response!(
46 + AccountSettingsTemplate,
47 + AdminCommunityTemplate,
46 48 ForumDirectoryTemplate,
47 49 CommunityTemplate,
48 50 CategoryTemplate,
@@ -83,6 +83,12 @@ pub struct PostRow {
83 83 pub endorsement_count: u32,
84 84 pub is_endorsed: bool,
85 85 pub can_endorse: bool,
86 + /// Whether to show the `+` badge next to the author's name. True only for
87 + /// Fan+ subscribers — creators don't get the badge per platform spec.
88 + pub author_has_plus_badge: bool,
89 + /// Signature HTML to render below the post body. `None` if the author
90 + /// hasn't set one or has lost Fan+.
91 + pub author_signature_html: Option<String>,
86 92 }
87 93
88 94 // ============================================================================
@@ -115,6 +121,43 @@ impl Pagination {
115 121 // Page templates
116 122 // ============================================================================
117 123
124 + /// Admin community detail page — state controls + clean-slate.
125 + #[derive(Template)]
126 + #[template(path = "pages/admin_community.html")]
127 + pub struct AdminCommunityTemplate {
128 + pub csrf_token: CsrfTokenOption,
129 + pub session_user: Option<TemplateSessionUser>,
130 + pub mnw_base_url: std::sync::Arc<str>,
131 + pub community_name: String,
132 + pub community_slug: String,
133 + /// Current state as a snake_case string (`active`/`restricted`/`frozen`/`archived`).
134 + pub current_state: &'static str,
135 + pub thread_count: i64,
136 + pub member_count: i64,
137 + pub is_suspended: bool,
138 + pub suspension_reason: Option<String>,
139 + }
140 +
141 + /// Account settings — Fan+ signature editor and perk status.
142 + #[derive(Template)]
143 + #[template(path = "pages/account.html")]
144 + pub struct AccountSettingsTemplate {
145 + pub csrf_token: CsrfTokenOption,
146 + pub session_user: Option<TemplateSessionUser>,
147 + pub mnw_base_url: std::sync::Arc<str>,
148 + /// Whether the viewer has Fan+ perks (incl. creator auto-grant). Drives
149 + /// whether the signature form is editable or shows the upsell.
150 + pub has_plus: bool,
151 + /// Direct Fan+ subscription (distinct from creator auto-grant).
152 + pub fan_plus: bool,
153 + /// Currently saved signature markdown (None if unset).
154 + pub signature_markdown: Option<String>,
155 + /// Rendered preview of the saved signature.
156 + pub signature_html: Option<String>,
157 + /// Server-side validation error to surface above the form.
158 + pub error: Option<String>,
159 + }
160 +
118 161 /// Forum directory — lists local communities.
119 162 #[derive(Template)]
120 163 #[template(path = "pages/forum_directory.html")]
@@ -124,6 +167,8 @@ pub struct ForumDirectoryTemplate {
124 167 pub mnw_base_url: std::sync::Arc<str>,
125 168 pub communities: Vec<CommunityDirectoryRow>,
126 169 pub pagination: Pagination,
170 + /// True when viewing the archived-only listing (`?filter=archived`).
171 + pub viewing_archived: bool,
127 172 }
128 173
129 174 /// Project forum — category table within a single project.
@@ -0,0 +1,65 @@
1 + {% extends "base.html" %}
2 +
3 + {% block title %}Account — Multithreaded{% endblock %}
4 +
5 + {% block head %}<meta name="robots" content="noindex">{% endblock %}
6 +
7 + {% block header %}{% include "partials/site_header.html" %}{% endblock %}
8 +
9 + {% block content %}
10 + <div class="container">
11 + <h2 class="section-heading">Account</h2>
12 +
13 + <div class="settings-section">
14 + <h3>Signature</h3>
15 + <p class="form-help">
16 + A short markdown note rendered below every post you make. Fan+ subscribers may use
17 + image embeds; otherwise text and links only.
18 + </p>
19 +
20 + {% if let Some(err) = error %}
21 + <div class="form-error">{{ err }}</div>
22 + {% endif %}
23 +
24 + {% if has_plus %}
25 + <form method="post" action="/account/signature" class="form-container">
26 + <div class="form-group">
27 + <label for="signature">Signature (markdown, up to 1024 characters)</label>
28 + <textarea id="signature" name="signature" class="textarea-medium" maxlength="1024">{{ signature_markdown.as_deref().unwrap_or("") }}</textarea>
29 + </div>
30 + <div class="form-actions">
31 + <button type="submit" class="primary">Save signature</button>
32 + {% if signature_markdown.is_some() %}
33 + <button type="submit" name="clear" value="1" class="secondary">Clear signature</button>
34 + {% endif %}
35 + </div>
36 + </form>
37 +
38 + {% if let Some(html) = signature_html %}
39 + <h4 class="subsection-heading">Preview</h4>
40 + <div class="post-signature">
41 + {{ html|safe }}
42 + </div>
43 + {% endif %}
44 + {% else %}
45 + <div class="form-help">
46 + Signatures are a Fan+ feature. <a href="{{ mnw_base_url }}/fan-plus">Learn more</a>.
47 + </div>
48 + {% endif %}
49 + </div>
50 +
51 + <div class="settings-section">
52 + <h3>Status</h3>
53 + <ul class="account-status">
54 + <li>Fan+ subscription: <strong>{% if fan_plus %}active{% else %}inactive{% endif %}</strong></li>
55 + <li>+ perks available: <strong>{% if has_plus %}yes{% else %}no{% endif %}</strong></li>
56 + </ul>
57 + <form method="post" action="/auth/refresh" class="form-inline">
58 + <button type="submit" class="secondary">Refresh from MNW</button>
59 + </form>
60 + <span class="form-help">
61 + Hit refresh after subscribing or upgrading on makenot.work to see changes here.
62 + </span>
63 + </div>
64 + </div>
65 + {% endblock %}
@@ -58,7 +58,7 @@
58 58 {% for post in posts %}
59 59 <div class="post-item{% if post.is_op %} op{% endif %}{% if post.is_removed %} post-removed{% endif %}" id="post-{{ post.id }}" data-post-id="{{ post.id }}">
60 60 <div class="post-header">
61 - <a href="/p/{{ community_slug }}/u/{{ post.author_username }}" class="post-author">{{ post.author_name }}</a>
61 + <a href="/p/{{ community_slug }}/u/{{ post.author_username }}" class="post-author">{{ post.author_name }}</a>{% if post.author_has_plus_badge %}<span class="badge badge-plus" title="Fan+ subscriber">+</span>{% endif %}
62 62 <span>
63 63 <span class="post-timestamp">{{ post.timestamp }}</span>
64 64 {% if !post.is_removed %}
@@ -97,6 +97,11 @@
97 97 <div class="post-body">
98 98 {{ post.body_html|safe }}
99 99 </div>
100 + {% if let Some(sig_html) = post.author_signature_html %}
101 + <div class="post-signature">
102 + {{ sig_html|safe }}
103 + </div>
104 + {% endif %}
100 105 {% if !post.link_previews.is_empty() %}
101 106 <div class="post-link-previews">
102 107 {% for lp in post.link_previews %}
@@ -14,6 +14,7 @@
14 14 {% if let Some(user) = session_user %}
15 15 <a href="{{ mnw_base_url }}/feed">Feed</a>
16 16 <a href="{{ mnw_base_url }}/u/{{ user.username }}">{{ user.username }}</a>
17 + <a href="/account">Account</a>
17 18 <a href="{{ mnw_base_url }}/dashboard">Dashboard</a>
18 19 {% if user.is_platform_admin %}<a href="/_admin">Admin</a>{% endif %}
19 20 <form method="post" action="/auth/logout" style="display:inline"><button type="submit" class="link-button" aria-label="Log out">Log Out</button></form>
@@ -19,8 +19,21 @@ pub struct TestHarness {
19 19 _test_db: TestDb,
20 20 }
21 21
22 + /// Options for customizing a [`TestHarness`].
23 + #[derive(Default)]
24 + pub struct HarnessOptions {
25 + pub platform_admin_id: Option<Uuid>,
26 + /// Override MNW base URL — point at a wiremock server for tests that exercise
27 + /// OAuth/userinfo flows. Defaults to a black-hole URL that fails fast.
28 + pub mnw_base_url: Option<String>,
29 + }
30 +
22 31 impl TestHarness {
23 32 pub async fn new() -> Self {
33 + Self::with_options(HarnessOptions::default()).await
34 + }
35 +
36 + pub async fn with_options(opts: HarnessOptions) -> Self {
24 37 let test_db = TestDb::new().await;
25 38 let pool = test_db.pool.clone();
26 39
@@ -38,10 +51,14 @@ impl TestHarness {
38 51 ));
39 52
40 53 let config = Config {
41 - mnw_base_url: "http://127.0.0.1:9999".into(),
54 + mnw_base_url: opts
55 + .mnw_base_url
56 + .as_deref()
57 + .unwrap_or("http://127.0.0.1:9999")
58 + .into(),
42 59 oauth_client_id: "test-client-id".to_string(),
43 60 oauth_redirect_uri: "http://127.0.0.1:3400/auth/callback".to_string(),
44 - platform_admin_id: None,
61 + platform_admin_id: opts.platform_admin_id,
45 62 cookie_secure: false,
46 63 s3: None,
47 64 internal_shared_secret: None,
@@ -159,61 +176,11 @@ impl TestHarness {
159 176
160 177 /// Create a harness with a specific platform admin user ID.
161 178 pub async fn new_with_admin(admin_id: Uuid) -> Self {
162 - let harness = Self::new().await;
163 - // Rebuild with admin config — we need to reconstruct the app
164 - // because Config is cloned into AppState.
165 - drop(harness);
166 -
167 - let test_db = TestDb::new().await;
168 - let pool = test_db.pool.clone();
169 -
170 - let session_store = PostgresStore::new(pool.clone());
171 - session_store
172 - .migrate()
173 - .await
174 - .expect("Failed to migrate session store");
175 -
176 - let session_layer = SessionManagerLayer::new(session_store)
177 - .with_secure(false)
178 - .with_same_site(SameSite::Lax)
179 - .with_expiry(Expiry::OnInactivity(
180 - tower_sessions::cookie::time::Duration::days(1),
181 - ));
182 -
183 - let config = Config {
184 - mnw_base_url: "http://127.0.0.1:9999".into(),
185 - oauth_client_id: "test-client-id".to_string(),
186 - oauth_redirect_uri: "http://127.0.0.1:3400/auth/callback".to_string(),
179 + Self::with_options(HarnessOptions {
187 180 platform_admin_id: Some(admin_id),
188 - cookie_secure: false,
189 - s3: None,
190 - internal_shared_secret: None,
191 - };
192 -
193 - let state = AppState {
194 - db: pool.clone(),
195 - config,
196 - http: reqwest::Client::new(),
197 - preview_http: multithreaded::link_preview::build_preview_client(),
198 - s3: None,
199 - };
200 -
201 - let test_login = axum::Router::new()
202 - .route("/_test/login", axum::routing::post(test_login_handler))
203 - .with_state(state.clone());
204 -
205 - let app = routes::forum_routes(state)
206 - .merge(test_login)
207 - .layer(axum::middleware::from_fn(csrf::csrf_middleware))
208 - .layer(session_layer);
209 -
210 - let client = TestClient::new(app);
211 -
212 - TestHarness {
213 - client,
214 - db: pool,
215 - _test_db: test_db,
216 - }
181 + ..Default::default()
182 + })
183 + .await
217 184 }
218 185
219 186 /// Ban a user in a community via direct SQL.
@@ -260,6 +227,9 @@ impl TestHarness {
260 227 }
261 228
262 229 /// Handler for `POST /_test/login` — sets session keys without OAuth.
230 + ///
231 + /// Accepts optional `access_token` and `perks` (JSON object) fields to seed the
232 + /// fields normally populated by the OAuth callback.
263 233 async fn test_login_handler(
264 234 session: tower_sessions::Session,
265 235 axum::Json(payload): axum::Json<serde_json::Value>,
@@ -275,5 +245,12 @@ async fn test_login_handler(
275 245 let _ = session.insert("user_id", user_id).await;
276 246 let _ = session.insert("username", username).await;
277 247
248 + if let Some(token) = payload.get("access_token").and_then(|v| v.as_str()) {
249 + let _ = session.insert("mnw_access_token", token).await;
250 + }
251 + if let Some(perks) = payload.get("perks") {
252 + let _ = session.insert("perks", perks).await;
253 + }
254 +
278 255 axum::http::StatusCode::OK
279 256 }
@@ -1,5 +1,7 @@
1 - use crate::harness::TestHarness;
1 + use crate::harness::{HarnessOptions, TestHarness};
2 2 use axum::http::StatusCode;
3 + use wiremock::matchers::{header, method, path};
4 + use wiremock::{Mock, MockServer, ResponseTemplate};
3 5
4 6 #[tokio::test]
5 7 async fn unauthenticated_sees_login_link() {
@@ -176,6 +178,172 @@ async fn callback_with_correct_state_fails_at_token_exchange() {
176 178 );
177 179 }
178 180
181 + // ── Perks refresh (`POST /auth/refresh`) ──
182 + //
183 + // Refresh re-hits MNW's `/oauth/userinfo` using the cached access token and
184 + // overwrites the session's `perks`. These tests use wiremock to stand in for
185 + // MNW.
186 +
187 + /// Spin up a TestHarness pointed at a wiremock MNW. Returns both so individual
188 + /// tests can register response expectations on the mock.
189 + async fn harness_with_mock_mnw() -> (TestHarness, MockServer) {
190 + let mock = MockServer::start().await;
191 + let h = TestHarness::with_options(HarnessOptions {
192 + mnw_base_url: Some(mock.uri()),
193 + ..Default::default()
194 + })
195 + .await;
196 + (h, mock)
197 + }
198 +
199 + /// Log in via the test harness and seed access token + perks into the session.
200 + async fn login_with_token(h: &mut TestHarness, username: &str, token: &str, perks: serde_json::Value) -> uuid::Uuid {
201 + let user_id = uuid::Uuid::new_v4();
202 + sqlx::query(
203 + "INSERT INTO users (mnw_account_id, username, display_name) \
204 + VALUES ($1, $2, $2) ON CONFLICT (mnw_account_id) DO NOTHING",
205 + )
206 + .bind(user_id)
207 + .bind(username)
208 + .execute(&h.db)
209 + .await
210 + .expect("insert test user");
211 + h.client.get("/").await;
212 + let body = serde_json::json!({
213 + "user_id": user_id.to_string(),
214 + "username": username,
215 + "access_token": token,
216 + "perks": perks,
217 + });
218 + h.client.post_json("/_test/login", &body.to_string()).await;
219 + user_id
220 + }
221 +
222 + #[tokio::test]
223 + async fn refresh_updates_perks_from_mnw() {
224 + let (mut h, mock) = harness_with_mock_mnw().await;
225 + let user_id = login_with_token(
226 + &mut h,
227 + "refreshuser",
228 + "fake-token",
229 + serde_json::json!({ "fan_plus": false, "is_creator": false }),
230 + )
231 + .await;
232 +
233 + Mock::given(method("GET"))
234 + .and(path("/oauth/userinfo"))
235 + .and(header("authorization", "Bearer fake-token"))
236 + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
237 + "user_id": user_id,
238 + "username": "refreshuser",
239 + "display_name": "Refresh User",
240 + "avatar_url": null,
241 + "perks": {
242 + "fan_plus": true,
243 + "is_creator": false,
244 + "creator_tier": null,
245 + },
246 + })))
247 + .expect(1)
248 + .mount(&mock)
249 + .await;
250 +
251 + let resp = h.client.post_form("/auth/refresh", "").await;
252 + assert_eq!(resp.status, StatusCode::OK, "body: {}", resp.text);
253 + let body: serde_json::Value = serde_json::from_str(&resp.text).expect("json body");
254 + assert_eq!(body["perks"]["fan_plus"], true);
255 + assert_eq!(body["perks"]["is_creator"], false);
256 + }
257 +
258 + #[tokio::test]
259 + async fn refresh_returns_creator_tier_features() {
260 + let (mut h, mock) = harness_with_mock_mnw().await;
261 + let user_id = login_with_token(
262 + &mut h,
263 + "creatoruser",
264 + "creator-token",
265 + serde_json::json!({}),
266 + )
267 + .await;
268 +
269 + Mock::given(method("GET"))
270 + .and(path("/oauth/userinfo"))
271 + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
272 + "user_id": user_id,
273 + "username": "creatoruser",
274 + "display_name": null,
275 + "avatar_url": null,
276 + "perks": {
277 + "fan_plus": false,
278 + "is_creator": true,
279 + "creator_tier": { "tier": "big_files", "features": ["file_uploads", "large_files"] },
280 + },
281 + })))
282 + .mount(&mock)
283 + .await;
284 +
285 + let resp = h.client.post_form("/auth/refresh", "").await;
286 + assert_eq!(resp.status, StatusCode::OK);
287 + let body: serde_json::Value = serde_json::from_str(&resp.text).unwrap();
288 + assert_eq!(body["perks"]["is_creator"], true);
289 + assert_eq!(body["perks"]["creator_tier"]["tier"], "big_files");
290 + let features = body["perks"]["creator_tier"]["features"].as_array().expect("features array");
291 + assert!(features.iter().any(|f| f == "file_uploads"));
292 + assert!(features.iter().any(|f| f == "large_files"));
293 + }
294 +
295 + #[tokio::test]
296 + async fn refresh_unauthorized_flushes_session() {
297 + let (mut h, mock) = harness_with_mock_mnw().await;
298 + login_with_token(&mut h, "expireduser", "stale-token", serde_json::json!({})).await;
299 +
300 + Mock::given(method("GET"))
301 + .and(path("/oauth/userinfo"))
302 + .respond_with(ResponseTemplate::new(401).set_body_json(serde_json::json!({"error": "invalid_token"})))
303 + .mount(&mock)
304 + .await;
305 +
306 + let resp = h.client.post_form("/auth/refresh", "").await;
307 + assert_eq!(resp.status, StatusCode::UNAUTHORIZED);
308 +
309 + // Session should be flushed — the home page now shows the login link, not the username.
310 + let resp = h.client.get("/").await;
311 + assert!(resp.text.contains("Login"), "expected login link after session flush");
312 + assert!(
313 + !resp.text.contains("expireduser"),
314 + "username should not appear after flush"
315 + );
316 + }
317 +
318 + #[tokio::test]
319 + async fn refresh_without_session_returns_401() {
320 + let mut h = TestHarness::new().await;
321 + let resp = h.client.post_form("/auth/refresh", "").await;
322 + assert_eq!(resp.status, StatusCode::UNAUTHORIZED);
323 + }
324 +
325 + #[tokio::test]
326 + async fn refresh_on_mnw_5xx_returns_bad_gateway() {
327 + let (mut h, mock) = harness_with_mock_mnw().await;
328 + login_with_token(&mut h, "transientuser", "any-token", serde_json::json!({})).await;
329 +
330 + Mock::given(method("GET"))
331 + .and(path("/oauth/userinfo"))
332 + .respond_with(ResponseTemplate::new(503))
333 + .mount(&mock)
334 + .await;
335 +
336 + let resp = h.client.post_form("/auth/refresh", "").await;
337 + assert_eq!(resp.status, StatusCode::BAD_GATEWAY);
338 +
339 + // Session should still be valid — 5xx is transient.
340 + let resp = h.client.get("/").await;
341 + assert!(
342 + resp.text.contains("transientuser"),
343 + "session should survive transient MNW error"
344 + );
345 + }
346 +
179 347 #[tokio::test]
180 348 async fn suspended_user_sees_error_page() {
181 349 let mut h = TestHarness::new().await;