mirror of
https://github.com/djdevin/recflare.git
synced 2026-09-08 06:31:27 -07:00
[plus] discord role verifier to grant RR plus
This commit is contained in:
@@ -141,3 +141,30 @@ RECFLARE_DOMAIN=rec.example.com
|
|||||||
# Setting them both is what opens web signup; with either missing it stays closed. See
|
# 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
|
# DEPLOYING.md. Accounts are still created by the game either way, and both `auth` account
|
||||||
# caps above apply regardless.
|
# 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.
|
||||||
|
|||||||
@@ -1040,8 +1040,9 @@ const app = new Hono<App>()
|
|||||||
|
|
||||||
// Stamp the account's elevated roles into the token's `role` claim so the client
|
// 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
|
// authorizes developer/moderator powers from the token itself (not just the
|
||||||
// /role/* lookups). One read of the just-resolved account; roles thus refresh on
|
// /role/* lookups), and its Plus flag into `rn.plus`. One read of the just-resolved
|
||||||
// every login and every refresh_token grant.
|
// account serves both; they thus refresh on every login and every refresh_token
|
||||||
|
// grant.
|
||||||
const roleAccount = await getAccount(c.env.DB, Number(accountId))
|
const roleAccount = await getAccount(c.env.DB, Number(accountId))
|
||||||
// A refresh grant posts no platform of its own, so the identity comes off the
|
// 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.
|
// account — the same read, and the only place the bound identity is authoritative.
|
||||||
@@ -1056,7 +1057,13 @@ const app = new Hono<App>()
|
|||||||
jwtSecret,
|
jwtSecret,
|
||||||
accountRoles(roleAccount),
|
accountRoles(roleAccount),
|
||||||
accountPrivileges(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
|
// Issue a fresh, persisted refresh token (single-use; the client redeems it via
|
||||||
// grant_type=refresh_token). A refresh grant thus rotates its token.
|
// grant_type=refresh_token). A refresh grant thus rotates its token.
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { resolver } from 'hono-openapi'
|
import { resolver } from 'hono-openapi'
|
||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
|
|
||||||
|
import { PlatformType } from '@repo/domain/src/enums'
|
||||||
|
|
||||||
import type { OpenAPIV3_1 } from 'openapi-types'
|
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
|
* PlatformType, the client's platform enum — the single source for the schema and
|
||||||
* the single source for the schema and description below. The `platform` form field
|
* description below. It lives in `@repo/domain` rather than here because the link table
|
||||||
* is posted as the integer; the token's `platform` claim carries the name.
|
* (`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 = {
|
export { PlatformType } from '@repo/domain/src/enums'
|
||||||
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]
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A PlatformType by value. Only Steam and Oculus (Meta) can actually be verified —
|
* A PlatformType by value. Only Steam and Oculus (Meta) can actually be verified —
|
||||||
|
|||||||
@@ -19,8 +19,18 @@
|
|||||||
* A link is only ever written from a VERIFIED identity (a Steam-signed ticket or a
|
* 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
|
* 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.
|
* 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). */
|
/** Schema DDL (mirror of migrations/0007_platform_accounts.sql, sans the backfill). */
|
||||||
export const PLATFORM_SCHEMA_DDL: string[] = [
|
export const PLATFORM_SCHEMA_DDL: string[] = [
|
||||||
`CREATE TABLE IF NOT EXISTS platform_account (
|
`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
|
WHERE json_extract(data, '$.platformId') IS NOT NULL
|
||||||
AND json_extract(data, '$.platformId') <> ''`
|
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. */
|
/** One account ↔ platform identity link. */
|
||||||
export interface PlatformLink {
|
export interface PlatformLink {
|
||||||
accountId: number
|
accountId: number
|
||||||
@@ -119,6 +161,9 @@ export async function getLinksForPlatformIdentity(
|
|||||||
platformId: string
|
platformId: string
|
||||||
): Promise<PlatformLink[]> {
|
): Promise<PlatformLink[]> {
|
||||||
if (platformId === '') return []
|
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
|
const { results } = await db
|
||||||
.prepare(
|
.prepare(
|
||||||
`${SELECT_LINK} WHERE platform = ?1 AND platform_id = ?2 ORDER BY linked_at, account_id`
|
`${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
|
platformId: string
|
||||||
): Promise<PlatformLink[]> {
|
): Promise<PlatformLink[]> {
|
||||||
if (platformId === '') return []
|
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
|
const { results } = await db
|
||||||
.prepare(`${SELECT_LINK} WHERE platform_id = ?1 ORDER BY linked_at, account_id`)
|
.prepare(
|
||||||
.bind(platformId)
|
`${SELECT_LINK} WHERE platform_id = ?1 AND ${CACHED_LOGIN_FILTER}
|
||||||
|
ORDER BY linked_at, account_id`
|
||||||
|
)
|
||||||
|
.bind(platformId, ...CACHED_LOGIN_PLATFORMS)
|
||||||
.all<LinkRow>()
|
.all<LinkRow>()
|
||||||
return results
|
return results
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,7 +21,9 @@ import {
|
|||||||
createReport,
|
createReport,
|
||||||
SCHEMA_DDL as REPORTS_SCHEMA_DDL,
|
SCHEMA_DDL as REPORTS_SCHEMA_DDL,
|
||||||
} from '../../../../api/src/reports-db'
|
} from '../../../../api/src/reports-db'
|
||||||
|
import { PlatformType } from '../../openapi'
|
||||||
import {
|
import {
|
||||||
|
countAccountsForPlatformIdentity,
|
||||||
getLinksForAccount,
|
getLinksForAccount,
|
||||||
linkPlatformIdentity,
|
linkPlatformIdentity,
|
||||||
PLATFORM_BACKFILL_SQL,
|
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
|
// 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
|
// 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.
|
// 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')
|
expect(payload.role).not.toContain('junior')
|
||||||
// No privileges to carry, so the claim is absent rather than an empty array.
|
// No privileges to carry, so the claim is absent rather than an empty array.
|
||||||
expect(payload['rn.privilege']).toBeUndefined()
|
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')
|
expect(payload.scope).toContain('rn.api')
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -615,6 +666,64 @@ describe('auth worker routes', () => {
|
|||||||
expect(payload.role).toEqual(expect.arrayContaining(['gameClient', 'developer', 'moderator']))
|
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 () => {
|
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)')
|
await env.DB.prepare('INSERT OR IGNORE INTO account (data) VALUES (?1)')
|
||||||
.bind(
|
.bind(
|
||||||
|
|||||||
+48
-42
@@ -17,7 +17,7 @@ import {
|
|||||||
setOutfit,
|
setOutfit,
|
||||||
} from '@repo/domain'
|
} from '@repo/domain'
|
||||||
import { intVar, logger, withCleanSpec, withNotFound, withOnError } from '@repo/hono-helpers'
|
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 {
|
import {
|
||||||
getCustomAvatarItems,
|
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())
|
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 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
|
* 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
|
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,
|
* Whether the caller holds a Rec Room Plus subscription — the ONE definition, shared by
|
||||||
* shared by `UpdateAndGetSubscription` (which reports it) and the storefront buys (which
|
* `UpdateAndGetSubscription` (which reports it) and the storefront buys (which price off
|
||||||
* price off it via `SubscriberPrices`). Nothing sells subscriptions here, so holding the
|
* it via `SubscriberPrices`). Those two must never disagree: a subscriber whose client
|
||||||
* `developer` role IS the subscription; if a real subscription store ever lands, this is
|
* applied the discount itself and then had the buy refused as a price mismatch is exactly
|
||||||
* the only place that has to learn about it. Read from the token's `role` claim, never the
|
* what one definition prevents.
|
||||||
* body; no or an invalid token is "not subscribed".
|
*
|
||||||
|
* 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> {
|
async function isSubscriber(c: Context<App>): Promise<boolean> {
|
||||||
const roles = await authedRoles(c)
|
return validateAndGetPlus(c.req.raw, await c.env.JWT_SECRET.get())
|
||||||
return roles?.includes(DEVELOPER_ROLE) ?? false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** `SubscriptionLevel.Gold`. 1 is Platinum. */
|
/** `SubscriptionLevel.Gold`. 1 is Platinum. */
|
||||||
@@ -448,20 +450,21 @@ const SUBSCRIPTION_PLATFORM_ALL = -1
|
|||||||
const STUB_SUBSCRIPTION_ID = 1
|
const STUB_SUBSCRIPTION_ID = 1
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The complimentary subscription a `developer` account reports — Rec Room Plus, which the
|
* The complimentary subscription a subscriber reports — Rec Room Plus, which the client's
|
||||||
* client's API calls a `CampusCard`.
|
* 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
|
* Nothing here sells subscriptions, so holding one of those IS the subscription. Every
|
||||||
* paid-tier surfaces get exercised without a store. Every field is computed per call and
|
* field is computed per call and none of it is persisted, so this is not a record of
|
||||||
* none of it is persisted, so this is not a record of anything — revoking the role revokes
|
* anything — dropping the role or the flag drops the subscription, and no expiry sweep or
|
||||||
* the subscription, and no expiry sweep or renewal exists.
|
* renewal exists.
|
||||||
*
|
*
|
||||||
* `ExpirationDate` is a year out from THIS call rather than a fixed date: a hard-coded one
|
* `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
|
* 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.
|
* 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.
|
* 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()
|
const now = new Date()
|
||||||
// Calendar arithmetic, not now + 365 days: setUTCFullYear lands on the same date next
|
// Calendar arithmetic, not now + 365 days: setUTCFullYear lands on the same date next
|
||||||
// year whether or not a leap day falls in between.
|
// 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',
|
summary: 'Buy a storefront item',
|
||||||
description: [
|
description: [
|
||||||
'Looks the item up in its storefront catalog, confirms the client’s `RequestedPrice`',
|
'Looks the item up in its storefront catalog, confirms the client’s `RequestedPrice`',
|
||||||
'still matches the `Prices` entry — a Rec Room Plus subscriber (the same check as',
|
'still matches the `Prices` entry — a Rec Room Plus subscriber (the same `rn.plus`',
|
||||||
'`UpdateAndGetSubscription`) may pay anywhere from that down to 10% off, since their',
|
'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,',
|
'client applies the discount itself and not to every item — debits the buyer atomically,',
|
||||||
'grants the item (into the inventory or',
|
'grants the item (into the inventory or',
|
||||||
'consumable table), and returns a gift box. A `Gift` block routes the item — and its',
|
'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
|
// 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
|
// buy one from: Plus is claimed on the website by proving a Discord role, and reaches
|
||||||
// reports an active Gold year, everyone else reports none. Nothing is stored — see
|
// this worker as the token's `rn.plus` claim. A caller carrying it reports an active
|
||||||
// `developerSubscription`.
|
// 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
|
// 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
|
// 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
|
// 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(
|
.post(
|
||||||
'/api/CampusCard/v1/UpdateAndGetSubscription',
|
'/api/CampusCard/v1/UpdateAndGetSubscription',
|
||||||
describeRoute({
|
describeRoute({
|
||||||
tags: ['Econ'],
|
tags: ['Econ'],
|
||||||
summary: 'Subscription lookup',
|
summary: 'Subscription lookup',
|
||||||
description: [
|
description: [
|
||||||
'The caller’s Rec Room Plus subscription. Nothing sells subscriptions here, so the',
|
'The caller’s Rec Room Plus subscription. Nothing sells subscriptions here: Plus is',
|
||||||
'operator-granted `developer` role stands in for one: a developer’s token reports an',
|
'claimed on the website by proving a qualifying role in the community Discord, and',
|
||||||
'active Gold (`Level` 0) yearly (`Period` 1) subscription on `PlatformType` -1 (All),',
|
'arrives as the token’s `rn.plus` claim. A token carrying it reports an active Gold',
|
||||||
'expiring a year from the call, and every other caller gets `{}`. Auth is optional —',
|
'(`Level` 0) yearly (`Period` 1) subscription on `PlatformType` -1 (All), expiring a',
|
||||||
'a missing or invalid token reads as “not subscribed”, not 401. Nothing is persisted:',
|
'year from the call; every other caller gets `{}`. The `developer` role does NOT',
|
||||||
'the role IS the subscription, so revoking it revokes this.',
|
'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(' '),
|
].join(' '),
|
||||||
responses: {
|
responses: {
|
||||||
200: json(SubscriptionResponse, 'The subscription, or `{}` for no subscription'),
|
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)
|
const id = await authedId(c)
|
||||||
if (id === null) return c.json({})
|
if (id === null) return c.json({})
|
||||||
return c.json({
|
return c.json({
|
||||||
Subscription: developerSubscription(id),
|
Subscription: plusSubscription(id),
|
||||||
PlatformAccountSubscribedPlayerId: null,
|
PlatformAccountSubscribedPlayerId: null,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -116,8 +116,9 @@ export const CustomAvatarItemsResponse = z.object({
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* A Rec Room Plus subscription (the client calls it a `CampusCard`). Nothing here sells one,
|
* 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
|
* so this is the complimentary subscription reported by a caller whose token carries
|
||||||
* `developerSubscription` in econ.app.ts for why each field reads the way it does.
|
* `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({
|
export const SubscriptionDto = z.object({
|
||||||
SubscriptionId: z.int().describe('Placeholder — no subscription is stored'),
|
SubscriptionId: z.int().describe('Placeholder — no subscription is stored'),
|
||||||
|
|||||||
@@ -285,12 +285,21 @@ async function bearer(
|
|||||||
sub = '42',
|
sub = '42',
|
||||||
roles?: string[],
|
roles?: string[],
|
||||||
/** The client build to stamp as `rn.ver` — omitted, like a token minted before the claim. */
|
/** 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>> {
|
): Promise<Record<string, string>> {
|
||||||
const now = Math.floor(Date.now() / 1000)
|
const now = Math.floor(Date.now() / 1000)
|
||||||
const claims: Record<string, unknown> = { sub, exp: now + 3600 }
|
const claims: Record<string, unknown> = { sub, exp: now + 3600 }
|
||||||
if (roles !== undefined) claims.role = roles
|
if (roles !== undefined) claims.role = roles
|
||||||
if (version !== undefined) claims['rn.ver'] = version
|
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(
|
const signingInput = `${b64url(JSON.stringify({ alg: 'HS256', typ: 'JWT' }))}.${b64url(
|
||||||
JSON.stringify(claims)
|
JSON.stringify(claims)
|
||||||
)}`
|
)}`
|
||||||
@@ -1770,7 +1779,7 @@ describe('econ endpoints', () => {
|
|||||||
|
|
||||||
test('POST /api/storefronts/v2/buyItem charges a subscriber the SubscriberPrices entry', async () => {
|
test('POST /api/storefronts/v2/buyItem charges a subscriber the SubscriberPrices entry', async () => {
|
||||||
await drainFrames()
|
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(res.status).toBe(200)
|
||||||
expect(((await res.json()) as { Balance: number }).Balance).toBe(-85)
|
expect(((await res.json()) as { Balance: number }).Balance).toBe(-85)
|
||||||
const bal = await exports.default.fetch(`${ORIGIN}/api/storefronts/v4/balance/2`, {
|
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`, {
|
const res = await exports.default.fetch(`${ORIGIN}/api/storefronts/v2/buyItem`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
...(await bearer('325', ['gameClient', 'developer'])),
|
...(await bearer('325', ['gameClient'], undefined, true)),
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
@@ -1807,7 +1816,7 @@ describe('econ endpoints', () => {
|
|||||||
const res = await exports.default.fetch(`${ORIGIN}/api/storefronts/v2/buyItem`, {
|
const res = await exports.default.fetch(`${ORIGIN}/api/storefronts/v2/buyItem`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
...(await bearer('326', ['gameClient', 'developer'])),
|
...(await bearer('326', ['gameClient'], undefined, true)),
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
@@ -1828,10 +1837,10 @@ describe('econ endpoints', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
test('POST /api/storefronts/v2/buyItem 409s a subscriber below the discount band', async () => {
|
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)
|
expect(res.status).toBe(409)
|
||||||
// …and above it: a made-up price is a mismatch in either direction.
|
// …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)
|
expect(over.status).toBe(409)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -3593,7 +3602,9 @@ describe('econ endpoints', () => {
|
|||||||
"DELETE FROM reward_status WHERE account_id = 83 AND gift_context = 'Dodgeball'"
|
"DELETE FROM reward_status WHERE account_id = 83 AND gift_context = 'Dodgeball'"
|
||||||
).run()
|
).run()
|
||||||
expect((await request('rewardType=PostGameActivity&giftContext=Dodgeball')).status).toBe(200)
|
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)
|
if (i < 3) handed.push(latest?.AvatarItemDesc as string)
|
||||||
else expect(latest).toMatchObject({ AvatarItemDesc: '', GiftContext: 50 })
|
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 () => {
|
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)
|
expect(res.status).toBe(200)
|
||||||
const body = (await res.json()) as {
|
const body = (await res.json()) as {
|
||||||
Subscription: Record<string, unknown>
|
Subscription: Record<string, unknown>
|
||||||
@@ -3777,7 +3788,7 @@ describe('econ endpoints', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
test('POST /api/CampusCard/v1/UpdateAndGetSubscription is {} without the developer role', async () => {
|
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({})
|
expect(await (await getSubscription(await bearer('206', ['gameClient']))).json()).toEqual({})
|
||||||
// A token with no `role` claim at all.
|
// A token with no `role` claim at all.
|
||||||
expect(await (await getSubscription(await bearer('206'))).json()).toEqual({})
|
expect(await (await getSubscription(await bearer('206'))).json()).toEqual({})
|
||||||
@@ -3787,6 +3798,56 @@ describe('econ endpoints', () => {
|
|||||||
expect(await anon.json()).toEqual({})
|
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 () => {
|
test('unknown path returns 404', async () => {
|
||||||
const res = await exports.default.fetch(`${ORIGIN}/nope`)
|
const res = await exports.default.fetch(`${ORIGIN}/nope`)
|
||||||
expect(res.status).toBe(404)
|
expect(res.status).toBe(404)
|
||||||
|
|||||||
@@ -81,6 +81,125 @@ printf '1x0000000000000000000000000000000AA' |
|
|||||||
|
|
||||||
The tests seed the same pair into their own local store in `beforeAll`.
|
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
|
## Development
|
||||||
|
|
||||||
### Run in dev mode
|
### Run in dev mode
|
||||||
|
|||||||
@@ -18,6 +18,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@repo/domain": "workspace:*",
|
"@repo/domain": "workspace:*",
|
||||||
"@repo/hono-helpers": "workspace:*",
|
"@repo/hono-helpers": "workspace:*",
|
||||||
|
"@repo/jwt": "workspace:*",
|
||||||
"@scalar/api-reference": "1.63.0",
|
"@scalar/api-reference": "1.63.0",
|
||||||
"hono": "4.12.27",
|
"hono": "4.12.27",
|
||||||
"react": "19.2.7",
|
"react": "19.2.7",
|
||||||
|
|||||||
+296
-7
@@ -46,6 +46,18 @@ interface Hosts {
|
|||||||
interface SiteConfig {
|
interface SiteConfig {
|
||||||
signupEnabled: boolean
|
signupEnabled: boolean
|
||||||
turnstileSiteKey: string | null
|
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`). */
|
/** The private self DTO from `accounts` (`GET /account/me`). */
|
||||||
@@ -495,6 +507,50 @@ const changePassword = (oldPassword: string, newPassword: string): Promise<unkno
|
|||||||
authed: true,
|
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
|
* 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
|
* — 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 aren’t 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 we’ll 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 won’t 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 doesn’t 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 you’re 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
|
* 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
|
* 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(() => setAccount(null))
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
setConfig({ signupEnabled: false, turnstileSiteKey: null })
|
setConfig({
|
||||||
|
signupEnabled: false,
|
||||||
|
turnstileSiteKey: null,
|
||||||
|
benefitsEnabled: false,
|
||||||
|
discordAuthorizeUrl: null,
|
||||||
|
})
|
||||||
setAccount(null)
|
setAccount(null)
|
||||||
})
|
})
|
||||||
}, [])
|
}, [])
|
||||||
@@ -627,7 +896,11 @@ export function App() {
|
|||||||
onAuthed={setAccount}
|
onAuthed={setAccount}
|
||||||
/>
|
/>
|
||||||
) : path === '/account' ? (
|
) : 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 ? (
|
) : roomId !== null ? (
|
||||||
<RoomPage account={account} roomId={roomId} navigate={navigate} />
|
<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. */
|
/** The signed-in account page. Redirects to sign-in when there's no session. */
|
||||||
function AccountPage({
|
function AccountPage({
|
||||||
account,
|
account,
|
||||||
|
config,
|
||||||
navigate,
|
navigate,
|
||||||
onChange,
|
onChange,
|
||||||
}: {
|
}: {
|
||||||
account: SelfAccount | null | undefined
|
account: SelfAccount | null | undefined
|
||||||
|
config: SiteConfig | undefined
|
||||||
navigate: Navigate
|
navigate: Navigate
|
||||||
onChange: (a: SelfAccount) => void
|
onChange: (a: SelfAccount) => void
|
||||||
}) {
|
}) {
|
||||||
@@ -1052,7 +1327,7 @@ function AccountPage({
|
|||||||
return (
|
return (
|
||||||
<main className="shell wide">
|
<main className="shell wide">
|
||||||
<h1>My account</h1>
|
<h1>My account</h1>
|
||||||
<Dashboard account={account} navigate={navigate} onChange={onChange} />
|
<Dashboard account={account} config={config} navigate={navigate} onChange={onChange} />
|
||||||
</main>
|
</main>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -1382,10 +1657,10 @@ function BlobUpload({
|
|||||||
<span className="badge beta">Beta</span>
|
<span className="badge beta">Beta</span>
|
||||||
</p>
|
</p>
|
||||||
<p className="muted blob-upload-caveat">
|
<p className="muted blob-upload-caveat">
|
||||||
New and lightly tested. Nothing here checks the file — the server stores whatever it
|
New and lightly tested. Nothing here checks the file — the server stores whatever it is and
|
||||||
is and the game finds out on load. This server runs the {CLIENT_BUILD_DATE} build, so
|
the game finds out on load. This server runs the {CLIENT_BUILD_DATE} build, so scene data
|
||||||
scene data from a room built on anything newer may not load at all. Download the save
|
from a room built on anything newer may not load at all. Download the save above and keep it
|
||||||
above and keep it before replacing it.
|
before replacing it.
|
||||||
</p>
|
</p>
|
||||||
<label className="blob-upload-file">
|
<label className="blob-upload-file">
|
||||||
Scene data file
|
Scene data file
|
||||||
@@ -1776,10 +2051,12 @@ function LoginForm({ onAuthed }: { onAuthed: (a: SelfAccount) => void }) {
|
|||||||
|
|
||||||
function Dashboard({
|
function Dashboard({
|
||||||
account,
|
account,
|
||||||
|
config,
|
||||||
navigate,
|
navigate,
|
||||||
onChange,
|
onChange,
|
||||||
}: {
|
}: {
|
||||||
account: SelfAccount
|
account: SelfAccount
|
||||||
|
config: SiteConfig | undefined
|
||||||
navigate: Navigate
|
navigate: Navigate
|
||||||
onChange: (a: SelfAccount) => void
|
onChange: (a: SelfAccount) => void
|
||||||
}) {
|
}) {
|
||||||
@@ -1800,6 +2077,18 @@ function Dashboard({
|
|||||||
render: () => <EmailForm account={account} onChange={onChange} />,
|
render: () => <EmailForm account={account} onChange={onChange} />,
|
||||||
},
|
},
|
||||||
{ id: 'password', label: 'Password', render: () => <PasswordForm /> },
|
{ 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()
|
...(isAdmin()
|
||||||
? [
|
? [
|
||||||
{ id: 'maintenance', label: 'Server maintenance', render: () => <MaintenanceForm /> },
|
{ id: 'maintenance', label: 'Server maintenance', render: () => <MaintenanceForm /> },
|
||||||
|
|||||||
+47
-3
@@ -7,9 +7,11 @@ export type Env = SharedHonoEnv & {
|
|||||||
/** Static-asset fetcher for the built React SPA (see wrangler.jsonc `assets`). */
|
/** Static-asset fetcher for the built React SPA (see wrangler.jsonc `assets`). */
|
||||||
ASSETS: Fetcher
|
ASSETS: Fetcher
|
||||||
/**
|
/**
|
||||||
* The shared `recflare` D1, bound READ-ONLY in practice: the only thing www asks it
|
* The shared `recflare` D1. www asks it two things: the live presence head-count
|
||||||
* is the live presence head-count behind `/server-status`. Every table it can see is
|
* behind `/server-status`, and the caller's `account` row on the benefits claim —
|
||||||
* owned (and migrated) by another worker.
|
* 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
|
DB: D1Database
|
||||||
/**
|
/**
|
||||||
@@ -39,6 +41,48 @@ export type Env = SharedHonoEnv & {
|
|||||||
* failing to resolve closes web signup — see src/turnstile.ts.
|
* failing to resolve closes web signup — see src/turnstile.ts.
|
||||||
*/
|
*/
|
||||||
TURNSTILE_SECRET_KEY: SecretsStoreSecret
|
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 */
|
/** Variables can be extended */
|
||||||
|
|||||||
@@ -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) })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,8 +1,19 @@
|
|||||||
import { adminSecretsStore, env, SELF } from 'cloudflare:test'
|
import { adminSecretsStore, env, SELF } from 'cloudflare:test'
|
||||||
import { beforeAll, expect, it } from 'vitest'
|
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 { 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 { DOCUMENTED_SERVICES } from '../../docs'
|
||||||
import { DISCORD_INVITE, ISSUES_URL, PRIVACY_EMAIL } from '../../links'
|
import { DISCORD_INVITE, ISSUES_URL, PRIVACY_EMAIL } from '../../links'
|
||||||
import { turnstileKeys } from '../../turnstile'
|
import { turnstileKeys } from '../../turnstile'
|
||||||
@@ -21,12 +32,37 @@ declare module 'cloudflare:test' {
|
|||||||
const TEST_SITE_KEY = '1x00000000000000000000AA'
|
const TEST_SITE_KEY = '1x00000000000000000000AA'
|
||||||
const TEST_SECRET_KEY = '1x0000000000000000000000000000000AA'
|
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 () => {
|
beforeAll(async () => {
|
||||||
await adminSecretsStore(env.TURNSTILE_SITE_KEY).create(TEST_SITE_KEY)
|
await adminSecretsStore(env.TURNSTILE_SITE_KEY).create(TEST_SITE_KEY)
|
||||||
await adminSecretsStore(env.TURNSTILE_SECRET_KEY).create(TEST_SECRET_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
|
// `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.
|
// 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()
|
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:
|
// 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({
|
expect(await res.json()).toEqual({
|
||||||
signupEnabled: true,
|
signupEnabled: true,
|
||||||
turnstileSiteKey: TEST_SITE_KEY,
|
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: {
|
hosts: {
|
||||||
auth: 'https://auth.rec.example.com',
|
auth: 'https://auth.rec.example.com',
|
||||||
accounts: 'https://accounts.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)
|
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.1–4 checks are run
|
// The privacy policy is what the Meta Horizon Store's VRC.Privacy.1–4 checks are run
|
||||||
// against, and a reviewer only sees the rendered page — so the four things they look
|
// 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
|
// for are pinned here. If a section is renamed, re-read the VRC before loosening the
|
||||||
|
|||||||
+206
-1
@@ -1,10 +1,35 @@
|
|||||||
import { Hono } from 'hono'
|
import { Hono } from 'hono'
|
||||||
import { useWorkersLogger } from 'workers-tagged-logger'
|
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 { countOnlinePlayers } from '@repo/domain/src/presence-db'
|
||||||
import { logger, withDefaultCors, withOnError } from '@repo/hono-helpers'
|
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 { authUnreachable } from './auth-messages'
|
||||||
|
import {
|
||||||
|
AUTHORIZE_URL,
|
||||||
|
discordConfig,
|
||||||
|
exchangeCode,
|
||||||
|
fetchGuildMembership,
|
||||||
|
qualifies,
|
||||||
|
redirectUri,
|
||||||
|
revokeToken,
|
||||||
|
SCOPES,
|
||||||
|
} from './discord'
|
||||||
import { docsPage, fetchSpec } from './docs'
|
import { docsPage, fetchSpec } from './docs'
|
||||||
import { privacyPage } from './privacy'
|
import { privacyPage } from './privacy'
|
||||||
import { turnstileKeys, verifyTurnstile } from './turnstile'
|
import { turnstileKeys, verifyTurnstile } from './turnstile'
|
||||||
@@ -21,6 +46,7 @@ import {
|
|||||||
storageBase,
|
storageBase,
|
||||||
} from './upstream'
|
} from './upstream'
|
||||||
|
|
||||||
|
import type { Context } from 'hono'
|
||||||
import type { App } from './context'
|
import type { App } from './context'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -37,8 +63,49 @@ import type { App } from './context'
|
|||||||
* there's no client contract being duplicated.
|
* there's no client contract being duplicated.
|
||||||
* - `/api/config`, which tells the SPA the Turnstile site key and where the other
|
* - `/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.
|
* 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>()
|
const app = new Hono<App>()
|
||||||
.use(
|
.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
|
// 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.
|
// markup either way); the secret never leaves the worker.
|
||||||
.get('/api/config', async (c) => {
|
.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({
|
return c.json({
|
||||||
signupEnabled: keys !== null,
|
signupEnabled: keys !== null,
|
||||||
turnstileSiteKey: keys?.siteKey ?? 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: {
|
hosts: {
|
||||||
auth: authBase(c.env),
|
auth: authBase(c.env),
|
||||||
accounts: accountsBase(c.env),
|
accounts: accountsBase(c.env),
|
||||||
@@ -169,6 +242,138 @@ const app = new Hono<App>()
|
|||||||
return c.json(token)
|
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 -----------------------------------------------------
|
// ---- Privacy policy -----------------------------------------------------
|
||||||
// Server-rendered rather than a SPA route so the page has real text without
|
// 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
|
// JavaScript: the Meta Horizon Store re-fetches this URL to check the policy is
|
||||||
|
|||||||
+51
-1
@@ -66,6 +66,37 @@
|
|||||||
"binding": "TURNSTILE_SECRET_KEY",
|
"binding": "TURNSTILE_SECRET_KEY",
|
||||||
"store_id": "local",
|
"store_id": "local",
|
||||||
"secret_name": "TURNSTILE_SECRET_KEY"
|
"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
|
// 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
|
// (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
|
// 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.
|
// 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": ""
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -101,6 +101,30 @@ export interface Account {
|
|||||||
* `runx admin grant-moderator`. Absent/false means no role.
|
* `runx admin grant-moderator`. Absent/false means no role.
|
||||||
*/
|
*/
|
||||||
isModerator?: boolean
|
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 {
|
interface AccountRow {
|
||||||
|
|||||||
@@ -5,6 +5,39 @@
|
|||||||
* the tsconfig sets `isolatedModules` (which disallows `const enum` across files).
|
* 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`. */
|
/** The kind of a room instance (live session), matching the client's `RoomInstanceType`. */
|
||||||
export enum RoomInstanceType {
|
export enum RoomInstanceType {
|
||||||
Public = 0,
|
Public = 0,
|
||||||
|
|||||||
@@ -12,9 +12,21 @@
|
|||||||
const ITERATIONS = 100_000
|
const ITERATIONS = 100_000
|
||||||
|
|
||||||
const b64 = (bytes: Uint8Array): string => btoa(String.fromCharCode(...bytes))
|
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(
|
const keyMaterial = await crypto.subtle.importKey(
|
||||||
'raw',
|
'raw',
|
||||||
new TextEncoder().encode(password),
|
new TextEncoder().encode(password),
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
export {
|
export {
|
||||||
validateAndGetAccountId,
|
validateAndGetAccountId,
|
||||||
|
validateAndGetPlus,
|
||||||
validateAndGetRoles,
|
validateAndGetRoles,
|
||||||
validateAndGetVersion,
|
validateAndGetVersion,
|
||||||
generateToken,
|
generateToken,
|
||||||
|
|||||||
+40
-1
@@ -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
|
* 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
|
* caller posted to `/connect/token`, stamped by {@link generateToken}. `null` when the
|
||||||
@@ -189,7 +213,8 @@ export async function generateToken(
|
|||||||
secret: string,
|
secret: string,
|
||||||
extraRoles: string[] = [],
|
extraRoles: string[] = [],
|
||||||
privileges: string[] = [],
|
privileges: string[] = [],
|
||||||
version: string = GAME_VERSION
|
version: string = GAME_VERSION,
|
||||||
|
hasPlus = false
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
const now = Math.floor(Date.now() / 1000)
|
const now = Math.floor(Date.now() / 1000)
|
||||||
// The client reads `role`/`scope` (and expects a well-formed iss/aud) to
|
// 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
|
// `scope`. Omitted entirely when empty, so an unrestricted token is byte-for-byte
|
||||||
// what it was before privileges existed.
|
// what it was before privileges existed.
|
||||||
...(privileges.length > 0 ? { 'rn.privilege': privileges } : {}),
|
...(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,
|
scope: TOKEN_SCOPES,
|
||||||
jti: crypto.randomUUID(),
|
jti: crypto.randomUUID(),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import type { D1ExecResult } from '../d1'
|
|||||||
* runx admin clear-password --username alice [--remote]
|
* runx admin clear-password --username alice [--remote]
|
||||||
* runx admin lookup --username alice [--remote]
|
* runx admin lookup --username alice [--remote]
|
||||||
* runx admin grant-developer --account 1 [--revoke] [--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
|
* Build a `grant-<thing>` command that toggles a boolean flag on the account blob.
|
||||||
* blob. `jsonKey` is the account field (e.g. `isDeveloper`) — a fixed literal, not
|
* `jsonKey` is the account field (e.g. `isDeveloper`) — a fixed literal, not user input.
|
||||||
* user input. Both the /role/:role lookup and the token's `role` claim read it.
|
*
|
||||||
|
* `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)
|
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('--account <id>', 'Account id to target')
|
||||||
.option('--username <name>', 'Username to target (case-insensitive)')
|
.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('--local', 'Target the local dev database (the default).', false)
|
||||||
.option('--remote', 'Target the deployed database instead of the local dev database.', false)
|
.option('--remote', 'Target the deployed database instead of the local dev database.', false)
|
||||||
.action(async (opts) => {
|
.action(async (opts) => {
|
||||||
@@ -141,10 +147,10 @@ function grantRoleCommand(name: string, jsonKey: string, roleLabel: string) {
|
|||||||
const value = opts.revoke ? 'false' : 'true'
|
const value = opts.revoke ? 'false' : 'true'
|
||||||
const sql = `UPDATE account SET data = json_set(data, '$.${jsonKey}', json('${value}')) WHERE ${where} RETURNING account_id`
|
const sql = `UPDATE account SET data = json_set(data, '$.${jsonKey}', json('${value}')) WHERE ${where} RETURNING account_id`
|
||||||
const verb = opts.revoke ? 'Revoking' : 'Granting'
|
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)
|
assertMatched(await execSql(sql, remote), label)
|
||||||
console.log(
|
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 grantDeveloper = grantRoleCommand('grant-developer', 'isDeveloper', 'developer')
|
||||||
const grantModerator = grantRoleCommand('grant-moderator', 'isModerator', 'moderator')
|
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')
|
const lookup = new Command('lookup')
|
||||||
.description('Print an account by id or username')
|
.description('Print an account by id or username')
|
||||||
.option('--account <id>', 'Account id to look up')
|
.option('--account <id>', 'Account id to look up')
|
||||||
@@ -202,6 +223,7 @@ export const adminCmd = new Command('admin')
|
|||||||
.addCommand(clearPassword)
|
.addCommand(clearPassword)
|
||||||
.addCommand(grantDeveloper)
|
.addCommand(grantDeveloper)
|
||||||
.addCommand(grantModerator)
|
.addCommand(grantModerator)
|
||||||
|
.addCommand(grantPlus)
|
||||||
.addCommand(lookup)
|
.addCommand(lookup)
|
||||||
.addHelpText(
|
.addHelpText(
|
||||||
'after',
|
'after',
|
||||||
@@ -216,5 +238,6 @@ Examples:
|
|||||||
$ runx admin clear-password --username alice
|
$ runx admin clear-password --username alice
|
||||||
$ runx admin grant-developer --account 1 [--revoke]
|
$ runx admin grant-developer --account 1 [--revoke]
|
||||||
$ runx admin grant-moderator --username alice --remote
|
$ 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`
|
$ runx admin lookup --username alice --remote`
|
||||||
)
|
)
|
||||||
|
|||||||
Generated
+3
@@ -1243,6 +1243,9 @@ importers:
|
|||||||
'@repo/hono-helpers':
|
'@repo/hono-helpers':
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../../packages/hono-helpers
|
version: link:../../packages/hono-helpers
|
||||||
|
'@repo/jwt':
|
||||||
|
specifier: workspace:*
|
||||||
|
version: link:../../packages/jwt
|
||||||
'@scalar/api-reference':
|
'@scalar/api-reference':
|
||||||
specifier: 1.63.0
|
specifier: 1.63.0
|
||||||
version: 1.63.0(tailwindcss@4.3.3)(typescript@6.0.3)(zod@4.4.3)
|
version: 1.63.0(tailwindcss@4.3.3)(typescript@6.0.3)(zod@4.4.3)
|
||||||
|
|||||||
Reference in New Issue
Block a user