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