[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
+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`
)