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, +1337 insertions, -164 deletions
@@ -87,6 +87,16 @@
87 87 "winnow",
88 88 ]
89 89
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 +
90 100 [[package]]
91 101 name = "async-trait"
92 102 version = "0.1.89"
@@ -984,6 +994,24 @@
984 994 source = "registry+https://github.com/rust-lang/crates.io-index"
985 995 checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea"
986 996
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 +
987 1015 [[package]]
988 1016 name = "der"
989 1017 version = "0.6.1"
@@ -1270,6 +1298,7 @@
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 @@
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",
@@ -1539,6 +1569,12 @@
1539 1569 source = "registry+https://github.com/rust-lang/crates.io-index"
1540 1570 checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
1541 1571
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 +
1542 1578 [[package]]
1543 1579 name = "hex"
1544 1580 version = "0.4.3"
@@ -2153,7 +2189,7 @@
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 @@
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 @@
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 @@
2226 2262 "tracing-subscriber",
2227 2263 "urlencoding",
2228 2264 "uuid",
2265 + "wiremock",
2229 2266 ]
2230 2267
2231 2268 [[package]]
@@ -2313,6 +2350,16 @@
2313 2350 "libm",
2314 2351 ]
2315 2352
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 +
2316 2363 [[package]]
2317 2364 name = "once_cell"
2318 2365 version = "1.21.4"
@@ -2764,6 +2811,18 @@
2764 2811 "bitflags",
2765 2812 ]
2766 2813
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 +
2767 2826 [[package]]
2768 2827 name = "regex-automata"
2769 2828 version = "0.4.14"
@@ -4569,6 +4628,29 @@
4569 4628 "memchr",
4570 4629 ]
4571 4630
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 +
4572 4654 [[package]]
4573 4655 name = "wit-bindgen"
4574 4656 version = "0.51.0"
@@ -7,7 +7,7 @@
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 @@
96 96
97 97 [dev-dependencies]
98 98 http-body-util = "0.1"
99 + wiremock = "0.6"
@@ -13,31 +13,47 @@
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 @@
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
@@ -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 @@
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 @@
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 @@
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 @@
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 @@
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");
314 - }
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");
319 - return Redirect::to("/?error=userinfo_fetch_failed");
320 - }
321 - Ok(r) => {
322 - userinfo_res = Some(r);
451 + match fetch_userinfo(&state.http, &state.config.mnw_base_url, &token.access_token).await {
452 + Ok(i) => {
453 + info = Some(i);
323 454 break;
324 455 }
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");
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");
463 + return Redirect::to("/?error=userinfo_fetch_failed");
464 + }
465 + Err(UserinfoError::Unauthorized) => {
466 + tracing::error!("userinfo unauthorized — token rejected");
467 + return Redirect::to("/?error=userinfo_fetch_failed");
468 + }
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 @@
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 @@
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 {