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.
+ 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 (
+
+
+ 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 (
- 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.