[plus] discord role verifier to grant RR plus

This commit is contained in:
Devin Zuczek
2026-08-31 15:44:33 -04:00
parent 740e9efa09
commit 8260c5abcd
23 changed files with 1746 additions and 98 deletions
+27
View File
@@ -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 <store-id> --name DISCORD_CLIENT_ID \
# --scopes workers --remote
# wrangler secrets-store secret create <store-id> --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://<your domain>/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.
+10 -3
View File
@@ -1040,8 +1040,9 @@ const app = new Hono<App>()
// 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<App>()
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.
+8 -17
View File
@@ -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 —
+53 -2
View File
@@ -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/<snowflake>` 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<PlatformLink[]> {
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<PlatformLink[]> {
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<LinkRow>()
return results
}
+109
View File
@@ -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(
+48 -42
View File
@@ -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<App>): Promise<number | null> {
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<App>): Promise<string[] | null> {
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<App>): Promise<boolean> {
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<App>({ strict: false })
summary: 'Buy a storefront item',
description: [
'Looks the item up in its storefront catalog, confirms the clients `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<App>({ 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 callers Rec Room Plus subscription. Nothing sells subscriptions here, so the',
'operator-granted `developer` role stands in for one: a developers 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 callers 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 tokens `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<App>({ strict: false })
const id = await authedId(c)
if (id === null) return c.json({})
return c.json({
Subscription: developerSubscription(id),
Subscription: plusSubscription(id),
PlatformAccountSubscribedPlayerId: null,
})
}
+3 -2
View File
@@ -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'),
+70 -9
View File
@@ -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<Record<string, string>> {
const now = Math.floor(Date.now() / 1000)
const claims: Record<string, unknown> = { 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<string, unknown>
@@ -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<string, unknown> }
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)
+119
View File
@@ -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/<snowflake>`
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 '<client id>' |
wrangler secrets-store secret create <store-id> --name DISCORD_CLIENT_ID --scopes workers --remote
printf '<client secret>' |
wrangler secrets-store secret create <store-id> --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://<your domain>/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
+1
View File
@@ -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",
+296 -7
View File
@@ -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<unkno
authed: true,
})
/** Where this account's benefits stand: `www` reads them off the account row. */
interface BenefitsStatus {
/** Whether the account already has Rec Room Plus. */
hasPlus: boolean
/** Whether a Discord identity is already tied to it. Which one is deliberately not served. */
linked: boolean
}
/**
* The two ends of the benefits claim. Both live on `www` rather than on one of the game
* workers, because the claim needs the Discord client secret — see www.app.ts.
*/
const fetchBenefitsStatus = (): Promise<BenefitsStatus> =>
call<BenefitsStatus>('/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<BenefitsStatus | undefined>(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 (
<section className="card">
<h2>Claim benefits</h2>
<p className="muted">Benefit claims arent available on this server right now.</p>
</section>
)
}
const claimed = status?.hasPlus === true
return (
<section className="card">
<h2>Rec Room Plus</h2>
<p className="muted">
Members of our Discord with a supporter role get Rec Room Plus on their account. Verify with
Discord and well check your roles we only ever read your username and which roles you
hold in our server.
</p>
<p className="muted">
Claiming as <strong>@{account.username}</strong> (#{account.accountId}). A Discord account
can claim on one RecFlare account only.
</p>
{error && <p className="error">{error}</p>}
{done && <p className="ok">{done}</p>}
{relogin && (
<p className="hint">
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 wont show until then.
</p>
)}
{pending ? (
<p className="muted">Checking your Discord roles</p>
) : 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 && (
<>
<p className="ok">Rec Room Plus is active on this account.</p>
<p className="hint">
If the game doesnt show it, sign out and back in Rec Room Plus is read from the
session your game signed in with.
</p>
</>
)}
<button className="linkish" onClick={() => startDiscordAuth(authorizeUrl)}>
Re-verify with Discord
</button>
</>
) : (
<button
type="button"
className="cta discord"
onClick={() => startDiscordAuth(authorizeUrl)}
>
Verify with Discord
</button>
)}
</section>
)
}
/**
* `/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 (
<main className="shell">
<p className="muted">Loading</p>
</main>
)
}
if (account === null) {
return (
<main className="shell">
<h1>Claim your benefits</h1>
<section className="card">
<h2>Sign in first</h2>
<p className="muted">
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 youre signed in.
</p>
<Link to="/login" navigate={navigate} className="cta">
Sign in
</Link>
</section>
</main>
)
}
return (
<main className="shell">
<h1>Claim your benefits</h1>
<BenefitsPanel account={account} config={config} />
</main>
)
}
/**
* The room id in `/rooms/<id>`, 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' ? (
<AccountPage account={account} navigate={navigate} onChange={setAccount} />
<AccountPage account={account} config={config} navigate={navigate} onChange={setAccount} />
) : 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.
<ClaimPage account={account} config={config} navigate={navigate} />
) : roomId !== null ? (
<RoomPage account={account} roomId={roomId} navigate={navigate} />
) : (
@@ -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 (
<main className="shell wide">
<h1>My account</h1>
<Dashboard account={account} navigate={navigate} onChange={onChange} />
<Dashboard account={account} config={config} navigate={navigate} onChange={onChange} />
</main>
)
}
@@ -1382,10 +1657,10 @@ function BlobUpload({
<span className="badge beta">Beta</span>
</p>
<p className="muted blob-upload-caveat">
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.
</p>
<label className="blob-upload-file">
Scene data file
@@ -1776,10 +2051,12 @@ function LoginForm({ onAuthed }: { onAuthed: (a: SelfAccount) => void }) {
function Dashboard({
account,
config,
navigate,
onChange,
}: {
account: SelfAccount
config: SiteConfig | undefined
navigate: Navigate
onChange: (a: SelfAccount) => void
}) {
@@ -1800,6 +2077,18 @@ function Dashboard({
render: () => <EmailForm account={account} onChange={onChange} />,
},
{ id: 'password', label: 'Password', render: () => <PasswordForm /> },
// Only when the operator has Discord configured — otherwise the panel has nothing to
// offer and the tab is a promise the server can't keep. The claim also still lives at
// /claim, because that URL is Discord's registered redirect and has to keep working.
...(config?.benefitsEnabled
? [
{
id: 'benefits',
label: 'Claim benefits',
render: () => <BenefitsPanel account={account} config={config} />,
},
]
: []),
...(isAdmin()
? [
{ id: 'maintenance', label: 'Server maintenance', render: () => <MaintenanceForm /> },
+47 -3
View File
@@ -7,9 +7,11 @@ export type Env = SharedHonoEnv & {
/** Static-asset fetcher for the built React SPA (see wrangler.jsonc `assets`). */
ASSETS: Fetcher
/**
* The shared `recflare` D1, bound READ-ONLY in practice: the only thing www asks it
* is the live presence head-count behind `/server-status`. Every table it can see is
* owned (and migrated) by another worker.
* The shared `recflare` D1. www asks it two things: the live presence head-count
* behind `/server-status`, and the caller's `account` row on the benefits claim
* which is also the one place www WRITES (the `hasPlus`/`discordUserId` pair, through
* `@repo/domain`'s `updateAccount`, so the blob's shape stays in one module). Every
* table it can see is owned (and migrated) by another worker; www never migrates.
*/
DB: D1Database
/**
@@ -39,6 +41,48 @@ export type Env = SharedHonoEnv & {
* failing to resolve closes web signup see src/turnstile.ts.
*/
TURNSTILE_SECRET_KEY: SecretsStoreSecret
/**
* The HS256 signing key every worker shares, out of the same account-level Secrets
* Store. www needs it for ONE thing: the benefits claim is the only route here that
* acts on behalf of a specific account (it writes `hasPlus` onto it), so it has to
* establish WHICH account is calling rather than take the SPA's word for it. Every
* other www route is either anonymous or hands the token straight to another worker.
*
* Resolve with `await env.JWT_SECRET.get()`; validate through `@repo/jwt` so the
* signature/exp checks are the ones every other worker runs.
*/
JWT_SECRET: SecretsStoreSecret
/**
* The Discord application's client id. PUBLIC it ships to the browser, which needs
* it to build the authorize URL but kept in the Secrets Store beside its secret so
* one place configures the claim, exactly as TURNSTILE_SITE_KEY is.
*/
DISCORD_CLIENT_ID: SecretsStoreSecret
/**
* The Discord application's client secret what turns an authorization code into an
* access token. Never leaves this worker (see src/discord.ts).
*/
DISCORD_CLIENT_SECRET: SecretsStoreSecret
/**
* The guild (Discord server) whose membership the benefits claim checks, and the roles
* within it that entitle a player to the benefits. Both hold Discord SNOWFLAKES all
* digits, never a role's display name kept as strings because a snowflake exceeds
* 2^53. Plain vars rather than secrets: any member of the server can read these off
* their own client, and none of them authorizes anything on its own.
*
* OPTIONAL because an operator who hasn't set up Discord has neither, and that is a
* supported state: it CLOSES the claim (see `discordConfig`) rather than opening an
* unverified one.
*/
DISCORD_GUILD_ID?: string
/**
* The role ids inside DISCORD_GUILD_ID that grant Rec Room Plus numeric snowflakes,
* separated by commas and/or whitespace, e.g. `"1077000000000000001,1077000000000000002"`.
* ANY one of them qualifies, so several tiers (a supporter role, a booster role, staff)
* can share the same benefit. Parsed by `parseRoleIds`; a value that parses to no ids at
* all closes the claim, exactly as an unset one does.
*/
DISCORD_BENEFITS_ROLE_IDS?: string
}
/** Variables can be extended */
+297
View File
@@ -0,0 +1,297 @@
import { logger } from '@repo/hono-helpers'
import type { Env } from './context'
/**
* Discord OAuth2, the identity check behind the website's benefits claim.
*
* A player proves they hold one of the qualifying roles in the community Discord, and
* the claim grants them Rec Room Plus (`account.hasPlus`). The proof is a real OAuth2
* AUTHORIZATION CODE exchange, not a token the browser hands us: the SPA sends only the
* short-lived `code` Discord redirected it back with, and this worker swaps that for an
* access token using the client SECRET, which like the Turnstile secret next door
* can never ship to a page. The access token therefore never exists in the browser at
* all, and it is revoked here the moment the roles have been read.
*
* Roles come from `GET /users/@me/guilds/{guild}/member`, which needs no bot: the
* `guilds.members.read` scope lets the TOKEN'S OWNER read their own membership. That is
* the whole reason this shape was chosen over a bot token nothing here has to be in
* the guild, and the worker holds no credential that could read anybody else's roles.
*
* The four settings (client id, client secret, guild, one or more roles) are the switch,
* exactly as the Turnstile keypair is for signup: with any of them missing the claim is CLOSED
* (`/api/config` says so and `/api/benefits/claim` refuses) rather than open and
* unverified. Nothing is ever inferred from the environment.
*/
/** Discord's API, pinned to v10 — the version the endpoints below are documented at. */
const API_BASE = 'https://discord.com/api/v10'
/**
* Where the browser is sent to consent. Deliberately NOT under `/api/v10`: the authorize
* page is a human-facing page on the main site, and the versioned path serves a redirect
* to it at best.
*/
export const AUTHORIZE_URL = 'https://discord.com/oauth2/authorize'
/**
* The scopes the claim asks for, in the order Discord shows them on the consent screen.
*
* - `identify` the user's own id, which the claim stores as a `PlatformType.Discord`
* link on the account to keep itself once-only.
* - `guilds.members.read` their member record (and so their ROLES) in one guild they
* are in. Narrower than `guilds`, which lists every server they belong to and is not
* needed: the claim asks about exactly one guild.
*
* A space-joined string because that is how the authorize URL takes them.
*/
export const SCOPES = 'identify guilds.members.read'
/** Everything the claim needs configured. Resolved per request; see `discordConfig`. */
export interface DiscordConfig {
/** The application's client id. PUBLIC — it ships to the browser in the authorize URL. */
clientId: string
/** The application's client secret. Never leaves this worker. */
clientSecret: string
/** The guild (server) whose membership is checked. */
guildId: string
/**
* The role ids within that guild that entitle a player to the benefits Discord
* snowflakes, all digits. ANY one of them qualifies: they're alternatives (a supporter
* role, a booster role, staff), not requirements, so this is a set to test membership
* against and never an ordered list. Always at least one entry an empty list closes
* the claim (see `discordConfig`).
*/
roleIds: string[]
}
/**
* Parse the configured role ids Discord snowflakes, so each one is ALL DIGITS (a role's
* display name is not an id and will never match anything). They stay strings rather than
* becoming numbers: a snowflake exceeds 2^53, and they are only ever compared, never done
* arithmetic on.
*
* Separated by commas and/or whitespace, so a value pasted out of Discord one id per line
* works as well as `1077000000000000001,1077000000000000002` does; blank entries are
* dropped, which is what makes a trailing comma harmless rather than a role id of `''`
* that nothing can ever match.
*
* The digits are not ENFORCED here, deliberately. A typo'd snowflake is indistinguishable
* from a real role nobody holds, and both correctly result in a claim being refused, so a
* format rule would buy nothing but a way to reject a valid id if Discord ever widens the
* format. Misconfiguration shows up as "nobody can claim", which is the safe direction.
*/
export const parseRoleIds = (raw: string): string[] =>
raw
.split(/[\s,]+/)
.map((id) => id.trim())
.filter((id) => id !== '')
/**
* The Discord settings, or null when the claim isn't configured which is what CLOSES
* it. All four must be present, and the role list must parse to at least ONE id: a client
* id with no roles would authenticate a player and then have no question to ask about
* them, and treating that as "configured" would hand Plus to anyone with a Discord
* account.
*
* Which of the four is missing is logged (never their values) because a half-configured
* app is otherwise indistinguishable from an operator deliberately leaving benefits off.
*
* The id and secret come from the account-level Secrets Store the whole monorepo shares,
* so they're read per request rather than off `env` as strings; `.get()` caches per
* isolate, so changing either needs a `www` redeploy to take effect on a warm worker
* the same caveat TURNSTILE_* and JWT_SECRET carry. The guild and roles are plain vars:
* they're server ids visible to every member, not credentials.
*/
export async function discordConfig(env: Env): Promise<DiscordConfig | null> {
const [clientId, clientSecret] = await Promise.all([
readSecret(env.DISCORD_CLIENT_ID, 'DISCORD_CLIENT_ID'),
readSecret(env.DISCORD_CLIENT_SECRET, 'DISCORD_CLIENT_SECRET'),
])
const guildId = env.DISCORD_GUILD_ID ?? ''
const roleIds = parseRoleIds(env.DISCORD_BENEFITS_ROLE_IDS ?? '')
if (clientId !== '' && clientSecret !== '' && guildId !== '' && roleIds.length > 0) {
return { clientId, clientSecret, guildId, roleIds }
}
if (clientId !== '' || clientSecret !== '' || guildId !== '' || roleIds.length > 0) {
logger.error('discord is half-configured, so benefit claims are closed', {
hasClientId: clientId !== '',
hasClientSecret: clientSecret !== '',
hasGuildId: guildId !== '',
// The COUNT, not the ids: a value that parsed to nothing (say, a stray comma) is
// indistinguishable from an unset one without it.
roleIdCount: roleIds.length,
})
}
return null
}
/**
* One Secrets Store value as a string, or '' when it can't be read. The binding is
* declared in wrangler.jsonc so it's always on `env`; what varies is whether the store
* holds the secret a missing one throws rather than resolving empty. Mirrors
* `turnstile.ts`'s reader, and for the same reason: a store this worker can't read must
* close the feature, not 500 the homepage.
*/
async function readSecret(secret: SecretsStoreSecret, name: string): Promise<string> {
try {
return (await secret.get()) ?? ''
} catch (err) {
logger.error('failed to read a discord credential from the secrets store', {
secret: name,
error: String(err),
})
return ''
}
}
/**
* The URI Discord redirects back to after consent, derived from the request rather than
* configured.
*
* It must be byte-identical in three places the authorize URL the browser opens, the
* token exchange below, and the app's registered redirect list or Discord refuses the
* exchange. Deriving it from the incoming request's own origin is what keeps the first
* two in step across every environment (localhost in dev, the real domain in
* production) with nothing to configure, and it is also why the SPA does NOT get to
* supply it in the request body: an attacker-supplied redirect would turn this worker's
* client secret into a redemption oracle for codes issued to somebody else's app page.
*
* `/claim` is the SPA route that handles the return; see App.tsx.
*/
export const redirectUri = (request: Request): string => new URL('/claim', request.url).toString()
/**
* Swap an authorization code for an access token. Returns null on any refusal a code
* that was already spent, expired (they live ~1 minute), issued to another app, or paired
* with a different redirect URI all land here, and none of them is worth telling the
* browser apart: the answer is the same, start the flow again.
*
* The credentials go in the BODY rather than a Basic auth header. Both are legal and
* Discord documents the body form.
*/
export async function exchangeCode(
config: DiscordConfig,
code: string,
redirect: string
): Promise<string | null> {
try {
const res = await fetch(`${API_BASE}/oauth2/token`, {
method: 'POST',
headers: { 'content-type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
client_id: config.clientId,
client_secret: config.clientSecret,
grant_type: 'authorization_code',
code,
redirect_uri: redirect,
}).toString(),
})
if (!res.ok) {
// The body carries an OAuth error code (`invalid_grant`, `invalid_client`) — the
// last of which is a misconfiguration, not a player mistake, and this line is the
// only place it surfaces. Logged, never relayed: it tells a caller nothing.
logger.info('discord refused a code exchange', {
status: res.status,
body: await res.text().catch(() => ''),
})
return null
}
const token = (await res.json()) as { access_token?: unknown }
return typeof token.access_token === 'string' ? token.access_token : null
} catch (err) {
logger.error('could not reach discord to exchange a code', { error: String(err) })
return null
}
}
/**
* Whether a member holds ANY of the qualifying roles. Both sides are snowflake id
* strings, compared exactly Discord reports a member's roles as ids, never as names.
*
* The roles are alternatives (a supporter, a booster and a staff member all qualify), so
* this is an intersection test and not a subset one: requiring all of them would mean
* nobody ever claimed.
*/
export const qualifies = (memberRoles: string[], roleIds: string[]): boolean =>
memberRoles.some((role) => roleIds.includes(role))
/** Who claimed, and what they hold in the guild. */
export interface GuildMembership {
/** The Discord user's id (a snowflake, kept as a string — it exceeds 2^53). */
userId: string
/** Their Discord username, for the confirmation line. Display only, never stored. */
username: string
/** Their role ids in the guild. */
roles: string[]
}
/**
* The token owner's membership in the configured guild, or null when they aren't in it
* (Discord answers 404) or the call fails.
*
* `null` deliberately conflates "not a member" with "we couldn't ask". Both mean the same
* thing to the claim no proof was obtained and a claim that granted benefits when
* Discord was unreachable would be worse than one that asks the player to retry.
*/
export async function fetchGuildMembership(
accessToken: string,
guildId: string
): Promise<GuildMembership | null> {
try {
const res = await fetch(`${API_BASE}/users/@me/guilds/${guildId}/member`, {
headers: { authorization: `Bearer ${accessToken}` },
})
if (!res.ok) {
// 404 is the ordinary "they aren't in the server" answer, so it's info, not error.
logger.info('discord did not return a guild membership', { status: res.status })
return null
}
const member = (await res.json()) as {
user?: { id?: unknown; username?: unknown }
roles?: unknown
}
const userId = typeof member.user?.id === 'string' ? member.user.id : ''
if (userId === '') {
logger.error('discord returned a guild member with no user id')
return null
}
return {
userId,
username: typeof member.user?.username === 'string' ? member.user.username : '',
roles: Array.isArray(member.roles)
? member.roles.filter((r): r is string => typeof r === 'string')
: [],
}
} catch (err) {
logger.error('could not reach discord to read a guild membership', { error: String(err) })
return null
}
}
/**
* Hand the access token back to Discord once the roles have been read.
*
* Best-effort and deliberately un-awaited-on by the caller's success path: the claim has
* already been decided by this point, so a failed revoke must not fail it. It's here
* because the token is useless to us after one read and a live token is a liability for
* however long it would otherwise last (a week) this keeps the credential's lifetime
* about as long as the request that needed it.
*/
export async function revokeToken(config: DiscordConfig, accessToken: string): Promise<void> {
try {
await fetch(`${API_BASE}/oauth2/token/revoke`, {
method: 'POST',
headers: { 'content-type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
client_id: config.clientId,
client_secret: config.clientSecret,
token: accessToken,
token_type_hint: 'access_token',
}).toString(),
})
} catch (err) {
logger.info('could not revoke a discord access token', { error: String(err) })
}
}
+255
View File
@@ -1,8 +1,19 @@
import { adminSecretsStore, env, SELF } from 'cloudflare:test'
import { beforeAll, expect, it } from 'vitest'
import { SCHEMA_DDL as ACCOUNT_SCHEMA_DDL, updateAccount } from '@repo/domain/src/accounts-db'
import { PlatformType } from '@repo/domain/src/enums'
import { PRESENCE_SCHEMA_DDL, PRESENCE_TTL_SECONDS } from '@repo/domain/src/presence-db'
import { generateToken } from '@repo/jwt'
import {
CACHED_LOGIN_PLATFORMS,
countAccountsForPlatformIdentity,
isPlatformIdentityLinked,
linkPlatformIdentity,
PLATFORM_SCHEMA_DDL,
} from '../../../../auth/src/platform-db'
import { discordConfig, parseRoleIds, qualifies } from '../../discord'
import { DOCUMENTED_SERVICES } from '../../docs'
import { DISCORD_INVITE, ISSUES_URL, PRIVACY_EMAIL } from '../../links'
import { turnstileKeys } from '../../turnstile'
@@ -21,12 +32,37 @@ declare module 'cloudflare:test' {
const TEST_SITE_KEY = '1x00000000000000000000AA'
const TEST_SECRET_KEY = '1x0000000000000000000000000000000AA'
// The shared HS256 key. www verifies tokens itself for exactly one route (the benefits
// claim), so the tests have to be able to MINT one — hence a known value here rather than
// whatever a deployed store holds.
const TEST_JWT_SECRET = 'test-jwt-secret'
/** A bearer token for `accountId`, signed the way `auth` signs one. */
const tokenFor = (accountId: number): Promise<string> =>
generateToken(String(accountId), '', 4, TEST_JWT_SECRET)
// A Discord app that is HALF configured: credentials seeded below, but wrangler.jsonc
// leaves DISCORD_GUILD_ID / DISCORD_BENEFITS_ROLE_IDS empty. This is deliberately the most
// dangerous half — an operator who registers an app and stops has something that can sign
// a player in and no question left to ask about them — so it is the state the route-level
// tests pin: the claim must still be CLOSED. The fully-configured path is covered by
// unit-testing `discordConfig`, since exercising it end to end would call discord.com.
const TEST_DISCORD_CLIENT_ID = 'test-discord-client-id'
const TEST_DISCORD_CLIENT_SECRET = 'test-discord-client-secret'
beforeAll(async () => {
await adminSecretsStore(env.TURNSTILE_SITE_KEY).create(TEST_SITE_KEY)
await adminSecretsStore(env.TURNSTILE_SECRET_KEY).create(TEST_SECRET_KEY)
await adminSecretsStore(env.JWT_SECRET).create(TEST_JWT_SECRET)
await adminSecretsStore(env.DISCORD_CLIENT_ID).create(TEST_DISCORD_CLIENT_ID)
await adminSecretsStore(env.DISCORD_CLIENT_SECRET).create(TEST_DISCORD_CLIENT_SECRET)
// `presence` is owned (and migrated) by other workers — www only reads it — so the
// table has to be created here for the head-count behind /server-status.
for (const stmt of PRESENCE_SCHEMA_DDL) await env.DB.prepare(stmt).run()
// `account` likewise: owned by `auth`, read and (for the benefits claim) written here.
for (const stmt of ACCOUNT_SCHEMA_DDL) await env.DB.prepare(stmt).run()
// And `platform_account`, where a claimed Discord identity is linked.
for (const stmt of PLATFORM_SCHEMA_DDL) await env.DB.prepare(stmt).run()
})
// Web signup is open, but only behind the Turnstile check. These pin the closed door:
@@ -42,6 +78,11 @@ it('advertises signup and where the other workers live', async () => {
expect(await res.json()).toEqual({
signupEnabled: true,
turnstileSiteKey: TEST_SITE_KEY,
// Closed, because the Discord app here has no guild/role to check against — and with
// it closed the SPA is given no authorize URL to send anyone to, so the claim can't
// even be started. Note the client id is NOT leaked by a closed config.
benefitsEnabled: false,
discordAuthorizeUrl: null,
hosts: {
auth: 'https://auth.rec.example.com',
accounts: 'https://accounts.rec.example.com',
@@ -281,6 +322,220 @@ it('404s a spec proxy for an unknown service (not an open proxy)', async () => {
expect(res.status).toBe(404)
})
// ---- Discord benefits claim ------------------------------------------------
// All four settings are the switch, exactly as the Turnstile keypair is for signup: a
// half-configured app must read as OFF. The dangerous half is a client id and secret with
// no guild/role — that authenticates a player and then has no question left to ask about
// them, so treating it as configured would hand Rec Room Plus to anyone with a Discord
// account. Checked directly because the configured path can't be reached from here (it
// would call discord.com for real).
it('treats a half-configured discord app as benefit claims being off', async () => {
const stub = (value: string | null): SecretsStoreSecret =>
({ get: async () => value ?? '' }) as SecretsStoreSecret
const throws = (): SecretsStoreSecret =>
({
get: async () => {
throw new Error('secret not found')
},
}) as unknown as SecretsStoreSecret
const withDiscord = (
id: SecretsStoreSecret,
secret: SecretsStoreSecret,
guildId?: string,
roleIds?: string
) =>
({
ENVIRONMENT: 'development',
DISCORD_CLIENT_ID: id,
DISCORD_CLIENT_SECRET: secret,
DISCORD_GUILD_ID: guildId,
DISCORD_BENEFITS_ROLE_IDS: roleIds,
}) as Env
const id = stub('client-id')
const secret = stub('client-secret')
// Snowflakes, as the real vars hold: ids are all digits, never a role's display name.
const guild = '1077000000000000000'
const role = '1077000000000000001'
// Nothing at all, and a store this worker can't read: both closed, never a 500.
await expect(discordConfig(withDiscord(throws(), throws()))).resolves.toBeNull()
await expect(discordConfig(withDiscord(stub(''), stub(''), '', ''))).resolves.toBeNull()
// Each single missing piece, including the two that would otherwise grant Plus for a
// bare Discord login.
await expect(discordConfig(withDiscord(throws(), secret, guild, role))).resolves.toBeNull()
await expect(discordConfig(withDiscord(id, throws(), guild, role))).resolves.toBeNull()
await expect(discordConfig(withDiscord(id, secret, '', role))).resolves.toBeNull()
await expect(discordConfig(withDiscord(id, secret, guild, ''))).resolves.toBeNull()
await expect(discordConfig(withDiscord(id, secret))).resolves.toBeNull()
// A role list that parses to NO ids is unset, not configured — otherwise a stray comma
// left in the var would open the claim with nothing to check against.
await expect(discordConfig(withDiscord(id, secret, guild, ' , , '))).resolves.toBeNull()
// All four present is the only configured state.
await expect(discordConfig(withDiscord(id, secret, guild, role))).resolves.toEqual({
clientId: 'client-id',
clientSecret: 'client-secret',
guildId: guild,
roleIds: [role],
})
// Several qualifying roles is the ordinary case, not a special one.
const second = '1077000000000000002'
await expect(discordConfig(withDiscord(id, secret, guild, `${role},${second}`))).resolves.toEqual(
{
clientId: 'client-id',
clientSecret: 'client-secret',
guildId: guild,
roleIds: [role, second],
}
)
})
// Several roles can qualify for the same benefit (a supporter role, a booster role,
// staff…), so the list is parsed leniently: an operator pasting ids out of Discord gets
// one per line, and a trailing comma is a typo rather than a role of '' that nothing
// could ever match. Every id is a snowflake — all digits, kept as a string.
it('parses a qualifying-role list however an operator writes it', () => {
expect(parseRoleIds('1077000000000000001')).toEqual(['1077000000000000001'])
expect(parseRoleIds('1077000000000000001,1077000000000000002')).toEqual([
'1077000000000000001',
'1077000000000000002',
])
expect(parseRoleIds(' 1077000000000000001 , 1077000000000000002 ')).toEqual([
'1077000000000000001',
'1077000000000000002',
])
// Pasted a line at a time, straight out of Discord.
expect(parseRoleIds('1077000000000000001\n1077000000000000002\n')).toEqual([
'1077000000000000001',
'1077000000000000002',
])
// Kept as STRINGS, never parsed to numbers: a snowflake exceeds 2^53, so
// Number('1077000000000000001') would round and stop matching the real role.
expect(parseRoleIds('1077000000000000001')[0]).toBe('1077000000000000001')
// Nothing to match on — these are the values that must close the claim.
expect(parseRoleIds('')).toEqual([])
expect(parseRoleIds(' ')).toEqual([])
expect(parseRoleIds(',,')).toEqual([])
// A trailing separator adds no empty id, which would match no role and never qualify.
expect(parseRoleIds('1077000000000000001,')).toEqual(['1077000000000000001'])
})
// ANY one of the configured roles qualifies — they are alternatives, not requirements.
// Testing for a subset instead would mean a player had to hold every tier at once, i.e.
// nobody would ever claim.
it('qualifies a member holding any one of the roles', () => {
// Ids on both sides — Discord reports a member's roles as snowflakes, never as names.
const supporter = '1077000000000000001'
const booster = '1077000000000000002'
const qualifying = [supporter, booster]
expect(qualifies([supporter], qualifying)).toBe(true)
expect(qualifies([booster], qualifying)).toBe(true)
expect(qualifies([booster, supporter], qualifying)).toBe(true)
// Holding some other role in the server is not enough.
expect(qualifies(['1077000000000000009'], qualifying)).toBe(false)
expect(qualifies([], qualifying)).toBe(false)
})
// The closed door, from the outside. This must be refused BEFORE the token is looked at,
// so an unconfigured server can't be talked into a claim by a valid session.
it('refuses a benefits claim when discord is only half configured', async () => {
const res = await SELF.fetch('https://example.com/api/benefits/claim', {
method: 'POST',
headers: {
'content-type': 'application/json',
authorization: `Bearer ${await tokenFor(4001)}`,
},
body: JSON.stringify({ code: 'whatever' }),
})
expect(res.status).toBe(403)
expect(await res.json()).toEqual({ error: 'Benefit claims are currently disabled.' })
})
// The benefits routes act on ONE account — the claim writes `hasPlus` onto its row — so
// which account it is has to come from a verified token and never from the request. Both
// halves of "verified" are pinned: no token, and a token signed with a key this server
// doesn't use (i.e. one it never issued).
//
// Asserted on `/api/benefits/status` because it's the benefits route whose auth gate is
// reachable here: the claim refuses on the config gate FIRST (covered above), which is
// the right order — an unconfigured server shouldn't be examining credentials for a
// feature it doesn't run — but it means an unconfigured project can't observe its 401.
it('requires a valid session to read benefits', async () => {
const path = 'https://example.com/api/benefits/status'
// No token at all.
expect((await SELF.fetch(path)).status).toBe(401)
// A token that is well-formed but signed with the wrong key.
const forged = await generateToken('4002', '', 4, 'not-the-real-secret')
const res = await SELF.fetch(path, { headers: { authorization: `Bearer ${forged}` } })
expect(res.status).toBe(401)
})
// What the claim page renders before anyone presses anything. `hasPlus` is read off the
// account ROW rather than a token claim, because it's set after the browser's token was
// issued — a freshly-claimed player's token says nothing about it.
it('reports where an account stands on benefits', async () => {
const token = await tokenFor(4003)
// An account with no row at all reads as "nothing claimed" rather than 404ing: every
// account has a benefits status, whether or not it has been written to yet.
let res = await SELF.fetch('https://example.com/api/benefits/status', {
headers: { authorization: `Bearer ${token}` },
})
expect(res.status).toBe(200)
expect(await res.json()).toEqual({ hasPlus: false, linked: false })
await updateAccount(env.DB, 4003, { hasPlus: true })
await linkPlatformIdentity(env.DB, 4003, PlatformType.Discord, '99001')
res = await SELF.fetch('https://example.com/api/benefits/status', {
headers: { authorization: `Bearer ${token}` },
})
// `linked` says THAT a Discord identity is attached, never which one — the id is of no
// use to the page, and an account's linked identities have no business on the wire.
expect(await res.json()).toEqual({ hasPlus: true, linked: true })
})
// The once-only guard the claim is built on. Without it, one Discord member holding the
// role could walk it around every RecFlare account they own; with it, the second claim is
// refused and the first account keeps the benefit. Exercised at the two lookups the route
// asks, since the route's own path to them runs through discord.com.
it('tells a repeat claim from a second account claiming the same discord identity', async () => {
await updateAccount(env.DB, 4004, { hasPlus: true })
await linkPlatformIdentity(env.DB, 4004, PlatformType.Discord, '99002')
// The identity is taken, so a DIFFERENT account claiming it is the 409 case…
await expect(
countAccountsForPlatformIdentity(env.DB, PlatformType.Discord, '99002')
).resolves.toBe(1)
await expect(isPlatformIdentityLinked(env.DB, 4005, PlatformType.Discord, '99002')).resolves.toBe(
false
)
// …while the account that already holds it re-claims idempotently, which is what makes
// the page safe to reload and a lapsed-then-restored role re-claimable.
await expect(isPlatformIdentityLinked(env.DB, 4004, PlatformType.Discord, '99002')).resolves.toBe(
true
)
// A Discord member who has claimed nowhere yet.
await expect(
countAccountsForPlatformIdentity(env.DB, PlatformType.Discord, '99003')
).resolves.toBe(0)
})
// A Discord link must never become a way INTO an account. The login picker is public and
// unauthenticated, so listing one would both offer the client an account it can't redeem
// (the grant refuses platform 101) and tell anyone which RecFlare account a Discord user
// owns — a snowflake is readable by anyone sharing a server with them. `auth` owns that
// gate; this pins that the platform www writes to is one the gate actually excludes.
it('stores the discord identity on a platform the login picker will not list', () => {
expect(CACHED_LOGIN_PLATFORMS).not.toContain(PlatformType.Discord)
})
// The privacy policy is what the Meta Horizon Store's VRC.Privacy.14 checks are run
// against, and a reviewer only sees the rendered page — so the four things they look
// for are pinned here. If a section is renamed, re-read the VRC before loosening the
+206 -1
View File
@@ -1,10 +1,35 @@
import { Hono } from 'hono'
import { useWorkersLogger } from 'workers-tagged-logger'
import { getAccount, updateAccount } from '@repo/domain/src/accounts-db'
import { PlatformType } from '@repo/domain/src/enums'
import { countOnlinePlayers } from '@repo/domain/src/presence-db'
import { logger, withDefaultCors, withOnError } from '@repo/hono-helpers'
import { validateAndGetAccountId } from '@repo/jwt'
// The `platform_account` link table, owned (and migrated) by the `auth` worker. A claimed
// Discord identity is stored there as a PlatformType.Discord link — it is an account ↔
// external identity exactly like the Steam and Meta ones, and unlike a field on the
// account blob it can answer "is this Discord user already on another account" from an
// index. Nobody logs in with it: `auth` refuses a cached_login for any platform it can't
// verify, and its picker lists only the platforms that can be (CACHED_LOGIN_PLATFORMS).
import {
countAccountsForPlatformIdentity,
getLinksForAccount,
isPlatformIdentityLinked,
linkPlatformIdentity,
} from '../../auth/src/platform-db'
import { authUnreachable } from './auth-messages'
import {
AUTHORIZE_URL,
discordConfig,
exchangeCode,
fetchGuildMembership,
qualifies,
redirectUri,
revokeToken,
SCOPES,
} from './discord'
import { docsPage, fetchSpec } from './docs'
import { privacyPage } from './privacy'
import { turnstileKeys, verifyTurnstile } from './turnstile'
@@ -21,6 +46,7 @@ import {
storageBase,
} from './upstream'
import type { Context } from 'hono'
import type { App } from './context'
/**
@@ -37,8 +63,49 @@ import type { App } from './context'
* there's no client contract being duplicated.
* - `/api/config`, which tells the SPA the Turnstile site key and where the other
* workers live, so one client build works for any operator's domain.
* - `/api/benefits/*`, the Discord-verified benefits claim, for the same reason as
* signup: the OAuth2 client secret that turns Discord's authorization code into an
* access token can't ship to a browser. It is also the only route here that writes to
* the database, and so the only one that verifies a token itself (see the section).
*/
/**
* The account behind a request's bearer token, or null.
*
* www verifies a token itself for exactly one feature. Everywhere else the browser
* carries its token to the worker that owns the data (`accounts`, `rooms`, ) and that
* worker does the checking; but the benefits claim WRITES `hasPlus` onto an account row,
* and "which account" is the whole question asking the SPA would let anyone grant Plus
* to any id. Same key, same `@repo/jwt` validation (signature, exp) every other worker
* runs.
*/
const claimant = async (c: Context<App>): Promise<number | null> =>
validateAndGetAccountId(c.req.raw, await c.env.JWT_SECRET.get())
/**
* The Discord consent URL the SPA sends the player to, fully assembled here rather than
* in the browser everything in it (the scopes the claim needs, and the redirect URI,
* which must match the token exchange byte for byte) is this worker's business, and a
* page that built its own could drift from what `/api/benefits/claim` will accept.
*
* It carries no `state`. That is the SPA's to add and to check on the way back: it's a
* per-attempt CSRF nonce, so it has to be minted by the thing that will later verify it
* (see App.tsx). Everything else about the request is fixed by the server.
*/
function authorizeUrl(request: Request, clientId: string): string {
const url = new URL(AUTHORIZE_URL)
url.search = new URLSearchParams({
client_id: clientId,
response_type: 'code',
scope: SCOPES,
redirect_uri: redirectUri(request),
// Skip Discord's "you've already authorized this app, continue?" interstitial on a
// repeat claim; the player has already pressed a button that says what this does.
prompt: 'none',
}).toString()
return url.toString()
}
const app = new Hono<App>()
.use(
'*',
@@ -60,10 +127,16 @@ const app = new Hono<App>()
// one build works for any operator. The site key is public (it ships in the widget
// markup either way); the secret never leaves the worker.
.get('/api/config', async (c) => {
const keys = await turnstileKeys(c.env)
const [keys, discord] = await Promise.all([turnstileKeys(c.env), discordConfig(c.env)])
return c.json({
signupEnabled: keys !== null,
turnstileSiteKey: keys?.siteKey ?? null,
// The benefits claim, on the same terms: open only when it's fully configured, and
// the SPA is handed a ready-made consent URL rather than the parts to build one.
// Nothing secret is served — the client id inside it is public — and the guild/role
// ids never leave the worker, since it's the worker that asks Discord the question.
benefitsEnabled: discord !== null,
discordAuthorizeUrl: discord ? authorizeUrl(c.req.raw, discord.clientId) : null,
hosts: {
auth: authBase(c.env),
accounts: accountsBase(c.env),
@@ -169,6 +242,138 @@ const app = new Hono<App>()
return c.json(token)
})
// ---- Discord benefits claim ---------------------------------------------
/**
* Where the player's Discord already stands with this account what the claim page
* renders before anyone presses anything, so a player who has already claimed sees
* that rather than being walked through the flow again to find out.
*
* `hasPlus` is read from the account row rather than from a token claim: it is set
* here, after the token in the browser was issued, so a freshly-claimed player's token
* says nothing about it until they sign in again.
*/
.get('/api/benefits/status', async (c) => {
const accountId = await claimant(c)
if (accountId === null) return c.body(null, 401)
const [account, links] = await Promise.all([
getAccount(c.env.DB, accountId),
getLinksForAccount(c.env.DB, accountId),
])
return c.json({
hasPlus: account?.hasPlus ?? false,
// Whether this account is already tied to a Discord identity — not WHICH one. The
// player knows their own Discord; the id is of no use to the page and every reason
// to keep an account's linked identities off the wire.
linked: links.some((link) => link.platform === PlatformType.Discord),
})
})
/**
* Redeem a Discord authorization code and, if the player holds the configured role in
* the configured guild, give the account Rec Room Plus.
*
* The browser sends ONLY the code. It never sees an access token: the exchange happens
* here with the client secret, the roles are read with the resulting token, and the
* token is handed straight back to Discord (see discord.ts). The redirect URI is
* derived from this request's own origin rather than accepted from the body, so the
* secret can't be used to redeem codes issued for somebody else's page.
*
* The claim is once-only PER DISCORD USER, not per account: the Discord id is stored
* beside the flag, and a code from a Discord member who has already claimed elsewhere
* is refused. Otherwise one person with the role could walk it around every account
* they own. Re-claiming on the same account is allowed and simply re-affirms the flag,
* which is what makes the page safe to reload and a lapsed-then-restored role
* re-claimable.
*
* Nothing here ever REVOKES Plus: losing the Discord role later leaves the flag set.
* That's deliberate for now a sweep would need a bot token to enumerate the guild,
* which this design specifically avoids but it does mean the flag records "held the
* role once", not "holds it today".
*/
.post('/api/benefits/claim', async (c) => {
const config = await discordConfig(c.env)
if (!config) return c.json({ error: 'Benefit claims are currently disabled.' }, 403)
const accountId = await claimant(c)
if (accountId === null) {
return c.json({ error: 'Please sign in before claiming your benefits.' }, 401)
}
type ClaimBody = { code?: string }
const { code } = await c.req.json<ClaimBody>().catch(() => ({}) as ClaimBody)
if (!code) return c.json({ error: 'No Discord authorization code was provided.' }, 400)
const accessToken = await exchangeCode(config, code, redirectUri(c.req.raw))
// A code lives about a minute and is single-use, so this is far and away the most
// likely failure a real player hits — hence a sentence about starting over rather
// than a relayed OAuth code, which would tell them nothing.
if (accessToken === null) {
return c.json(
{ error: 'That Discord sign-in could not be completed. Please try again.' },
400
)
}
const membership = await fetchGuildMembership(accessToken, config.guildId)
// The token has told us everything it can; hand it back before answering, whatever
// the answer turns out to be. Awaited rather than fired into the void so a Worker
// that finishes the response can't cancel it.
await revokeToken(config, accessToken)
if (membership === null) {
return c.json({ error: 'You are not a member of our Discord server.' }, 403)
}
// Any ONE of the configured roles qualifies — see `qualifies`. The message stays
// singular-ish and names no role: which roles qualify is the operator's business to
// advertise in their own server, and listing them here would leak the guild's role
// layout to anyone who pressed the button.
if (!qualifies(membership.roles, config.roleIds)) {
return c.json({ error: 'Your Discord account does not have a qualifying role.' }, 403)
}
// The once-only guard, asked of the link table: is this Discord identity already on an
// account, and is that account someone else's? Re-claiming on the caller's OWN account
// is the idempotent case and must fall through — it's how a player whose role lapsed
// and came back re-affirms Plus, and it's what makes the page safe to reload.
//
// `countAccountsForPlatformIdentity` counts EVERY link for the identity, unfiltered by
// platform, which is why the claim can use the same helper `auth`'s per-identity
// signup cap does.
const alreadyMine = await isPlatformIdentityLinked(
c.env.DB,
accountId,
PlatformType.Discord,
membership.userId
)
if (!alreadyMine) {
const claimedElsewhere = await countAccountsForPlatformIdentity(
c.env.DB,
PlatformType.Discord,
membership.userId
)
if (claimedElsewhere > 0) {
logger.info('a discord account tried to claim benefits on a second account', {
accountId,
})
return c.json(
{ error: 'That Discord account has already claimed benefits on another account.' },
409
)
}
}
// Both writes are idempotent: the link is INSERT OR IGNORE (so `linkedAt` keeps the
// FIRST claim's time), and the flag is already true on a re-claim.
await linkPlatformIdentity(c.env.DB, accountId, PlatformType.Discord, membership.userId)
await updateAccount(c.env.DB, accountId, { hasPlus: true })
logger.info('granted plus from a discord benefits claim', { accountId })
// The username is echoed for the confirmation line only — it is never stored, and a
// Discord member who has since renamed themselves is not a problem to solve here.
return c.json({ hasPlus: true, discordUsername: membership.username })
})
// ---- Privacy policy -----------------------------------------------------
// Server-rendered rather than a SPA route so the page has real text without
// JavaScript: the Meta Horizon Store re-fetches this URL to check the policy is
+51 -1
View File
@@ -66,6 +66,37 @@
"binding": "TURNSTILE_SECRET_KEY",
"store_id": "local",
"secret_name": "TURNSTILE_SECRET_KEY"
},
// The shared HS256 signing key, bound here for the ONE www route that acts on a
// specific account: the benefits claim writes `hasPlus` onto the caller's row, so it
// has to verify which account is calling rather than trust the SPA.
{
"binding": "JWT_SECRET",
"store_id": "local",
"secret_name": "JWT_SECRET"
},
// The Discord OAuth2 application behind the benefits claim. Same store, same
// public-key-beside-its-secret arrangement as the Turnstile pair: the client id
// ships to the browser to build the authorize URL, the secret never leaves the
// worker (see src/discord.ts).
//
// wrangler secrets-store secret create <store-id> --name DISCORD_CLIENT_ID \
// --scopes workers --remote
// wrangler secrets-store secret create <store-id> --name DISCORD_CLIENT_SECRET \
// --scopes workers --remote
//
// These two PLUS the DISCORD_GUILD_ID / DISCORD_BENEFITS_ROLE_IDS vars below are what
// OPENS the claim; with any of the four missing it stays closed, so an operator who
// skips this gets no claim rather than one that grants Plus without checking.
{
"binding": "DISCORD_CLIENT_ID",
"store_id": "local",
"secret_name": "DISCORD_CLIENT_ID"
},
{
"binding": "DISCORD_CLIENT_SECRET",
"store_id": "local",
"secret_name": "DISCORD_CLIENT_SECRET"
}
],
// The `auth` worker, reached directly instead of over its public hostname. This is
@@ -97,6 +128,25 @@
// (see run-wrangler-deploy). www serves these to the SPA via `/api/config`, which
// is how one client build works for any operator. For local dev, point it at a
// deployed domain so the page has real workers to call.
"DOMAIN": "rec.example.com"
"DOMAIN": "rec.example.com",
// The Discord server the benefits claim checks membership of, and the roles in it
// that grant Rec Room Plus. Every value here is a Discord SNOWFLAKE all digits, no
// letters copied off a client with Developer Mode on (right-click the server or the
// role Copy ID). They are ids, not names: "Supporter" is what the role is called,
// 1077000000000000002 is what goes here. Not credentials, so they live in this file
// rather than in the Secrets Store; quoted as STRINGS because a snowflake exceeds
// 2^53 and would lose precision as a JSON number.
//
// ROLE_IDS is a LIST, separated by commas and/or whitespace. Any ONE of them
// qualifies, so several tiers can share the benefit:
//
// "DISCORD_BENEFITS_ROLE_IDS": "1077000000000000001,1077000000000000002"
//
// Empty by default: an operator who hasn't set up a Discord app has no server to
// point at, and an empty value (or one that parses to no ids) closes the claim see
// src/discord.ts `discordConfig` instead of leaving a form that grants Plus to
// anyone who signs in with Discord.
"DISCORD_GUILD_ID": "",
"DISCORD_BENEFITS_ROLE_IDS": ""
}
}
+24
View File
@@ -101,6 +101,30 @@ export interface Account {
* `runx admin grant-moderator`. Absent/false means no role.
*/
isModerator?: boolean
/**
* Whether this account has Rec Room Plus the paid tier the client's API calls a
* `CampusCard`. Nothing SELLS one here. Absent/false means no Plus.
*
* This flag ALONE is what confers it, and it stands on its own: two things set it, and
* neither is a precondition of the other.
*
* - the website's benefits claim (`www` `POST /api/benefits/claim`), where a player
* proves a qualifying role in the community Discord. That path also links their
* Discord identity into `platform_account` as a `PlatformType.Discord` row but the
* link exists to keep the CLAIM once-only per Discord user, not to justify the flag.
* - an operator, via `runx admin grant-plus`, with no Discord anywhere in sight.
*
* So never read a Discord link as a precondition for Plus, and never revoke one because
* the other is missing: a manually granted account has `hasPlus` and no link at all, and
* that is a normal, supported state.
*
* Nothing reads this per request. `auth` stamps it into every token it mints as the
* `rn.plus` claim, and `econ` decides the CampusCard and the subscriber discount from
* that claim alone so setting it takes effect on the account's NEXT login, not
* immediately. Tokens last a day and the client never refreshes them, so that lag is
* real: the website's claim page warns about it, and so does `grant-plus`.
*/
hasPlus?: boolean
}
interface AccountRow {
+33
View File
@@ -5,6 +5,39 @@
* the tsconfig sets `isolatedModules` (which disallows `const enum` across files).
*/
/**
* PlatformType, the client's platform enum. Declaration order is wire order. The
* `platform` form field is posted as the integer; a token's `platform` claim carries it
* too. `auth` re-exports this as the source for its OpenAPI schema and description.
*
* A plain `as const` object rather than an `enum` like its neighbours, and deliberately
* so: `auth` builds `PlatformTypeSchema`'s description by walking `Object.entries`, and a
* numeric TS enum also emits a REVERSE mapping (`{ '0': 'Steam', Steam: 0, … }`), which
* would double every member in the generated spec.
*
* Everything from `Steam` to `Pico` is a real Rec Room client platform, numbered by the
* client. `Discord` is OURS it is not a platform anyone signs in from, and the client
* never sends it. It exists so a verified Discord identity can be stored as an account
* link like any other external identity (see `auth`'s platform-db and the website's
* benefits claim); it sits at 101, well clear of the client's range, so a future client
* platform can be added without colliding with it.
*/
export const PlatformType = {
All: -1,
Steam: 0,
Oculus: 1,
PlayStation: 2,
Xbox: 3,
RecNet: 4,
IOS: 5,
GooglePlay: 6,
Standalone: 7,
Pico: 8,
Discord: 101,
} as const
export type PlatformType = (typeof PlatformType)[keyof typeof PlatformType]
/** The kind of a room instance (live session), matching the client's `RoomInstanceType`. */
export enum RoomInstanceType {
Public = 0,
+14 -2
View File
@@ -12,9 +12,21 @@
const ITERATIONS = 100_000
const b64 = (bytes: Uint8Array): string => btoa(String.fromCharCode(...bytes))
const fromB64 = (s: string): Uint8Array => Uint8Array.from(atob(s), (ch) => ch.charCodeAt(0))
const fromB64 = (s: string): Uint8Array<ArrayBuffer> =>
Uint8Array.from(atob(s), (ch) => ch.charCodeAt(0))
async function deriveBits(password: string, salt: Uint8Array): Promise<Uint8Array> {
/**
* The salt is `Uint8Array<ArrayBuffer>` rather than a bare `Uint8Array` because the latter
* is `Uint8Array<ArrayBufferLike>`, which admits a `SharedArrayBuffer` and the DOM lib's
* `BufferSource` does not. Both callers already produce a plain-ArrayBuffer view
* (`getRandomValues` and `fromB64`), so this only writes down what was always true; without
* it, any worker whose tsconfig includes the DOM lib (`www`, for its React client) fails to
* compile on the `deriveBits` call below.
*/
async function deriveBits(
password: string,
salt: Uint8Array<ArrayBuffer>
): Promise<Uint8Array<ArrayBuffer>> {
const keyMaterial = await crypto.subtle.importKey(
'raw',
new TextEncoder().encode(password),
+1
View File
@@ -1,5 +1,6 @@
export {
validateAndGetAccountId,
validateAndGetPlus,
validateAndGetRoles,
validateAndGetVersion,
generateToken,
+40 -1
View File
@@ -82,6 +82,30 @@ export async function validateAndGetRoles(
}
}
/**
* Whether a request's bearer token says the caller has Rec Room Plus the `rn.plus`
* claim stamped by {@link generateToken} from `account.hasPlus`. This is the ONE way Plus
* is decided (see `econ`'s `isSubscriber`); nothing re-reads the account for it, which is
* why a freshly-claimed player must sign in again before it applies.
*
* False for a missing, malformed or expired token, and false for a valid token that
* simply carries no claim the two are not worth telling apart, since neither is a
* subscriber. Only a literal `true` counts, so a token carrying some other value in that
* key can't read as Plus.
*/
export async function validateAndGetPlus(request: Request, secret: string): Promise<boolean> {
const authHeader = request.headers.get('Authorization')
if (!authHeader || !authHeader.toLowerCase().startsWith('bearer ')) return false
const token = authHeader.slice('bearer '.length)
try {
const payload = await verify(token, secret, 'HS256') // checks exp/nbf/signature
return payload['rn.plus'] === true
} catch {
return false
}
}
/**
* Validate a request's bearer token and return its `rn.ver` claim the game build the
* caller posted to `/connect/token`, stamped by {@link generateToken}. `null` when the
@@ -189,7 +213,8 @@ export async function generateToken(
secret: string,
extraRoles: string[] = [],
privileges: string[] = [],
version: string = GAME_VERSION
version: string = GAME_VERSION,
hasPlus = false
): Promise<string> {
const now = Math.floor(Date.now() / 1000)
// The client reads `role`/`scope` (and expects a well-formed iss/aud) to
@@ -220,6 +245,20 @@ export async function generateToken(
// `scope`. Omitted entirely when empty, so an unrestricted token is byte-for-byte
// what it was before privileges existed.
...(privileges.length > 0 ? { 'rn.privilege': privileges } : {}),
// Whether the account has Rec Room Plus (`account.hasPlus`) — a CLAIM, like
// `rn.privilege` and for the same reason: it is ours, the client has never heard of
// it, and `scope` is a fixed list the client parses. `econ` reads it to answer the
// CampusCard lookup and to price the subscriber discount, which is the whole point
// of carrying it here: those calls then need no database read at all.
//
// Omitted when false, so a non-subscriber's token is byte-for-byte what it was
// before Plus existed, and `validateAndGetPlus` reads an absent claim as "no Plus".
//
// STAMPED AT LOGIN, so it is only as fresh as the token: a player who claims Plus on
// the website has to sign in again (and restart the game) before it takes effect.
// Tokens last a day and the client does not refresh them — see TOKEN_TTL_SECONDS —
// so that wait is real, and it is the accepted trade for making the check free.
...(hasPlus ? { 'rn.plus': true } : {}),
scope: TOKEN_SCOPES,
jti: crypto.randomUUID(),
},
+31 -8
View File
@@ -18,6 +18,7 @@ import type { D1ExecResult } from '../d1'
* runx admin clear-password --username alice [--remote]
* runx admin lookup --username alice [--remote]
* runx admin grant-developer --account 1 [--revoke] [--remote]
* runx admin grant-plus --username alice [--revoke] [--remote]
*/
/**
@@ -123,16 +124,21 @@ const clearPassword = new Command('clear-password')
})
/**
* Build a `grant-<role>` command that toggles a boolean role flag on the account
* blob. `jsonKey` is the account field (e.g. `isDeveloper`) a fixed literal, not
* user input. Both the /role/:role lookup and the token's `role` claim read it.
* Build a `grant-<thing>` command that toggles a boolean flag on the account blob.
* `jsonKey` is the account field (e.g. `isDeveloper`) a fixed literal, not user input.
*
* `noun` is what the flag IS, and it is not always "role": the role flags feed the
* /role/:role lookup and the token's `role` claim, while `hasPlus` is an entitlement that
* rides on its own `rn.plus` claim and confers no role at all. Getting that word right in
* the output is the difference between an operator believing they granted a staff power
* and knowing they granted a subscription.
*/
function grantRoleCommand(name: string, jsonKey: string, roleLabel: string) {
function grantRoleCommand(name: string, jsonKey: string, roleLabel: string, noun = 'role') {
return new Command(name)
.description(`Grant (or, with --revoke, remove) the ${roleLabel} role on an account`)
.description(`Grant (or, with --revoke, remove) ${roleLabel} on an account`)
.option('--account <id>', 'Account id to target')
.option('--username <name>', 'Username to target (case-insensitive)')
.option('--revoke', `Remove the ${roleLabel} role instead of granting it`, false)
.option('--revoke', `Remove ${roleLabel} instead of granting it`, false)
.option('--local', 'Target the local dev database (the default).', false)
.option('--remote', 'Target the deployed database instead of the local dev database.', false)
.action(async (opts) => {
@@ -141,10 +147,10 @@ function grantRoleCommand(name: string, jsonKey: string, roleLabel: string) {
const value = opts.revoke ? 'false' : 'true'
const sql = `UPDATE account SET data = json_set(data, '$.${jsonKey}', json('${value}')) WHERE ${where} RETURNING account_id`
const verb = opts.revoke ? 'Revoking' : 'Granting'
console.log(`${verb} ${roleLabel} role for ${label} on ${target(remote)}`)
console.log(`${verb} ${roleLabel} ${noun} for ${label} on ${target(remote)}`)
assertMatched(await execSql(sql, remote), label)
console.log(
chalk.green(`${roleLabel} role ${opts.revoke ? 'revoked' : 'granted'} for ${label}`)
chalk.green(`${roleLabel} ${noun} ${opts.revoke ? 'revoked' : 'granted'} for ${label}`)
)
})
}
@@ -152,6 +158,21 @@ function grantRoleCommand(name: string, jsonKey: string, roleLabel: string) {
const grantDeveloper = grantRoleCommand('grant-developer', 'isDeveloper', 'developer')
const grantModerator = grantRoleCommand('grant-moderator', 'isModerator', 'moderator')
/**
* Rec Room Plus, the account's `hasPlus` flag. Players normally get it themselves by
* claiming a Discord role on the website; this is the operator's way in and the ONLY
* one, since the `developer` role deliberately no longer confers Plus.
*
* Granting does not take effect until the account's NEXT login: `auth` stamps `hasPlus`
* into the token as `rn.plus` when it mints one, and `econ` reads nothing else. Tokens
* last a day and the client never refreshes them, so tell the player to restart the game
* and sign in again.
*
* Revoking has the same lag in reverse a player keeps Plus until their current token
* expires. It is not a way to cut someone off immediately.
*/
const grantPlus = grantRoleCommand('grant-plus', 'hasPlus', 'Rec Room Plus', 'subscription')
const lookup = new Command('lookup')
.description('Print an account by id or username')
.option('--account <id>', 'Account id to look up')
@@ -202,6 +223,7 @@ export const adminCmd = new Command('admin')
.addCommand(clearPassword)
.addCommand(grantDeveloper)
.addCommand(grantModerator)
.addCommand(grantPlus)
.addCommand(lookup)
.addHelpText(
'after',
@@ -216,5 +238,6 @@ Examples:
$ runx admin clear-password --username alice
$ runx admin grant-developer --account 1 [--revoke]
$ runx admin grant-moderator --username alice --remote
$ runx admin grant-plus --username alice # Rec Room Plus; takes effect next login
$ runx admin lookup --username alice --remote`
)
+3
View File
@@ -1243,6 +1243,9 @@ importers:
'@repo/hono-helpers':
specifier: workspace:*
version: link:../../packages/hono-helpers
'@repo/jwt':
specifier: workspace:*
version: link:../../packages/jwt
'@scalar/api-reference':
specifier: 1.63.0
version: 1.63.0(tailwindcss@4.3.3)(typescript@6.0.3)(zod@4.4.3)