Skip to main content

max / makenotwork

Add /oauth/userinfo perks object for external implementers The "Log in with MNW" entitlement contract for downstream services (Multithreaded today, more to come). Implementers read the perks struct instead of reverse-engineering tier behavior from MNW source. - /oauth/userinfo now returns: perks { fan_plus: bool, is_creator: bool, creator_tier: { tier, features: [&str] } | null, } - CreatorTier::features() exposes the live capability strings. Strings (not tier names) so callers don't break when the tier lineup shifts. - docs/oauth_integration.md is the canonical contract. - 136 lines of workflow tests pin the userinfo response shape per tier. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Author: Max J. <87768334+MaxJMath@users.noreply.github.com> · 2026-05-15 16:18 UTC
Commit: 55e72a7470a65082d61d8127e0e9f1e791c91bf4
Parent: 9e8b242
4 files changed, +318 insertions, -1 deletion
@@ -0,0 +1,116 @@
1 + # OAuth Integration Guide for External Implementers
2 +
3 + How to integrate "Log in with MNW" into an external service (Multithreaded, future MNW-integrated services).
4 +
5 + This document is the contract. Implementers should not reverse-engineer entitlement logic from MNW source code — read the `perks` object and the rules below.
6 +
7 + ---
8 +
9 + ## Flow
10 +
11 + Standard OAuth 2.0 Authorization Code + PKCE (RFC 7636). See `src/routes/oauth.rs` for the server side.
12 +
13 + 1. Redirect the user to `GET /oauth/authorize?response_type=code&client_id=...&redirect_uri=...&state=...&code_challenge=...&code_challenge_method=S256`.
14 + 2. User authenticates with MNW, gets redirected to your `redirect_uri` with `?code=...&state=...`.
15 + 3. Exchange the code at `POST /oauth/token` (form-encoded): `grant_type=authorization_code&code=...&redirect_uri=...&code_verifier=...&client_id=...`.
16 + 4. Use the returned `access_token` as a Bearer token on subsequent requests.
17 +
18 + ---
19 +
20 + ## `GET /oauth/userinfo`
21 +
22 + Canonical "what is this user entitled to" endpoint. Always returns fresh state from the MNW database — no MNW-side caching.
23 +
24 + **Auth:** `Authorization: Bearer <access_token>`
25 +
26 + **Response (200):**
27 + ```json
28 + {
29 + "user_id": "uuid",
30 + "username": "alice",
31 + "display_name": "Alice Example",
32 + "avatar_url": "https://...",
33 + "perks": {
34 + "fan_plus": false,
35 + "is_creator": true,
36 + "creator_tier": {
37 + "tier": "big_files",
38 + "features": ["file_uploads", "large_files"]
39 + }
40 + }
41 + }
42 + ```
43 +
44 + **Errors:**
45 + - `401 invalid_token` — missing, malformed, or revoked access token.
46 + - `401 user_not_found` — token valid but user deactivated/deleted.
47 +
48 + ---
49 +
50 + ## The `perks` contract
51 +
52 + `perks` is the extension point. Implementers consume the fields they care about. New capabilities are added here as they ship — old fields are not renamed or removed without coordination.
53 +
54 + ### `fan_plus: bool`
55 +
56 + True iff the user has an active Fan+ consumer subscription (`fan_plus_subscriptions.status = 'active'`).
57 +
58 + ### `is_creator: bool`
59 +
60 + True iff the user has an active creator subscription at any tier. Equivalent to `creator_tier != null` and provided for ergonomic boolean checks.
61 +
62 + ### `creator_tier: { tier, features } | null`
63 +
64 + `null` when the user is not a creator. Otherwise:
65 +
66 + - `tier`: snake-cased tier name (`"basic" | "small_files" | "big_files" | "everything"`). Implementers **should not** gate features on this string — gate on `features` instead. The tier names exist for display and analytics.
67 + - `features`: array of capability strings backed by live platform behavior. Today: `"file_uploads"` (SmallFiles+), `"large_files"` (BigFiles+). New capabilities (e.g., `"live_streaming"`) are added when they actually launch — never as "coming soon" placeholders.
68 +
69 + ### Why structured, not flat booleans
70 +
71 + A flat `creator_tier: "big_files"` would force every implementer to memorize the tier lineup. Adding a new tier (or splitting an existing one) would break callers. The structured form means implementers gate on capabilities, and the platform owns the mapping.
72 +
73 + ---
74 +
75 + ## Refresh ergonomics
76 +
77 + Implementers cache `perks` per session, not per request. State changes (Fan+ subscribe, tier upgrade, cancellation) become visible only after refresh.
78 +
79 + **Refresh on:**
80 +
81 + 1. Login (initial `userinfo` call after token exchange).
82 + 2. Session cycle / token refresh.
83 + 3. **On demand** — when the user takes an action that should have changed perks. Example: after returning from a Fan+ checkout flow. Hit `userinfo` again and overwrite cached fields.
84 +
85 + There is no push notification of perk changes. If pull-on-demand isn't sufficient, an `/internal/perks-webhooks/register` API will be added — talk to MNW maintainers before relying on stale data.
86 +
87 + ---
88 +
89 + ## Recommended implementer pattern (Rust)
90 +
91 + ```rust
92 + struct CachedSession {
93 + user_id: Uuid,
94 + username: String,
95 + perks: Perks,
96 + fetched_at: DateTime<Utc>,
97 + }
98 +
99 + // One place that calls /oauth/userinfo and overwrites cached perks.
100 + async fn refresh_session(session_id: &str) -> Result<()> { /* … */ }
101 +
102 + // Authorization check used everywhere.
103 + fn effective_plus(perks: &Perks) -> bool {
104 + perks.fan_plus || perks.is_creator
105 + }
106 + ```
107 +
108 + Every gated route reads `effective_plus(&session.perks)`. Refresh-on-demand routes call `refresh_session` first.
109 +
110 + ---
111 +
112 + ## Stability rules
113 +
114 + - **Additive only.** New `perks` fields are non-breaking; missing fields default to "absent" (false / null / empty array).
115 + - **No renames.** Once a `features` string ships, it stays. Mistakes are deprecated, not deleted.
116 + - **No silent semantics changes.** Behavior changes to existing capability strings (e.g., raising the size threshold for `large_files`) are coordinated with implementers.
@@ -536,6 +536,20 @@ impl CreatorTier {
536 536 pub fn allows_file_uploads(&self) -> bool {
537 537 !matches!(self, Self::Basic)
538 538 }
539 +
540 + /// Capability strings exposed to external OAuth implementers via `/oauth/userinfo`.
541 + ///
542 + /// Implementers gate features on these strings rather than tier names so the
543 + /// tier lineup can change without breaking callers. Only ship strings backed by
544 + /// live behavior; new capabilities are added when they actually launch.
545 + pub fn features(&self) -> &'static [&'static str] {
546 + match self {
547 + Self::Basic => &[],
548 + Self::SmallFiles => &["file_uploads"],
549 + Self::BigFiles => &["file_uploads", "large_files"],
550 + Self::Everything => &["file_uploads", "large_files"],
551 + }
552 + }
539 553 }
540 554
541 555 // ── App Sync Tiers ──
@@ -1233,6 +1247,14 @@ mod tests {
1233 1247 }
1234 1248
1235 1249 #[test]
1250 + fn creator_tier_features_track_live_capabilities() {
1251 + assert!(CreatorTier::Basic.features().is_empty());
1252 + assert_eq!(CreatorTier::SmallFiles.features(), &["file_uploads"]);
1253 + assert_eq!(CreatorTier::BigFiles.features(), &["file_uploads", "large_files"]);
1254 + assert_eq!(CreatorTier::Everything.features(), &["file_uploads", "large_files"]);
1255 + }
1256 +
1257 + #[test]
1236 1258 fn app_sync_tier_round_trip() {
1237 1259 assert_eq!(AppSyncTier::Standard.to_string(), "standard");
1238 1260 assert_eq!("light".parse::<AppSyncTier>().unwrap(), AppSyncTier::Light);
@@ -21,7 +21,7 @@ use crate::{
21 21 auth::{verify_password, MaybeUser, SESSION_TRACKING_KEY},
22 22 constants::{self, LOCKOUT_MINUTES, MAX_LOGIN_ATTEMPTS},
23 23 csrf,
24 - db::{self, SyncAppId, UserId, UserSessionId, Username},
24 + db::{self, CreatorTier, SyncAppId, UserId, UserSessionId, Username},
25 25 error::{AppError, Result},
26 26 synckit_auth,
27 27 templates::OAuthAuthorizeTemplate,
@@ -485,6 +485,13 @@ fn base64_url_nopad_encode(data: &[u8]) -> String {
485 485 }
486 486
487 487 // ── GET /oauth/userinfo ──
488 + //
489 + // Canonical "what is this user entitled to on MNW" endpoint for external
490 + // implementers of "Log in with MNW". Always returns fresh state from the
491 + // database — implementers cache client-side and pull-refresh on demand.
492 + //
493 + // The `perks` object is the extension point: new capabilities are added here
494 + // (and to `CreatorTier::features`) as they ship. See `docs/oauth_integration.md`.
488 495
489 496 #[derive(Serialize)]
490 497 struct UserinfoResponse {
@@ -492,6 +499,23 @@ struct UserinfoResponse {
492 499 username: String,
493 500 display_name: Option<String>,
494 501 avatar_url: Option<String>,
502 + perks: UserPerks,
503 + }
504 +
505 + #[derive(Serialize)]
506 + struct UserPerks {
507 + /// Active Fan+ consumer subscription.
508 + fan_plus: bool,
509 + /// Has an active creator subscription at any tier.
510 + is_creator: bool,
511 + /// Structured creator tier info, present when `is_creator` is true.
512 + creator_tier: Option<CreatorTierInfo>,
513 + }
514 +
515 + #[derive(Serialize)]
516 + struct CreatorTierInfo {
517 + tier: CreatorTier,
518 + features: &'static [&'static str],
495 519 }
496 520
497 521 #[tracing::instrument(skip_all, name = "oauth::userinfo")]
@@ -519,11 +543,30 @@ async fn userinfo(
519 543 }
520 544 };
521 545
546 + let fan_plus = db::fan_plus::is_fan_plus_active(&state.db, db_user.id)
547 + .await
548 + .unwrap_or(false);
549 +
550 + let creator_tier = db_user
551 + .creator_tier
552 + .as_deref()
553 + .and_then(|s| s.parse::<CreatorTier>().ok());
554 +
555 + let perks = UserPerks {
556 + fan_plus,
557 + is_creator: creator_tier.is_some(),
558 + creator_tier: creator_tier.map(|tier| CreatorTierInfo {
559 + tier,
560 + features: tier.features(),
561 + }),
562 + };
563 +
522 564 Json(UserinfoResponse {
523 565 user_id: db_user.id,
524 566 username: db_user.username.to_string(),
525 567 display_name: db_user.display_name,
526 568 avatar_url: db_user.avatar_url,
569 + perks,
527 570 }).into_response()
528 571 }
529 572
@@ -294,3 +294,139 @@ async fn oauth_invalid_credentials() {
294 294 resp.text
295 295 );
296 296 }
297 +
298 + // ── Userinfo (`/oauth/userinfo`) ──
299 + //
300 + // `userinfo` is the canonical entitlement endpoint for external "Log in with MNW"
301 + // implementers. Tests cover the `perks` contract: shape on a fresh user, on a
302 + // creator, and on a Fan+ subscriber.
303 +
304 + /// Run the full authorize → token flow and return the Bearer access token.
305 + async fn obtain_access_token(h: &mut TestHarness, username: &str, password: &str) -> String {
306 + let user_id = sqlx::query_scalar::<_, UserId>("SELECT id FROM users WHERE username = $1")
307 + .bind(username)
308 + .fetch_one(&h.db)
309 + .await
310 + .expect("user lookup");
311 +
312 + let (_app_id, client_id) = create_sync_app(&h.db, user_id).await;
313 + let (verifier, challenge) = generate_pkce();
314 +
315 + h.client.post_form("/logout", "").await;
316 + let (code, _state) = authorize(h, &client_id, &challenge, username, password).await;
317 +
318 + let resp = h
319 + .client
320 + .post_form(
321 + "/oauth/token",
322 + &format!(
323 + "grant_type=authorization_code&code={}&redirect_uri={}&code_verifier={}&client_id={}",
324 + code,
325 + urlencoding::encode("http://127.0.0.1:9999/callback"),
326 + verifier,
327 + client_id,
328 + ),
329 + )
330 + .await;
331 + assert_eq!(resp.status.as_u16(), 200, "Token exchange failed: {}", resp.text);
332 + let token: TokenResponse = resp.json();
333 + token.access_token
334 + }
335 +
336 + #[derive(Deserialize)]
337 + struct UserinfoResp {
338 + user_id: UserId,
339 + username: String,
340 + display_name: Option<String>,
341 + avatar_url: Option<String>,
342 + perks: PerksResp,
343 + }
344 +
345 + #[derive(Deserialize)]
346 + struct PerksResp {
347 + fan_plus: bool,
348 + is_creator: bool,
349 + creator_tier: Option<CreatorTierResp>,
350 + }
351 +
352 + #[derive(Deserialize)]
353 + struct CreatorTierResp {
354 + tier: String,
355 + features: Vec<String>,
356 + }
357 +
358 + #[tokio::test]
359 + async fn oauth_userinfo_default() {
360 + let mut h = TestHarness::new().await;
361 + let user_id = h.signup("uinfo_def", "uinfo_def@test.com", "Password1!").await;
362 +
363 + let token = obtain_access_token(&mut h, "uinfo_def", "Password1!").await;
364 + h.client.set_bearer_token(&token);
365 + let resp = h.client.get("/oauth/userinfo").await;
366 + assert_eq!(resp.status.as_u16(), 200, "userinfo failed: {}", resp.text);
367 +
368 + let info: UserinfoResp = resp.json();
369 + assert_eq!(info.user_id, user_id);
370 + assert_eq!(info.username, "uinfo_def");
371 + assert!(info.display_name.is_none() || info.display_name.as_deref() == Some(""));
372 + let _ = info.avatar_url;
373 + assert!(!info.perks.fan_plus);
374 + assert!(!info.perks.is_creator);
375 + assert!(info.perks.creator_tier.is_none());
376 + }
377 +
378 + #[tokio::test]
379 + async fn oauth_userinfo_creator_tier() {
380 + let mut h = TestHarness::new().await;
381 + let user_id = h.signup("uinfo_creator", "uinfo_creator@test.com", "Password1!").await;
382 + sqlx::query("UPDATE users SET creator_tier = 'big_files' WHERE id = $1")
383 + .bind(user_id)
384 + .execute(&h.db)
385 + .await
386 + .expect("set tier");
387 +
388 + let token = obtain_access_token(&mut h, "uinfo_creator", "Password1!").await;
389 + h.client.set_bearer_token(&token);
390 + let resp = h.client.get("/oauth/userinfo").await;
391 + assert_eq!(resp.status.as_u16(), 200);
392 +
393 + let info: UserinfoResp = resp.json();
394 + assert!(info.perks.is_creator);
395 + assert!(!info.perks.fan_plus);
396 + let tier = info.perks.creator_tier.expect("creator_tier populated");
397 + assert_eq!(tier.tier, "big_files");
398 + assert!(tier.features.iter().any(|f| f == "file_uploads"));
399 + assert!(tier.features.iter().any(|f| f == "large_files"));
400 + }
401 +
402 + #[tokio::test]
403 + async fn oauth_userinfo_fan_plus() {
404 + let mut h = TestHarness::new().await;
405 + let user_id = h.signup("uinfo_fp", "uinfo_fp@test.com", "Password1!").await;
406 + sqlx::query(
407 + "INSERT INTO fan_plus_subscriptions (user_id, stripe_subscription_id, stripe_customer_id, status) \
408 + VALUES ($1, 'sub_uinfo_fp', 'cus_uinfo_fp', 'active')",
409 + )
410 + .bind(user_id)
411 + .execute(&h.db)
412 + .await
413 + .expect("seed fan_plus");
414 +
415 + let token = obtain_access_token(&mut h, "uinfo_fp", "Password1!").await;
416 + h.client.set_bearer_token(&token);
417 + let resp = h.client.get("/oauth/userinfo").await;
418 + assert_eq!(resp.status.as_u16(), 200);
419 +
420 + let info: UserinfoResp = resp.json();
421 + assert!(info.perks.fan_plus);
422 + assert!(!info.perks.is_creator);
423 + assert!(info.perks.creator_tier.is_none());
424 + }
425 +
426 + #[tokio::test]
427 + async fn oauth_userinfo_unauthorized() {
428 + let mut h = TestHarness::new().await;
429 + // No bearer token set — extractor rejects.
430 + let resp = h.client.get("/oauth/userinfo").await;
431 + assert_eq!(resp.status.as_u16(), 401);
432 + }