From 8260c5abcdae75a52d66ad9e584108533d984f9e Mon Sep 17 00:00:00 2001 From: Devin Zuczek Date: Mon, 31 Aug 2026 15:44:33 -0400 Subject: [PATCH] [plus] discord role verifier to grant RR plus --- .env.example | 27 ++ apps/auth/src/auth.app.ts | 13 +- apps/auth/src/openapi.ts | 25 +- apps/auth/src/platform-db.ts | 55 +++- apps/auth/src/test/integration/api.test.ts | 109 ++++++++ apps/econ/src/econ.app.ts | 90 +++--- apps/econ/src/openapi.ts | 5 +- apps/econ/src/test/integration/api.test.ts | 79 +++++- apps/www/README.md | 119 ++++++++ apps/www/package.json | 1 + apps/www/src/client/App.tsx | 303 ++++++++++++++++++++- apps/www/src/context.ts | 50 +++- apps/www/src/discord.ts | 297 ++++++++++++++++++++ apps/www/src/test/integration/api.test.ts | 255 +++++++++++++++++ apps/www/src/www.app.ts | 207 +++++++++++++- apps/www/wrangler.jsonc | 52 +++- packages/domain/src/accounts-db.ts | 24 ++ packages/domain/src/enums.ts | 33 +++ packages/domain/src/password.ts | 16 +- packages/jwt/src/index.ts | 1 + packages/jwt/src/jwt.ts | 41 ++- packages/tools/src/cmd/admin.cmd.ts | 39 ++- pnpm-lock.yaml | 3 + 23 files changed, 1746 insertions(+), 98 deletions(-) create mode 100644 apps/www/src/discord.ts diff --git a/.env.example b/.env.example index 52de3dc..dada82f 100644 --- a/.env.example +++ b/.env.example @@ -141,3 +141,30 @@ RECFLARE_DOMAIN=rec.example.com # Setting them both is what opens web signup; with either missing it stays closed. See # DEPLOYING.md. Accounts are still created by the game either way, and both `auth` account # caps above apply regardless. + +# The website's benefits claim (www `/claim`): a player proves a role in your Discord and +# gets Rec Room Plus. FOUR settings, and ALL FOUR are required — with any missing, the claim +# stays closed, `/api/config` reports `benefitsEnabled: false`, and the page and its nav link +# never appear. That is the usual reason "I set the secrets and nothing shows up". +# +# The two CREDENTIALS live in the Secrets Store, like the Turnstile pair above: +# +# wrangler secrets-store secret create --name DISCORD_CLIENT_ID \ +# --scopes workers --remote +# wrangler secrets-store secret create --name DISCORD_CLIENT_SECRET \ +# --scopes workers --remote +# +# The two IDS are plain vars, and go HERE — they are not secrets, and setting the secrets +# alone is not enough. Both are Discord snowflakes: all digits, no letters, copied with +# Developer Mode on (right-click the server or role -> Copy ID). Ids, not names. +# +# ROLE_IDS is a list and ANY one of them qualifies, so several tiers can share the benefit. +# SEPARATE THEM WITH COMMAS AND NO SPACES: these knobs are passed to wrangler as `--var` +# flags that are word-split, so a value containing a space silently breaks the deploy. (The +# worker itself also accepts whitespace, which is fine in wrangler.jsonc but not here.) +# RECFLARE_DISCORD_GUILD_ID=1077000000000000000 +# RECFLARE_DISCORD_BENEFITS_ROLE_IDS=1077000000000000001,1077000000000000002 +# +# Also add https:///claim to the app's Redirects in the Discord developer +# portal, or the exchange is refused. Granting Plus takes effect on the player's NEXT +# sign-in. `runx admin grant-plus` sets it directly, with no Discord involved. diff --git a/apps/auth/src/auth.app.ts b/apps/auth/src/auth.app.ts index e479c19..2a7d4dd 100644 --- a/apps/auth/src/auth.app.ts +++ b/apps/auth/src/auth.app.ts @@ -1040,8 +1040,9 @@ const app = new Hono() // Stamp the account's elevated roles into the token's `role` claim so the client // authorizes developer/moderator powers from the token itself (not just the - // /role/* lookups). One read of the just-resolved account; roles thus refresh on - // every login and every refresh_token grant. + // /role/* lookups), and its Plus flag into `rn.plus`. One read of the just-resolved + // account serves both; they thus refresh on every login and every refresh_token + // grant. const roleAccount = await getAccount(c.env.DB, Number(accountId)) // A refresh grant posts no platform of its own, so the identity comes off the // account — the same read, and the only place the bound identity is authoritative. @@ -1056,7 +1057,13 @@ const app = new Hono() jwtSecret, accountRoles(roleAccount), accountPrivileges(roleAccount), - version + version, + // Rec Room Plus, off the same account read as the roles above — `econ` decides the + // CampusCard and the subscriber discount from this claim alone, so it never has to + // load the account. It therefore refreshes on every login and every refresh_token + // grant, and only then: a player who claims Plus on the website keeps a token that + // says otherwise until they sign in again. + roleAccount?.hasPlus === true ) // Issue a fresh, persisted refresh token (single-use; the client redeems it via // grant_type=refresh_token). A refresh grant thus rotates its token. diff --git a/apps/auth/src/openapi.ts b/apps/auth/src/openapi.ts index b23c582..6dded37 100644 --- a/apps/auth/src/openapi.ts +++ b/apps/auth/src/openapi.ts @@ -1,6 +1,8 @@ import { resolver } from 'hono-openapi' import { z } from 'zod' +import { PlatformType } from '@repo/domain/src/enums' + import type { OpenAPIV3_1 } from 'openapi-types' /** @@ -49,24 +51,13 @@ export function form(schema: z.ZodType, description: string): OpenAPIV3_1.Reques } /** - * PlatformType, the client's platform enum. Declaration order is wire order, and is - * the single source for the schema and description below. The `platform` form field - * is posted as the integer; the token's `platform` claim carries the name. + * PlatformType, the client's platform enum — the single source for the schema and + * description below. It lives in `@repo/domain` rather than here because the link table + * (`platform-db`) and the website's benefits claim both need the values, and neither has + * any business importing this module's zod/hono-openapi dependencies. Re-exported so + * `import { PlatformType } from './openapi'` keeps working alongside the schemas. */ -export const PlatformType = { - All: -1, - Steam: 0, - Oculus: 1, - PlayStation: 2, - Xbox: 3, - RecNet: 4, - IOS: 5, - GooglePlay: 6, - Standalone: 7, - Pico: 8, -} as const - -export type PlatformType = (typeof PlatformType)[keyof typeof PlatformType] +export { PlatformType } from '@repo/domain/src/enums' /** * A PlatformType by value. Only Steam and Oculus (Meta) can actually be verified — diff --git a/apps/auth/src/platform-db.ts b/apps/auth/src/platform-db.ts index d4bf128..48f3ab2 100644 --- a/apps/auth/src/platform-db.ts +++ b/apps/auth/src/platform-db.ts @@ -19,8 +19,18 @@ * A link is only ever written from a VERIFIED identity (a Steam-signed ticket or a * Meta-validated nonce). It is what turns "this platform user" into "may enter this * account with no password", so an unproven `platform_id` must never reach it. + * + * Not every link is a LOGIN, though. The table is the account's set of external + * identities, and some are stored for what they entitle the player to rather than for + * entry: `PlatformType.Discord` (101) is written by the website's benefits claim, from an + * OAuth2 code exchange Discord itself vouched for. Nobody signs in with it — + * `verifyPlatformProof` answers `unsupported` for anything but Steam and Meta, so a + * `cached_login` naming platform 101 is refused — and the picker must not offer it + * either. See {@link CACHED_LOGIN_PLATFORMS}, which is what keeps those two in step. */ +import { PlatformType } from '@repo/domain/src/enums' + /** Schema DDL (mirror of migrations/0007_platform_accounts.sql, sans the backfill). */ export const PLATFORM_SCHEMA_DDL: string[] = [ `CREATE TABLE IF NOT EXISTS platform_account ( @@ -64,6 +74,38 @@ export const PLATFORM_BACKFILL_SQL = `INSERT OR IGNORE INTO platform_account (ac WHERE json_extract(data, '$.platformId') IS NOT NULL AND json_extract(data, '$.platformId') <> ''` +/** + * The platforms a cached login can actually be redeemed for — the ones + * `verifyPlatformProof` can prove, which is Steam (a Steam-signed ticket) and Meta/Oculus + * (a Meta-validated nonce). + * + * This is the picker's filter, and it exists to keep a promise the picker's own API + * documentation makes: "an entry here is always redeemable by a `cached_login` grant + * (both read the same table)". Once the table began holding identities that are NOT + * credentials — Discord, from the website's benefits claim — listing every row would have + * broken that promise in two ways at once. The client would be offered an account it can + * never log into (the grant refuses platform 101 outright), and, worse, the picker is + * PUBLIC and unauthenticated: `GET /cachedlogin/forplatformid/101/` would have + * told anyone which RecFlare account a given Discord user owns, and the bulk route would + * have done it for a list of them at once. A Discord id is trivially readable by anyone in + * a shared server, so that is a deanonymisation of every player who claimed benefits. + * + * Adding a platform here means asserting `verifyPlatformProof` can prove it. Filtering + * happens in the two picker reads only — {@link isPlatformIdentityLinked}, + * {@link countAccountsForPlatformIdentity} and {@link getLinksForAccount} deliberately see + * every link, because they answer "is this identity taken / whose is it", which is exactly + * the question the benefits claim's once-only guard asks about a Discord id. + */ +export const CACHED_LOGIN_PLATFORMS: readonly number[] = [PlatformType.Steam, PlatformType.Oculus] + +/** + * `IN (…)` fragment for the allowlist, so the filter is applied by the query rather than + * in JS. Placeholders are numbered from ?2 because the one caller binds the platform id + * as ?1 — explicit indices rather than bare `?`, which SQLite would number by position + * and quietly renumber the moment another parameter is added ahead of it. + */ +const CACHED_LOGIN_FILTER = `platform IN (${CACHED_LOGIN_PLATFORMS.map((_, i) => `?${i + 2}`).join(', ')})` + /** One account ↔ platform identity link. */ export interface PlatformLink { accountId: number @@ -119,6 +161,9 @@ export async function getLinksForPlatformIdentity( platformId: string ): Promise { if (platformId === '') return [] + // Asking about a platform nobody can log in from yields nothing at all, rather than a + // list the grant would refuse — see CACHED_LOGIN_PLATFORMS. + if (!CACHED_LOGIN_PLATFORMS.includes(platform)) return [] const { results } = await db .prepare( `${SELECT_LINK} WHERE platform = ?1 AND platform_id = ?2 ORDER BY linked_at, account_id` @@ -138,9 +183,15 @@ export async function getLinksForPlatformId( platformId: string ): Promise { if (platformId === '') return [] + // Matches on any platform a cached login can be redeemed for — but only those. This is + // the route a bare id takes, so without the filter a Discord snowflake posted here + // would resolve its account even though naming platform 101 explicitly would not. const { results } = await db - .prepare(`${SELECT_LINK} WHERE platform_id = ?1 ORDER BY linked_at, account_id`) - .bind(platformId) + .prepare( + `${SELECT_LINK} WHERE platform_id = ?1 AND ${CACHED_LOGIN_FILTER} + ORDER BY linked_at, account_id` + ) + .bind(platformId, ...CACHED_LOGIN_PLATFORMS) .all() return results } diff --git a/apps/auth/src/test/integration/api.test.ts b/apps/auth/src/test/integration/api.test.ts index 612cb9a..f2bd711 100644 --- a/apps/auth/src/test/integration/api.test.ts +++ b/apps/auth/src/test/integration/api.test.ts @@ -21,7 +21,9 @@ import { createReport, SCHEMA_DDL as REPORTS_SCHEMA_DDL, } from '../../../../api/src/reports-db' +import { PlatformType } from '../../openapi' import { + countAccountsForPlatformIdentity, getLinksForAccount, linkPlatformIdentity, PLATFORM_BACKFILL_SQL, @@ -421,6 +423,52 @@ describe('auth worker routes', () => { ]) }) + // A Discord link is an external identity, not a credential — `www`'s benefits claim + // writes one so a claimed Discord user can't claim again on a second account. It must + // stay invisible to BOTH picker routes, for two independent reasons: + // + // - Every entry the picker lists is promised to be redeemable by a `cached_login` + // grant, and that grant refuses platform 101 outright (verifyPlatformProof answers + // `unsupported`). Listing one offers the client an account it can never log into. + // - These routes are PUBLIC and unauthenticated, and a Discord snowflake is readable by + // anyone sharing a server with its owner. Answering here would turn the login picker + // into a lookup from "Discord user" to "their RecFlare account", for every player who + // ever claimed benefits. + // + // The bare-id route is checked too, and it is the easier one to miss: it matches on ANY + // platform, so it would resolve the snowflake even though naming 101 explicitly did not. + test('never lists a Discord link in either cached-login picker', async () => { + const discordId = '308994132968210433' + await env.DB.prepare('INSERT OR IGNORE INTO account (data) VALUES (?1)') + .bind(JSON.stringify({ accountId: 31399, username: 'DiscordClaimer', hasPlus: true })) + .run() + await linkPlatformIdentity(env.DB, 31399, PlatformType.Discord, discordId) + + // Named explicitly… + const named = await exports.default.fetch( + `${ORIGIN}/cachedlogin/forplatformid/${PlatformType.Discord}/${discordId}` + ) + expect(named.status).toBe(200) + expect(await named.json()).toEqual([]) + + // …and via the bare id, which matches across platforms. + const bare = await exports.default.fetch(`${ORIGIN}/cachedlogin/forplatformid/any/${discordId}`) + expect(await bare.json()).toEqual([]) + + // …and through the bulk friends-resolution route, which takes bare ids only. + const bulk = await exports.default.fetch(`${ORIGIN}/cachedlogin/forplatformids`, { + method: 'POST', + headers: { 'content-type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ id: discordId }).toString(), + }) + expect(await bulk.json()).toEqual([]) + + // The link is still really there — this is a filtered READ, not a failed write. + await expect( + countAccountsForPlatformIdentity(env.DB, PlatformType.Discord, discordId) + ).resolves.toBe(1) + }) + // The 20250424.01 build POSTs the picker lookup with a platform-attestation form body // instead of GETting it. Nothing reads that body yet, so both methods must answer the // same list — otherwise the newer client's login screen comes up empty. @@ -577,6 +625,9 @@ describe('auth worker routes', () => { expect(payload.role).not.toContain('junior') // No privileges to carry, so the claim is absent rather than an empty array. expect(payload['rn.privilege']).toBeUndefined() + // Same for Plus: omitted rather than `false`, so a non-subscriber's token is + // byte-for-byte what it was before `rn.plus` existed. + expect(payload['rn.plus']).toBeUndefined() expect(payload.scope).toContain('rn.api') }) @@ -615,6 +666,64 @@ describe('auth worker routes', () => { expect(payload.role).toEqual(expect.arrayContaining(['gameClient', 'developer', 'moderator'])) }) + // Rec Room Plus rides on the token as `rn.plus`, stamped from `account.hasPlus` — which + // the website's Discord benefits claim sets. `econ` decides the CampusCard and the + // subscriber discount from this claim ALONE and never reads the account, so if this + // stops being stamped, Plus silently stops existing for everyone. + // + // It is a CLAIM, not a scope: `scope` is a fixed list the client parses, and this is + // ours. And it is not a role — the `developer` role does not confer Plus. + test('POST /connect/token stamps rn.plus for a hasPlus account', async () => { + await env.DB.prepare('INSERT OR IGNORE INTO account (data) VALUES (?1)') + .bind( + JSON.stringify({ + accountId: 93, + username: 'PlusPlayer', + passwordHash: await hashPassword(LOGIN_PASSWORD), + hasPlus: true, + }) + ) + .run() + const payload = await tokenFor(`account_id=93&password=${LOGIN_PASSWORD}`) + expect(payload['rn.plus']).toBe(true) + expect(payload.scope).not.toContain('rn.plus') + // Plus is not an elevated role, and does not come with one. + expect(payload.role).not.toContain('developer') + }) + + // The flag is read at LOGIN, so signing in again is what activates it — the website's + // claim page and `runx admin grant-plus` both say so, and this is the mechanism behind it. + // + // This also pins that `hasPlus` stands ALONE: the flag is set here by raw SQL, exactly as + // `runx admin grant-plus` sets it, with no Discord app configured, no OAuth exchange and + // no `platform_account` link anywhere. An operator must be able to grant Plus outright. + test('rn.plus refreshes on the next login after hasPlus is set, with no Discord link', async () => { + await env.DB.prepare('INSERT OR IGNORE INTO account (data) VALUES (?1)') + .bind( + JSON.stringify({ + accountId: 94, + username: 'LateClaimer', + passwordHash: await hashPassword(LOGIN_PASSWORD), + }) + ) + .run() + // Before claiming: no Plus. + expect((await tokenFor(`account_id=94&password=${LOGIN_PASSWORD}`))['rn.plus']).toBeUndefined() + + // The website's claim writes the flag… + await env.DB.prepare( + "UPDATE account SET data = json_set(data, '$.hasPlus', json('true')) WHERE account_id = 94" + ).run() + + // …and the NEXT token carries it. The one already in the player's hands does not, + // which is exactly why they have to sign in again. + expect((await tokenFor(`account_id=94&password=${LOGIN_PASSWORD}`))['rn.plus']).toBe(true) + + // Nothing linked a Discord identity to this account, and Plus does not care. + const links = await getLinksForAccount(env.DB, 94) + expect(links.filter((l) => l.platform === PlatformType.Discord)).toEqual([]) + }) + test('POST /connect/token stamps the junior role for an isJunior account', async () => { await env.DB.prepare('INSERT OR IGNORE INTO account (data) VALUES (?1)') .bind( diff --git a/apps/econ/src/econ.app.ts b/apps/econ/src/econ.app.ts index 651c075..03d7b8c 100644 --- a/apps/econ/src/econ.app.ts +++ b/apps/econ/src/econ.app.ts @@ -17,7 +17,7 @@ import { setOutfit, } from '@repo/domain' import { intVar, logger, withCleanSpec, withNotFound, withOnError } from '@repo/hono-helpers' -import { validateAndGetAccountId, validateAndGetRoles, validateAndGetVersion } from '@repo/jwt' +import { validateAndGetAccountId, validateAndGetPlus, validateAndGetVersion } from '@repo/jwt' import { getCustomAvatarItems, @@ -167,16 +167,6 @@ async function authedId(c: Context): Promise { return validateAndGetAccountId(c.req.raw, await c.env.JWT_SECRET.get()) } -/** - * The `role` claim from a Bearer token — the operator-granted roles the auth worker stamps - * from the account's flags, so a plain player's token is just `['gameClient']`. `null` when - * the request carries no valid token; an empty array means a valid token with no roles. - * Shaped to mirror {@link authedId}. - */ -async function authedRoles(c: Context): Promise { - return validateAndGetRoles(c.req.raw, await c.env.JWT_SECRET.get()) -} - /** * The client build this request's token was minted for (`rn.ver`), as a comparable NUMBER — * the leading `YYYYMMDD` of e.g. `20250718.01`, whose `.01` is a same-day rebuild and not a @@ -415,20 +405,32 @@ async function pushBalancePurchase( */ const NOT_AN_INFLUENCER = 0 -/** The operator-granted role that comes with a complimentary subscription. */ -const DEVELOPER_ROLE = 'developer' - /** - * Whether the caller currently holds a Rec Room Plus subscription — the ONE definition, - * shared by `UpdateAndGetSubscription` (which reports it) and the storefront buys (which - * price off it via `SubscriberPrices`). Nothing sells subscriptions here, so holding the - * `developer` role IS the subscription; if a real subscription store ever lands, this is - * the only place that has to learn about it. Read from the token's `role` claim, never the - * body; no or an invalid token is "not subscribed". + * Whether the caller holds a Rec Room Plus subscription — the ONE definition, shared by + * `UpdateAndGetSubscription` (which reports it) and the storefront buys (which price off + * it via `SubscriberPrices`). Those two must never disagree: a subscriber whose client + * applied the discount itself and then had the buy refused as a price mismatch is exactly + * what one definition prevents. + * + * Nothing SELLS subscriptions here. Plus is `account.hasPlus`, claimed on the website by + * proving a qualifying role in the community Discord (`www` `POST /api/benefits/claim`), + * and it reaches this worker as the token's `rn.plus` claim — stamped by `auth` at login + * from that flag. So this is a pure token read: no database, no binding, nothing to load. + * + * The cost is FRESHNESS, deliberately accepted. The claim is only as current as the token, + * which lasts a day and is never refreshed (see TOKEN_TTL_SECONDS), so a player who claims + * on the website has to sign in again — and restart the game — before Plus applies. The + * website's claim page says so. + * + * The `developer` role does NOT grant Plus. It used to, as a stand-in while nothing else + * could confer it; now that the Discord claim exists, Plus is one thing with one source. + * An operator who wants a developer to have it sets `hasPlus` on their account like + * anyone else's. + * + * Never read from the body. No token, or an invalid one, is "not subscribed". */ async function isSubscriber(c: Context): Promise { - const roles = await authedRoles(c) - return roles?.includes(DEVELOPER_ROLE) ?? false + return validateAndGetPlus(c.req.raw, await c.env.JWT_SECRET.get()) } /** `SubscriptionLevel.Gold`. 1 is Platinum. */ @@ -448,20 +450,21 @@ const SUBSCRIPTION_PLATFORM_ALL = -1 const STUB_SUBSCRIPTION_ID = 1 /** - * The complimentary subscription a `developer` account reports — Rec Room Plus, which the - * client's API calls a `CampusCard`. + * The complimentary subscription a subscriber reports — Rec Room Plus, which the client's + * API calls a `CampusCard`. See `isSubscriber` for who counts as one: a `developer`, or a + * player who claimed `hasPlus` with a Discord role on the website. * - * Nothing here sells subscriptions, so holding the role IS the subscription: it's how the - * paid-tier surfaces get exercised without a store. Every field is computed per call and - * none of it is persisted, so this is not a record of anything — revoking the role revokes - * the subscription, and no expiry sweep or renewal exists. + * Nothing here sells subscriptions, so holding one of those IS the subscription. Every + * field is computed per call and none of it is persisted, so this is not a record of + * anything — dropping the role or the flag drops the subscription, and no expiry sweep or + * renewal exists. * * `ExpirationDate` is a year out from THIS call rather than a fixed date: a hard-coded one * lapses on a day nobody is expecting, and the client would start showing an expired * subscription with no way to renew it. `IsAutoRenewing` tells the client the same thing. * The dates are milliseconds-precision ISO like the rest of this worker's timestamps. */ -function developerSubscription(accountId: number) { +function plusSubscription(accountId: number) { const now = new Date() // Calendar arithmetic, not now + 365 days: setUTCFullYear lands on the same date next // year whether or not a leap day falls in between. @@ -3097,8 +3100,8 @@ const app = new Hono({ strict: false }) summary: 'Buy a storefront item', description: [ 'Looks the item up in its storefront catalog, confirms the client’s `RequestedPrice`', - 'still matches the `Prices` entry — a Rec Room Plus subscriber (the same check as', - '`UpdateAndGetSubscription`) may pay anywhere from that down to 10% off, since their', + 'still matches the `Prices` entry — a Rec Room Plus subscriber (the same `rn.plus`', + 'check as `UpdateAndGetSubscription`) may pay anywhere from that down to 10% off, since their', 'client applies the discount itself and not to every item — debits the buyer atomically,', 'grants the item (into the inventory or', 'consumable table), and returns a gift box. A `Gift` block routes the item — and its', @@ -3947,26 +3950,29 @@ const app = new Hono({ strict: false }) ) // Subscription lookup (Rec Room Plus, the client's `CampusCard`). There is no store to - // buy one from, so the `developer` role stands in for a paid subscription: a developer - // reports an active Gold year, everyone else reports none. Nothing is stored — see - // `developerSubscription`. + // buy one from: Plus is claimed on the website by proving a Discord role, and reaches + // this worker as the token's `rn.plus` claim. A caller carrying it reports an active + // Gold year; everyone else reports none. Nothing about the subscription itself is + // stored, and nothing here reads the database — see `isSubscriber` and `plusSubscription`. // // Auth is OPTIONAL, and a missing or invalid token answers "no subscription" rather than // 401: the client posts this while loading, so an error here can stall its load // orchestration, and "you aren't subscribed" is the truthful answer for an anonymous - // caller anyway. The role is read from the token's `role` claim, never from the body. + // caller anyway. Never read from the body. .post( '/api/CampusCard/v1/UpdateAndGetSubscription', describeRoute({ tags: ['Econ'], summary: 'Subscription lookup', description: [ - 'The caller’s Rec Room Plus subscription. Nothing sells subscriptions here, so the', - 'operator-granted `developer` role stands in for one: a developer’s token reports an', - 'active Gold (`Level` 0) yearly (`Period` 1) subscription on `PlatformType` -1 (All),', - 'expiring a year from the call, and every other caller gets `{}`. Auth is optional —', - 'a missing or invalid token reads as “not subscribed”, not 401. Nothing is persisted:', - 'the role IS the subscription, so revoking it revokes this.', + 'The caller’s Rec Room Plus subscription. Nothing sells subscriptions here: Plus is', + 'claimed on the website by proving a qualifying role in the community Discord, and', + 'arrives as the token’s `rn.plus` claim. A token carrying it reports an active Gold', + '(`Level` 0) yearly (`Period` 1) subscription on `PlatformType` -1 (All), expiring a', + 'year from the call; every other caller gets `{}`. The `developer` role does NOT', + 'confer it. Auth is optional — a missing or invalid token reads as “not subscribed”,', + 'not 401. The subscription itself is not persisted, and because the claim is stamped', + 'at login, a player who has just claimed must sign in again before it appears.', ].join(' '), responses: { 200: json(SubscriptionResponse, 'The subscription, or `{}` for no subscription'), @@ -3977,7 +3983,7 @@ const app = new Hono({ strict: false }) const id = await authedId(c) if (id === null) return c.json({}) return c.json({ - Subscription: developerSubscription(id), + Subscription: plusSubscription(id), PlatformAccountSubscribedPlayerId: null, }) } diff --git a/apps/econ/src/openapi.ts b/apps/econ/src/openapi.ts index 027e4d8..1533375 100644 --- a/apps/econ/src/openapi.ts +++ b/apps/econ/src/openapi.ts @@ -116,8 +116,9 @@ export const CustomAvatarItemsResponse = z.object({ /** * A Rec Room Plus subscription (the client calls it a `CampusCard`). Nothing here sells one, - * so this is the complimentary subscription a `developer` account reports — see - * `developerSubscription` in econ.app.ts for why each field reads the way it does. + * so this is the complimentary subscription reported by a caller whose token carries + * `rn.plus` — stamped from `account.hasPlus`, which the website's Discord benefits claim + * sets. See `plusSubscription` in econ.app.ts for why each field reads the way it does. */ export const SubscriptionDto = z.object({ SubscriptionId: z.int().describe('Placeholder — no subscription is stored'), diff --git a/apps/econ/src/test/integration/api.test.ts b/apps/econ/src/test/integration/api.test.ts index 0f49fde..34f2fea 100644 --- a/apps/econ/src/test/integration/api.test.ts +++ b/apps/econ/src/test/integration/api.test.ts @@ -285,12 +285,21 @@ async function bearer( sub = '42', roles?: string[], /** The client build to stamp as `rn.ver` — omitted, like a token minted before the claim. */ - version?: string + version?: string, + /** + * Stamp `rn.plus`, as auth does for an account with `hasPlus`. This is the ONLY thing + * that makes a caller a Rec Room Plus subscriber — the `developer` role does not — so + * every subscriber-priced test passes it. + */ + plus = false ): Promise> { const now = Math.floor(Date.now() / 1000) const claims: Record = { sub, exp: now + 3600 } if (roles !== undefined) claims.role = roles if (version !== undefined) claims['rn.ver'] = version + // Omitted when false, exactly as generateToken omits it — so these tokens match the + // shape of a real non-subscriber's. + if (plus) claims['rn.plus'] = true const signingInput = `${b64url(JSON.stringify({ alg: 'HS256', typ: 'JWT' }))}.${b64url( JSON.stringify(claims) )}` @@ -1770,7 +1779,7 @@ describe('econ endpoints', () => { test('POST /api/storefronts/v2/buyItem charges a subscriber the SubscriberPrices entry', async () => { await drainFrames() - const res = await buy2263(await bearer('322', ['gameClient', 'developer']), 85) + const res = await buy2263(await bearer('322', ['gameClient'], undefined, true), 85) expect(res.status).toBe(200) expect(((await res.json()) as { Balance: number }).Balance).toBe(-85) const bal = await exports.default.fetch(`${ORIGIN}/api/storefronts/v4/balance/2`, { @@ -1785,7 +1794,7 @@ describe('econ endpoints', () => { const res = await exports.default.fetch(`${ORIGIN}/api/storefronts/v2/buyItem`, { method: 'POST', headers: { - ...(await bearer('325', ['gameClient', 'developer'])), + ...(await bearer('325', ['gameClient'], undefined, true)), 'Content-Type': 'application/json', }, body: JSON.stringify({ @@ -1807,7 +1816,7 @@ describe('econ endpoints', () => { const res = await exports.default.fetch(`${ORIGIN}/api/storefronts/v2/buyItem`, { method: 'POST', headers: { - ...(await bearer('326', ['gameClient', 'developer'])), + ...(await bearer('326', ['gameClient'], undefined, true)), 'Content-Type': 'application/json', }, body: JSON.stringify({ @@ -1828,10 +1837,10 @@ describe('econ endpoints', () => { }) test('POST /api/storefronts/v2/buyItem 409s a subscriber below the discount band', async () => { - const res = await buy2263(await bearer('323', ['gameClient', 'developer']), 84) + const res = await buy2263(await bearer('323', ['gameClient'], undefined, true), 84) expect(res.status).toBe(409) // …and above it: a made-up price is a mismatch in either direction. - const over = await buy2263(await bearer('323', ['gameClient', 'developer']), 96) + const over = await buy2263(await bearer('323', ['gameClient'], undefined, true), 96) expect(over.status).toBe(409) }) @@ -3593,7 +3602,9 @@ describe('econ endpoints', () => { "DELETE FROM reward_status WHERE account_id = 83 AND gift_context = 'Dodgeball'" ).run() expect((await request('rewardType=PostGameActivity&giftContext=Dodgeball')).status).toBe(200) - const latest = (await giftBoxes('83')).findLast((b) => b.GiftContext === 8000 || b.GiftContext === 50) + const latest = (await giftBoxes('83')).findLast( + (b) => b.GiftContext === 8000 || b.GiftContext === 50 + ) if (i < 3) handed.push(latest?.AvatarItemDesc as string) else expect(latest).toMatchObject({ AvatarItemDesc: '', GiftContext: 50 }) } @@ -3745,7 +3756,7 @@ describe('econ endpoints', () => { }) test('POST /api/CampusCard/v1/UpdateAndGetSubscription gives a developer a Gold year', async () => { - const res = await getSubscription(await bearer('205', ['gameClient', 'developer'])) + const res = await getSubscription(await bearer('205', ['gameClient'], undefined, true)) expect(res.status).toBe(200) const body = (await res.json()) as { Subscription: Record @@ -3777,7 +3788,7 @@ describe('econ endpoints', () => { }) test('POST /api/CampusCard/v1/UpdateAndGetSubscription is {} without the developer role', async () => { - // A plain player's token: valid, but no elevated role. + // A plain player's token: valid, no elevated role, and no `hasPlus` on the account. expect(await (await getSubscription(await bearer('206', ['gameClient']))).json()).toEqual({}) // A token with no `role` claim at all. expect(await (await getSubscription(await bearer('206'))).json()).toEqual({}) @@ -3787,6 +3798,56 @@ describe('econ endpoints', () => { expect(await anon.json()).toEqual({}) }) + // Plus reaches this worker as the token's `rn.plus` claim, which `auth` stamps from + // `account.hasPlus` at login. Nothing here reads the account, so this is the whole + // mechanism — and the reason a player who claims on the website has to sign in again. + // + // The token carries only `gameClient`, exactly as a game client's does. + test('POST /api/CampusCard/v1/UpdateAndGetSubscription honours the rn.plus claim', async () => { + const res = await getSubscription(await bearer('9208', ['gameClient'], undefined, true)) + expect(res.status).toBe(200) + const body = (await res.json()) as { Subscription: Record } + expect(body.Subscription).toMatchObject({ + SubscriptionId: 1, + RecNetPlayerId: 9208, + PlatformType: -1, + Level: 0, + Period: 1, + IsAutoRenewing: true, + }) + }) + + // The `developer` role used to BE the subscription, as a stand-in while nothing else + // could confer one. Now that Plus has a real source it is one thing with one source, and + // an elevated account is not a subscriber unless it also holds `rn.plus`. Pinned because + // nothing else would fail if the old shortcut came back: it would silently hand Plus (and + // the 10% discount) to every operator account. + test('the developer role alone is not a Rec Room Plus subscription', async () => { + const dev = await getSubscription(await bearer('9210', ['gameClient', 'developer'])) + expect(dev.status).toBe(200) + expect(await dev.json()).toEqual({}) + + // …and it buys nothing at the subscriber price either, so the report and the buy path + // agree. 85 is the SubscriberPrices entry for sf300's 2263; 95 is the list price. + const discounted = await buy2263(await bearer('9211', ['gameClient', 'developer']), 85) + expect(discounted.status).toBe(409) + }) + + // Plus is priced, not just displayed: the same claim gates the subscriber discount band + // on a buy. A subscriber whose client applied the discount itself and then had the + // purchase refused as a price mismatch is exactly what one definition prevents, so the + // CampusCard report and the buy must never disagree. + test('an rn.plus token is charged the subscriber price', async () => { + const res = await buy2263(await bearer('9326', ['gameClient'], undefined, true), 85) + expect(res.status).toBe(200) + expect(((await res.json()) as { Balance: number }).Balance).toBe(-85) + + // The same request without the claim is refused, so the discount really comes from + // `rn.plus` and not from the band being open to everyone. + const plain = await buy2263(await bearer('9327', ['gameClient']), 85) + expect(plain.status).toBe(409) + }) + test('unknown path returns 404', async () => { const res = await exports.default.fetch(`${ORIGIN}/nope`) expect(res.status).toBe(404) diff --git a/apps/www/README.md b/apps/www/README.md index 86a88ee..7c1094a 100644 --- a/apps/www/README.md +++ b/apps/www/README.md @@ -81,6 +81,125 @@ printf '1x0000000000000000000000000000000AA' | The tests seed the same pair into their own local store in `beforeAll`. +### Benefits claim and Discord + +The **Claim benefits** tab on the account page lets a player prove they hold one of +the qualifying roles in the community Discord and, if they do, gives their account Rec +Room Plus (`account.hasPlus`). The same panel also renders at `/claim`, which is the +app's registered `redirect_uri` — Discord sends the browser back there mid-flow, so +that route has to keep working on a cold load even though nothing links to it. It runs a standard +OAuth2 **authorization code** flow: + +1. The tab sends the browser to Discord's consent screen, using the URL `www` + assembles in `/api/config` plus a `state` nonce the page mints and stashes in + `sessionStorage`. +2. Discord redirects back to `/claim?code=…&state=…`. The page checks the nonce is + the one it minted, strips the query, and posts only the `code` to + `POST /api/benefits/claim` with the player's bearer token. +3. The worker swaps the code for an access token with the client secret, reads + `GET /users/@me/guilds/{guild}/member` to get the player's roles, revokes the + token, and — if any one of the configured roles is there — writes `hasPlus` onto + the account and links the Discord id. + +**A claim takes effect on the player's next sign-in, not immediately.** `auth` stamps +`hasPlus` into every token it mints as the `rn.plus` claim, and `econ` decides the +CampusCard and the subscriber discount from that claim alone — no database read on +either path. The token the player's game is holding was minted before they claimed, it +lasts a day, and the client never refreshes it, so they have to restart Rec Room and +sign in again. The claim page says so. + +The browser never holds a Discord access token: the client secret can't ship to a +page, which is why this is the second feature (after signup) with a server side. +The scopes are `identify` and `guilds.members.read`, which let the token's owner +read **their own** membership in one guild — so no bot is needed and this worker +holds no credential that could read anyone else's roles. + +The verified Discord id is stored as a link in `platform_account` (the `auth` +worker's table of account ↔ external identities, migration 0007) under +`PlatformType.Discord` (101) — the same place a Steam or Meta identity lives, +because that is what it is. Only `hasPlus` goes on the account itself. + +Nobody logs in with it. `auth`'s `verifyPlatformProof` can prove exactly two +platforms (Steam and Meta), so a `cached_login` naming 101 is refused outright, and +the login picker filters to those same platforms (`CACHED_LOGIN_PLATFORMS`). That +filter matters for privacy as well as correctness: the picker is public and +unauthenticated, so without it `GET /cachedlogin/forplatformid/101/` +would tell anyone which RecFlare account a given Discord user owns. + +Storing the link there is what makes the claim once-only **per Discord user**, not +per account: a second claim from the same Discord member on a different account is +refused (409), answered from the table's index rather than a scan of every account +blob. Re-claiming on the same account is idempotent — the link is `INSERT OR +IGNORE`, so `linkedAt` keeps the first claim's time — so the page is safe to +reload. Nothing revokes Plus: losing the role later leaves the flag set, so it +records "held the role once", not "holds it today". + +Four settings configure it, and **all four** are required or the claim stays +closed (`/api/config` reports `benefitsEnabled: false`, so the SPA hides the tab, +and `/api/benefits/claim` returns 403). A half-configured app is +treated as unconfigured on purpose: a client id and secret with no guild/roles +would authenticate a player and have no question left to ask about them. + +- `DISCORD_CLIENT_ID` / `DISCORD_CLIENT_SECRET` — Secrets Store, same account-level + store as `JWT_SECRET` and the Turnstile pair. The id is public (it ships to the + browser inside the authorize URL) but lives beside its secret so one place + configures the feature. +- `DISCORD_GUILD_ID` / `DISCORD_BENEFITS_ROLE_IDS` — plain vars in `wrangler.jsonc`, + not credentials. Both hold Discord **snowflakes: all digits, no letters**. Turn on + Developer Mode in Discord (Settings → Advanced), then right-click the server or the + role and Copy ID. These are ids, not names — `Supporter` is what the role is + _called_, `1077000000000000002` is what goes in the var — and they're quoted as + strings because a snowflake is too large to survive as a JSON number. + +`DISCORD_BENEFITS_ROLE_IDS` is a **list**, separated by commas and/or whitespace, so +several tiers can qualify for the same benefit. **Any one** of them is enough — they +are alternatives, not requirements: + +```jsonc +"DISCORD_BENEFITS_ROLE_IDS": "1077000000000000001,1077000000000000002" +``` + +Blank entries are dropped, so a trailing comma is harmless. A value that parses to no +ids at all counts as unset and closes the claim, rather than opening it with nothing +to check against. + +```sh +printf '' | + wrangler secrets-store secret create --name DISCORD_CLIENT_ID --scopes workers --remote +printf '' | + wrangler secrets-store secret create --name DISCORD_CLIENT_SECRET --scopes workers --remote +``` + +The two ids are **not secrets**, and setting only the secrets is the usual reason the +page never appears. Put them in the root `.env` as operator knobs, where they ride +along as `--var` on deploy (see `recflare_vars`), rather than editing `wrangler.jsonc` +— that keeps your server's ids out of the repo: + +```sh +RECFLARE_DISCORD_GUILD_ID=1077000000000000000 +RECFLARE_DISCORD_BENEFITS_ROLE_IDS=1077000000000000001,1077000000000000002 +``` + +Use **commas with no spaces** there. Those knobs become `--var` flags that the deploy +script word-splits, so a value containing a space breaks it. (`parseRoleIds` also +accepts whitespace, which is fine in `wrangler.jsonc` but not via `.env`.) + +Then redeploy `www` — the Secrets Store `.get()` caches per isolate, so a warm worker +won't pick up newly created secrets until it restarts. + +**Diagnosing a claim that won't appear:** fetch `/api/config`. If `benefitsEnabled` is +`false`, the gate is closed and it isn't a UI problem — `www` logs +`discord is half-configured, so benefit claims are closed` with a flag per input +(`hasClientId`, `hasClientSecret`, `hasGuildId`, `roleIdCount`), which names exactly +which one is missing. `wrangler tail www` shows it. + +In the [Discord developer portal](https://discord.com/developers/applications), +add `https:///claim` to the app's **Redirects**. It has to match byte +for byte: `www` derives the redirect URI from the incoming request's own origin +(never from the request body, which would turn the client secret into a redemption +oracle for someone else's app), so add `http://localhost:5173/claim` too if you +want the flow to work under `pnpm turbo dev`. + ## Development ### Run in dev mode diff --git a/apps/www/package.json b/apps/www/package.json index 6648e6e..7c296ce 100644 --- a/apps/www/package.json +++ b/apps/www/package.json @@ -18,6 +18,7 @@ "dependencies": { "@repo/domain": "workspace:*", "@repo/hono-helpers": "workspace:*", + "@repo/jwt": "workspace:*", "@scalar/api-reference": "1.63.0", "hono": "4.12.27", "react": "19.2.7", diff --git a/apps/www/src/client/App.tsx b/apps/www/src/client/App.tsx index eddf11b..dca9b96 100644 --- a/apps/www/src/client/App.tsx +++ b/apps/www/src/client/App.tsx @@ -46,6 +46,18 @@ interface Hosts { interface SiteConfig { signupEnabled: boolean turnstileSiteKey: string | null + /** + * Whether the Discord-verified benefits claim is configured. False when the operator + * has no Discord app/guild/role set, in which case the claim page and its links stay + * hidden — the endpoint would refuse anyway. + */ + benefitsEnabled: boolean + /** + * The Discord consent URL to send the player to, assembled by `www` (scopes and the + * redirect URI are its business, and must match what the claim will accept). Null when + * benefits are off. The `state` nonce is appended here — see `startDiscordAuth`. + */ + discordAuthorizeUrl: string | null } /** The private self DTO from `accounts` (`GET /account/me`). */ @@ -495,6 +507,50 @@ const changePassword = (oldPassword: string, newPassword: string): Promise => + call('/api/benefits/status', { authed: true }) + +/** Redeem the code Discord sent us back with. The access token never reaches this page. */ +const claimBenefits = (code: string): Promise<{ discordUsername?: string }> => + call<{ discordUsername?: string }>('/api/benefits/claim', { json: { code }, authed: true }) + +/** + * The per-attempt CSRF nonce for the Discord round-trip, in sessionStorage. + * + * OAuth's `state` only means anything if the same page that minted it is the one that + * checks it, so it can't come from the server. sessionStorage rather than localStorage: + * it belongs to this tab and this attempt, and it should not outlive the tab that started + * the flow. + */ +const OAUTH_STATE_KEY = 'rf_discord_state' + +/** + * Send the browser to Discord's consent screen. + * + * A real navigation, not a client-side route — Discord is another origin. The `state` is + * minted here and stashed for the return leg; `www` built everything else about the URL + * (see `/api/config`), so this only ever appends the one parameter it owns. + */ +function startDiscordAuth(authorizeUrl: string) { + const state = crypto.randomUUID() + sessionStorage.setItem(OAUTH_STATE_KEY, state) + const url = new URL(authorizeUrl) + url.searchParams.set('state', state) + window.location.assign(url.toString()) +} + /** * Admin-only broadcasts. The token goes to `notify`, which enforces the admin-role gate * — so a session without the role is rejected there (403) even though the UI shows no @@ -562,6 +618,214 @@ function Link({ ) } +/** + * The benefits claim itself: where the player stands, and the button that starts (or + * re-runs) the Discord round-trip. + * + * This is BOTH ends of the OAuth round-trip: it sends the player to Discord, and it is + * what renders when Discord sends them back. Which half is running is decided by whether + * the URL carries a `code`. + * + * What it never holds is a Discord access token. It forwards the one-time `code` to + * `www`, which does the exchange with the client secret and answers with a verdict; that + * is the whole reason this one feature has a server side at all. + * + * Rendered in TWO places, which is why it is a component rather than a page. Its home is + * the "Claim benefits" tab in the account dashboard, where someone would go looking for + * it. But it also has to render on `/claim`, because that path is Discord's registered + * redirect URI — the browser comes back to it with a `?code=`, and it is the only URL a + * cold load can land on mid-flow. One component means the two can't drift. + * + * The effect keys off whether the URL carries a code, so the same code covers both: on + * the dashboard there is none, and it just reports status. + */ +function BenefitsPanel({ account, config }: { account: SelfAccount; config: SiteConfig }) { + const [status, setStatus] = useState(undefined) + const [error, setError] = useState('') + const [done, setDone] = useState('') + const [pending, setPending] = useState(false) + // Shown after a successful claim only. Plus rides on the game's token as `rn.plus`, + // stamped at login, so the copy of it the player is holding still says they have none — + // and tokens last a day and are never refreshed. Without this line the claim looks like + // it silently did nothing, which is the single most likely support question here. + const [relogin, setRelogin] = useState(false) + // StrictMode runs effects twice in dev, and a Discord code is single-use: the second + // run would redeem a spent code and report a failure over a claim that just worked. + const redeemed = useRef(false) + + useEffect(() => { + const params = new URLSearchParams(window.location.search) + const code = params.get('code') + const state = params.get('state') + const expected = sessionStorage.getItem(OAUTH_STATE_KEY) + + if (code === null) { + // Nothing came back from Discord — either the dashboard tab, or `/claim` opened + // directly. Just show where they stand. Discord also returns with + // `?error=access_denied` when someone cancels: no code, nothing to say, and the + // button is right there to try again. + void fetchBenefitsStatus() + .then(setStatus) + .catch(() => setStatus(undefined)) + return + } + + // The return leg. Strip the query first, whatever happens next: the code is spent by + // the request below, so a reload must not carry it (and a code has no business + // sitting in the address bar, or in whatever the player pastes it into). replaceState + // rather than a route change, so Back doesn't walk into a used code either. + window.history.replaceState(null, '', '/claim') + if (redeemed.current) return + redeemed.current = true + sessionStorage.removeItem(OAUTH_STATE_KEY) + + // The nonce this tab minted must be the one that came back. A mismatch means the + // round-trip wasn't started here, which is exactly what `state` exists to catch. + if (state === null || expected === null || state !== expected) { + setError('That Discord sign-in did not match this browser. Please start again.') + return + } + + setPending(true) + claimBenefits(code) + .then((result) => { + setStatus({ hasPlus: true, linked: true }) + setDone( + result.discordUsername + ? `Verified as ${result.discordUsername} — Rec Room Plus is now on your account.` + : 'Verified — Rec Room Plus is now on your account.' + ) + setRelogin(true) + }) + .catch((err: unknown) => setError(err instanceof Error ? err.message : String(err))) + .finally(() => setPending(false)) + }, []) + + // Read into a local so the narrowing survives into the click handlers below. + const authorizeUrl = config.discordAuthorizeUrl + if (!config.benefitsEnabled || authorizeUrl === null) { + return ( +
+

Claim benefits

+

Benefit claims aren’t available on this server right now.

+
+ ) + } + + const claimed = status?.hasPlus === true + + return ( +
+

Rec Room Plus

+

+ Members of our Discord with a supporter role get Rec Room Plus on their account. Verify with + Discord and we’ll check your roles — we only ever read your username and which roles you + hold in our server. +

+

+ Claiming as @{account.username} (#{account.accountId}). A Discord account + can claim on one RecFlare account only. +

+ + {error &&

{error}

} + {done &&

{done}

} + {relogin && ( +

+ Restart Rec Room and sign in again to pick it up — your game reads Rec Room Plus from the + session it signed in with, so it won’t show until then. +

+ )} + + {pending ? ( +

Checking your Discord roles…

+ ) : claimed ? ( + // Already claimed. The button stays, because a player whose roles changed (or who + // re-linked) can safely run it again — the claim is idempotent on their own + // account — but it no longer reads as the thing to do. + <> + {!done && ( + <> +

Rec Room Plus is active on this account.

+

+ If the game doesn’t show it, sign out and back in — Rec Room Plus is read from the + session your game signed in with. +

+ + )} + + + ) : ( + + )} +
+ ) +} + +/** + * `/claim` — the page Discord redirects back to. + * + * Not linked from anywhere any more: the claim lives in the account dashboard's "Claim + * benefits" tab. This route still has to exist and still has to work on a cold load, + * because it is the app's registered `redirect_uri` — the browser arrives here from + * Discord carrying the `?code=`, with whatever session it has. + * + * Signing in comes FIRST, and not only because the grant needs an account to land on: the + * bearer token is what tells `www` whose row to write, so a claim without one has no + * subject. Hence the sign-in card rather than a redirect — someone who arrives here from a + * link should be told what this is before being bounced to a login form. + */ +function ClaimPage({ + account, + config, + navigate, +}: { + account: SelfAccount | null | undefined + config: SiteConfig | undefined + navigate: Navigate +}) { + if (account === undefined || config === undefined) { + return ( +
+

Loading…

+
+ ) + } + + if (account === null) { + return ( +
+

Claim your benefits

+
+

Sign in first

+

+ Benefits are granted to a RecFlare account, so we need to know which one is yours before + you verify with Discord. If you were part-way through a claim, start it again from your + account page once you’re signed in. +

+ + Sign in + +
+
+ ) + } + + return ( +
+

Claim your benefits

+ +
+ ) +} + /** * The room id in `/rooms/`, or null for any other path. Numeric rather than the * room's name: a name is renameable (`PUT /rooms/{id}/name`), so a link someone @@ -597,7 +861,12 @@ export function App() { .catch(() => setAccount(null)) }) .catch(() => { - setConfig({ signupEnabled: false, turnstileSiteKey: null }) + setConfig({ + signupEnabled: false, + turnstileSiteKey: null, + benefitsEnabled: false, + discordAuthorizeUrl: null, + }) setAccount(null) }) }, []) @@ -627,7 +896,11 @@ export function App() { onAuthed={setAccount} /> ) : path === '/account' ? ( - + + ) : path === '/claim' ? ( + // Its own page rather than a dashboard tab: this path is Discord's registered + // redirect URI, so it has to be one stable URL a cold load can land on. + ) : roomId !== null ? ( ) : ( @@ -1030,10 +1303,12 @@ function LoginPage({ /** The signed-in account page. Redirects to sign-in when there's no session. */ function AccountPage({ account, + config, navigate, onChange, }: { account: SelfAccount | null | undefined + config: SiteConfig | undefined navigate: Navigate onChange: (a: SelfAccount) => void }) { @@ -1052,7 +1327,7 @@ function AccountPage({ return (

My account

- +
) } @@ -1382,10 +1657,10 @@ function BlobUpload({ Beta

- New and lightly tested. Nothing here checks the file — the server stores whatever it - is and the game finds out on load. This server runs the {CLIENT_BUILD_DATE} build, so - scene data from a room built on anything newer may not load at all. Download the save - above and keep it before replacing it. + New and lightly tested. Nothing here checks the file — the server stores whatever it is and + the game finds out on load. This server runs the {CLIENT_BUILD_DATE} build, so scene data + from a room built on anything newer may not load at all. Download the save above and keep it + before replacing it.