From 839799723423c0382e2218c4c94c756b555082be Mon Sep 17 00:00:00 2001 From: Devin Zuczek Date: Sat, 24 Oct 2026 15:00:49 -0400 Subject: [PATCH] [studio] rollout support --- .env.example | 12 +++++ CLI.md | 3 +- apps/auth/README.md | 7 ++- apps/auth/src/auth.app.ts | 55 ++++++++++---------- apps/auth/src/context.ts | 12 +++++ apps/auth/src/platform-db.ts | 60 ++++++++++++++++++++++ apps/auth/src/test/integration/api.test.ts | 48 +++++++++++++++++ apps/studio/src/context.ts | 7 +++ apps/studio/src/studio.app.ts | 11 +++- apps/www/src/context.ts | 8 +++ apps/www/src/studio-access.ts | 11 ++-- apps/www/src/test/integration/api.test.ts | 21 ++++++++ 12 files changed, 218 insertions(+), 37 deletions(-) diff --git a/.env.example b/.env.example index 8e04fe4..9efc211 100644 --- a/.env.example +++ b/.env.example @@ -279,6 +279,18 @@ RECFLARE_DOMAIN=rec.example.com # link Discord at /claim, and come back. # RECFLARE_DISCORD_ROLLOUT_ROLE_IDS=1077000000000000001,1077000000000000002 +# OPTIONAL: a staged rollout of Rec Room Studio to Discord roles (`auth`, `studio`, `www`). +# The Discord ROLE ids whose holders get Studio upload access — `auth` stamps the +# `betastudio` role into their token exactly as it does for an account staff flagged +# `hasStudio` on the website, and `studio`'s access lookups and the website's Studio +# settings page say "granted" for them too. The flag keeps working beside this, so staff +# can still add individuals. Same rules as ROLE_IDS above: snowflakes, not names; commas, no +# spaces; ANY one qualifies; roles come from the snapshot the benefits claim and the daily +# sweep keep, so a newly granted role counts once the sweep runs (or the player re-claims), +# and the player's Studio token picks it up on their next sign-in or refresh. UNSET — or +# parsing to no roles — grants nobody: the flag alone decides. +# RECFLARE_STUDIO_ROLLOUT=1077000000000000001,1077000000000000002 + # OPTIONAL: a token gift for Discord supporters (`econ` cron). Maps Discord ROLE ids to the # RecCenterTokens a holder is handed in a gift box EVERY TIME THE CRON FIRES, as # `=` pairs. How often is the schedule's call: `triggers.crons` in diff --git a/CLI.md b/CLI.md index 3c0f539..44d51da 100644 --- a/CLI.md +++ b/CLI.md @@ -56,7 +56,8 @@ bun runx admin grant-moderator --username alice --remote Sets the account's `hasStudio` flag, which `auth` stamps into the token as the `betastudio` role — the claim RecFlare Studio reads as permission to upload. Staff normally manage this from the website (`/settings/recroomstudio`); this is the -operator's way in. Takes effect on the player's next Studio sign-in or refresh. +operator's way in. Takes effect on the player's next Studio sign-in or refresh. To open +Studio to a whole Discord role instead, name it in `RECFLARE_STUDIO_ROLLOUT` (`.env`). ```sh bun runx admin grant-studio --username alice diff --git a/apps/auth/README.md b/apps/auth/README.md index 4d97915..d21d93e 100644 --- a/apps/auth/README.md +++ b/apps/auth/README.md @@ -63,8 +63,11 @@ claim, so developer/moderator powers refresh on every login and every refresh gr Grant those flags with `runx admin grant-developer` / `grant-moderator`. `betastudio` is a separate claim. RecFlare Studio treats it as permission to upload, -and developer does not include it. It is stamped from the account's `hasStudio` flag. -Staff add and remove people at `/settings/recroomstudio` on the website (or +and developer does not include it. It is stamped from the account's `hasStudio` flag, +or from a Discord link holding a role named in `STUDIO_ROLLOUT` +(`RECFLARE_STUDIO_ROLLOUT` in the root `.env`) — a staged Studio rollout to a Discord +role, read from the same role snapshot as the build rollout gate. Staff add and remove +individual people at `/settings/recroomstudio` on the website (or `runx admin grant-studio`). A player already signed into Studio keeps the old token until the next sign-in or refresh. diff --git a/apps/auth/src/auth.app.ts b/apps/auth/src/auth.app.ts index 34fdec0..97a708c 100644 --- a/apps/auth/src/auth.app.ts +++ b/apps/auth/src/auth.app.ts @@ -70,13 +70,15 @@ import { TokenResponse, } from './openapi' import { + accountHoldsDiscordRole, countAccountsForPlatformIdentity, - getLinksForAccount, getLinksForIdentity, getLinksForPlatformId, getLinksForPlatformIdentity, + hasStudioAccess, isPlatformIdentityLinked, linkPlatformIdentity, + parseRoleIdList, } from './platform-db' import { consumeRefreshToken, issueRefreshToken } from './refresh-db' import { verifySteamTicket } from './steam-ticket' @@ -230,16 +232,11 @@ const UNSUPPORTED_BUILD_DESCRIPTION = /** * `DISCORD_ROLLOUT_ROLE_IDS` as a list of role ids: snowflakes separated by commas and/or - * whitespace (the same grammar `www`'s parseRoleIds reads DISCORD_BENEFITS_ROLE_IDS with). - * Unset or blank parses to an empty list, which `accountMayUseRolloutBuild` treats as a - * gate closed to everyone but staff. + * whitespace (`parseRoleIdList`, the grammar `www`'s parseRoleIds reads + * DISCORD_BENEFITS_ROLE_IDS with). Unset or blank parses to an empty list, which + * `accountMayUseRolloutBuild` treats as a gate closed to everyone but staff. */ -function rolloutRoleIds(raw: string | undefined): string[] { - return (raw ?? '') - .split(/[\s,]+/) - .map((id) => id.trim()) - .filter((id) => id !== '') -} +const rolloutRoleIds = parseRoleIdList /** * Whether an account may sign in on a rollout-gated build. STAFF — a developer or moderator @@ -260,11 +257,7 @@ async function accountMayUseRolloutBuild( ): Promise { const account = await getAccount(db, accountId) if (account && (holdsRole(account, 'developer') || holdsRole(account, 'moderator'))) return true - if (roleIds.length === 0) return false - const links = await getLinksForAccount(db, accountId) - return links.some( - (link) => link.platform === PlatformType.Discord && link.roles.some((r) => roleIds.includes(r)) - ) + return accountHoldsDiscordRole(db, accountId, roleIds) } /** New players start in the Orientation room (RoomId 13) — the new-user flow. */ @@ -394,25 +387,25 @@ async function roleFilterAnswer(c: Context, role: 'developer' | 'moderator' * is unconditional (even with no account resolved). The rest are the operator-granted * extras, plus `junior` off the account's own `isJunior` flag. * - * `betastudio` rides on the account's `hasStudio` flag, and developer does not imply it. - * RecFlare Studio treats that exact claim as Full access, which is what lets the editor - * upload; staff set the flag from the website. `influencer` rides on `isInfluencer` - * (`runx admin grant-influencer`) and lives only in the token — no `/role/*` lookup - * answers it. Order is stable so tokens are deterministic: screenshare, developer, - * moderator, junior, betastudio, then influencer. + * `betastudio` rides on `studio` — the account's Studio access as `hasStudioAccess` + * resolves it: the `hasStudio` flag staff set from the website, OR a Discord link holding + * a role in the `STUDIO_ROLLOUT` var — and developer does not imply it. RecFlare Studio + * treats that exact claim as Full access, which is what lets the editor upload. The + * caller resolves it (it costs a link read) so this stays a pure projection of the + * account. `influencer` rides on `isInfluencer` (`runx admin grant-influencer`) and lives + * only in the token — no `/role/*` lookup answers it. Order is stable so tokens are + * deterministic: screenshare, developer, moderator, junior, betastudio, then influencer. */ function accountRoles( - account: Pick< - Account, - 'isDeveloper' | 'isModerator' | 'isJunior' | 'hasStudio' | 'isInfluencer' - > | null + account: Pick | null, + studio: boolean ): string[] { const roles = ['screenshare'] if (!account) return roles if (account.isDeveloper) roles.push('developer') if (account.isModerator) roles.push('moderator') if (account.isJunior) roles.push('junior') - if (account.hasStudio) roles.push('betastudio') + if (studio) roles.push('betastudio') if (account.isInfluencer) roles.push('influencer') return roles } @@ -500,7 +493,7 @@ async function issueSession( roleAccount.platformId ?? '', accountPlatform(roleAccount), jwtSecret, - accountRoles(roleAccount), + accountRoles(roleAccount, await hasStudioAccess(c.env.DB, roleAccount, c.env.STUDIO_ROLLOUT)), accountPrivileges(roleAccount), GAME_VERSION, roleAccount.hasPlus === true @@ -1112,7 +1105,8 @@ const app = new Hono() 'powers refresh on every login and every refresh grant. `junior` rides along for an', 'account flagged `isJunior`, and `screenshare` is on every token — it is a feature', 'gate the client reads, not a privilege anyone is granted. `betastudio` is stamped', - 'from the account’s `hasStudio` flag (staff set it on the website). RecFlare Studio', + 'from the account’s `hasStudio` flag (staff set it on the website) or from a Discord', + 'link holding a role in the `STUDIO_ROLLOUT` var. RecFlare Studio', 'treats that claim as permission to upload; `developer` does not confer it. A junior', 'also carries', 'the `rn.privilege` CLAIM (`BanVChat`, `BanRmChat`) — scope-shaped name, but the', @@ -1706,7 +1700,10 @@ const app = new Hono() platformId, platform, jwtSecret, - accountRoles(roleAccount), + accountRoles( + roleAccount, + await hasStudioAccess(c.env.DB, roleAccount, c.env.STUDIO_ROLLOUT) + ), accountPrivileges(roleAccount), version, // Rec Room Plus, off the same account read as the roles above — `econ` decides the diff --git a/apps/auth/src/context.ts b/apps/auth/src/context.ts index 4c1861b..1c37dd1 100644 --- a/apps/auth/src/context.ts +++ b/apps/auth/src/context.ts @@ -64,6 +64,18 @@ export type Env = SharedHonoEnv & { * Logins on every other build are never touched by this. */ DISCORD_ROLLOUT_ROLE_IDS?: string + /** + * The Discord role ids whose holders get Rec Room Studio upload access — stamped into the + * token as `betastudio` exactly as the account's `hasStudio` flag is, so a whole Discord + * role can be let into Studio without touching anyone's account. Same grammar as + * DISCORD_ROLLOUT_ROLE_IDS above (snowflakes, commas and/or whitespace; ANY one qualifies), + * read from the same Discord link snapshot. Set for every worker from the root `.env` + * (`RECFLARE_STUDIO_ROLLOUT`): `studio` and `www` read it through the same + * `hasStudioAccess`, so what the token says, what the editor's access lookup says and + * what the website's `/api/studio-access` says agree. Unset, or naming no role, grants + * nobody — the flag alone decides. The flag still works beside it. + */ + STUDIO_ROLLOUT?: string /** * The client builds a `/connect/token` grant may post as `ver` and still get a token: * a comma-separated list of builds (`20230414,20250718`), or `off` for none. Unset means diff --git a/apps/auth/src/platform-db.ts b/apps/auth/src/platform-db.ts index 81c564b..d18d06c 100644 --- a/apps/auth/src/platform-db.ts +++ b/apps/auth/src/platform-db.ts @@ -395,6 +395,66 @@ export async function recordFirstRoles( return res.meta.changes > 0 } +/** + * A list of Discord role ids out of an operator var — numeric snowflakes separated by + * commas and/or whitespace, the grammar every `*_ROLE_IDS`-shaped var here shares + * (`auth`'s DISCORD_ROLLOUT_ROLE_IDS, `STUDIO_ROLLOUT`; `www` reads + * DISCORD_BENEFITS_ROLE_IDS the same way). Unset or blank parses to an empty list. + */ +export function parseRoleIdList(raw: string | undefined): string[] { + return (raw ?? '') + .split(/[\s,]+/) + .map((id) => id.trim()) + .filter((id) => id !== '') +} + +/** + * Whether an account's Discord link records ANY of `roleIds`. False with none configured — + * the link read is skipped, so a var that names no role grants nobody anything. Roles are + * the SNAPSHOT the website's benefits claim and `www`'s daily sweep keep (see + * {@link PlatformLink.roles}), so a role granted in Discord today counts once the player + * re-claims or the sweep runs — not instantly. + */ +export async function accountHoldsDiscordRole( + db: D1Database, + accountId: number, + roleIds: readonly string[] +): Promise { + if (roleIds.length === 0) return false + const links = await getLinksForAccount(db, accountId) + return links.some( + (link) => link.platform === PlatformType.Discord && link.roles.some((r) => roleIds.includes(r)) + ) +} + +/** + * Whether an account has Rec Room Studio upload access — what `auth` stamps into the + * token as the `betastudio` role, `studio` answers on its access lookups and `www`'s + * `/api/studio-access` reports. Two grants, either suffices: + * + * - the account's own `hasStudio` flag, set by staff on the website or + * `runx admin grant-studio`; + * - the STUDIO ROLLOUT: a Discord link holding a role named in the `STUDIO_ROLLOUT` var + * (`RECFLARE_STUDIO_ROLLOUT` in the root `.env`, reaching every worker), which hands a + * whole Discord role the same access without touching anyone's account. Unset, or + * naming no role, it grants nobody — the flag alone decides, as before. + * + * Every reader goes through this so the three surfaces cannot disagree: a role holder + * whom `auth` stamps `betastudio` for must also be told "granted" by the website, and + * `studio`'s lookups (which rebuild the role from the account rather than a token) must + * see the same answer. The flag is checked first so an account that has it costs no + * link read. + */ +export async function hasStudioAccess( + db: D1Database, + account: { accountId: number; hasStudio?: boolean } | null | undefined, + studioRolloutRoleIds: string | undefined +): Promise { + if (!account) return false + if (account.hasStudio === true) return true + return accountHoldsDiscordRole(db, account.accountId, parseRoleIdList(studioRolloutRoleIds)) +} + /** Every platform identity linked to an account (a player's PC and headset, say). */ export async function getLinksForAccount( db: D1Database, diff --git a/apps/auth/src/test/integration/api.test.ts b/apps/auth/src/test/integration/api.test.ts index 246db2b..8976111 100644 --- a/apps/auth/src/test/integration/api.test.ts +++ b/apps/auth/src/test/integration/api.test.ts @@ -7,6 +7,7 @@ import '../../auth.app' import { AUDIT_LOG_SCHEMA_DDL, GAME_VERSION, + getAccount, getAccountsByDeviceId, hashPassword, PRESENCE_SCHEMA_DDL, @@ -823,6 +824,53 @@ describe('auth worker routes', () => { expect(decodePayload(refreshed.json.access_token as string).role).not.toContain('betastudio') }) + // STUDIO_ROLLOUT opens Studio to a Discord ROLE: a holder is stamped `betastudio` exactly + // as a flagged account is, with no account flag involved, from the same Discord link + // snapshot the build rollout gate reads. Unset, the flag alone decides. + test('POST /connect/token stamps betastudio for a Discord role in STUDIO_ROLLOUT', async () => { + const studioRole = '1077000000000000031' + const otherRole = '1077000000000000032' + await seedAccount(9813, 'StudioRoleHolder') + await linkPlatformIdentity(env.DB, 9813, PlatformType.Discord, '500000000000009813', [ + studioRole, + ]) + await seedAccount(9814, 'StudioRoleOutsider') + await linkPlatformIdentity(env.DB, 9814, PlatformType.Discord, '500000000000009814', [ + otherRole, + ]) + const login = (id: number) => tokenFor(`account_id=${id}&password=${LOGIN_PASSWORD}`) + + const original = env.STUDIO_ROLLOUT + try { + // Unset, or naming no role: the link grants nothing. + delete env.STUDIO_ROLLOUT + expect((await login(9813)).role).not.toContain('betastudio') + env.STUDIO_ROLLOUT = ' , ' + expect((await login(9813)).role).not.toContain('betastudio') + + // Same list grammar as DISCORD_ROLLOUT_ROLE_IDS; any one named role qualifies. + env.STUDIO_ROLLOUT = ` ${otherRole}x ,\n${studioRole} ` + expect((await login(9813)).role).toEqual(['gameClient', 'screenshare', 'betastudio']) + // A Discord link holding only other roles is not in the rollout. + expect((await login(9814)).role).not.toContain('betastudio') + // The account flag never moved: this is the var, not a grant written anywhere. + expect((await getAccount(env.DB, 9813))?.hasStudio).toBeUndefined() + + // The refresh grant re-resolves it, so ending the rollout drops the claim on the + // next refresh — the same path a revoked flag takes. + const session = await postToken(`account_id=9813&password=${LOGIN_PASSWORD}`) + delete env.STUDIO_ROLLOUT + const refreshed = await postToken( + `grant_type=refresh_token&refresh_token=${encodeURIComponent(session.json.refresh_token as string)}` + ) + expect(refreshed.status).toBe(200) + expect(decodePayload(refreshed.json.access_token as string).role).not.toContain('betastudio') + } finally { + if (original === undefined) delete env.STUDIO_ROLLOUT + else env.STUDIO_ROLLOUT = original + } + }) + // 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 diff --git a/apps/studio/src/context.ts b/apps/studio/src/context.ts index 0108d8d..8810e4d 100644 --- a/apps/studio/src/context.ts +++ b/apps/studio/src/context.ts @@ -27,6 +27,13 @@ export type Env = SharedHonoEnv & { CLOUD_BUILD_TEMPLATE_VERSION?: string /** R2 key of an operator-published template catalog; unset disables downloads. */ STUDIO_DISTRIBUTION_MANIFEST?: string + /** + * Discord role ids whose holders have Studio upload access without the account's + * `hasStudio` flag (`RECFLARE_STUDIO_ROLLOUT` in the root `.env`; the same var `auth` + * stamps `betastudio` from). The access lookups here rebuild the role from the account + * rather than a token, so they read it through the same `hasStudioAccess`. + */ + STUDIO_ROLLOUT?: string } /** Variables can be extended */ diff --git a/apps/studio/src/studio.app.ts b/apps/studio/src/studio.app.ts index d4aae3c..1f015e6 100644 --- a/apps/studio/src/studio.app.ts +++ b/apps/studio/src/studio.app.ts @@ -11,6 +11,7 @@ import { import { withNotFound, withOnError } from '@repo/hono-helpers' import { studioBuildValidationId, validateAndGetAccountId, validateAndGetRoles } from '@repo/jwt' +import { hasStudioAccess } from '../../auth/src/platform-db' import cloudBuilds from './cloud-builds' import distribution from './distribution' import { getUserBuildCount, listCloudBuilds, storeLocalCloudBuild } from './local-builds' @@ -203,7 +204,7 @@ const app = new Hono() if (!(await getRoomCoOwnerIds(c.env.DB, payload.roomId))?.includes(id)) return c.body(null, 403) const account = await getAccount(c.env.DB, id) - roles = account?.hasStudio ? ['betastudio'] : [] + roles = (await hasStudioAccess(c.env.DB, account, c.env.STUDIO_ROLLOUT)) ? ['betastudio'] : [] } if ( caller !== null && @@ -223,7 +224,13 @@ const app = new Hono() } // Inspection authorization above belongs to the caller; this grant belongs to the target. if (caller !== null && caller !== id) - roles = (await getAccount(c.env.DB, id))?.hasStudio ? ['betastudio'] : [] + roles = (await hasStudioAccess( + c.env.DB, + await getAccount(c.env.DB, id), + c.env.STUDIO_ROLLOUT + )) + ? ['betastudio'] + : [] const grant = await c.env.DB.prepare( 'SELECT flags,created_at,modified_at FROM avatar_studio_access WHERE account_id=?' ) diff --git a/apps/www/src/context.ts b/apps/www/src/context.ts index b214337..a20743e 100644 --- a/apps/www/src/context.ts +++ b/apps/www/src/context.ts @@ -144,6 +144,14 @@ export type Env = SharedHonoEnv & { * all closes the claim, exactly as an unset one does. */ DISCORD_BENEFITS_ROLE_IDS?: string + /** + * Discord role ids whose holders have Rec Room Studio upload access without the + * account's `hasStudio` flag (`RECFLARE_STUDIO_ROLLOUT` in the root `.env`; the var + * `auth` stamps `betastudio` from). `/api/studio-access` answers `granted` through the + * same `hasStudioAccess`, so the Studio settings page agrees with the token. The staff + * list and its add/remove still manage the flag alone. + */ + STUDIO_ROLLOUT?: string /** * The bot user's token for the scheduled role refresh (discord-roles.ts). Optional in * effect: the binding must exist for the deploy, but a placeholder value just leaves the diff --git a/apps/www/src/studio-access.ts b/apps/www/src/studio-access.ts index 61b19fa..69a6df3 100644 --- a/apps/www/src/studio-access.ts +++ b/apps/www/src/studio-access.ts @@ -8,6 +8,8 @@ import { import { logger } from '@repo/hono-helpers' import { validateAndGetAccountId } from '@repo/jwt' +import { hasStudioAccess } from '../../auth/src/platform-db' + import type { Context } from 'hono' import type { Account } from '@repo/domain' import type { App } from './context' @@ -20,15 +22,18 @@ import type { App } from './context' * the signed-in player. Staff add and remove people through the routes under * `/api/staff/studio-access`, behind the same `requireStaff` gate as moderation. * The grant is the account's `hasStudio` flag; `auth` stamps `betastudio` from it - * on the next Studio sign-in or refresh. Who granted what is on `audit_log`. + * on the next Studio sign-in or refresh. Who granted what is on `audit_log`. A Discord + * role in the `STUDIO_ROLLOUT` var grants the same access without the flag + * (`hasStudioAccess`); the status below reports it, the staff list does not — that list + * is the accounts staff added by hand. */ -/** The signed-in player's own flag, and nothing else. 401 with no session. */ +/** Whether the signed-in player can upload — flag or rollout role. 401 with no session. */ export async function studioAccessStatusHandler(c: Context) { const accountId = await validateAndGetAccountId(c.req.raw, await c.env.JWT_SECRET.get()) if (accountId === null) return c.json({ error: 'Unauthorized' }, 401) const account = await getAccount(c.env.DB, accountId) - return c.json({ granted: account?.hasStudio === true }) + return c.json({ granted: await hasStudioAccess(c.env.DB, account, c.env.STUDIO_ROLLOUT) }) } /** Every account with the flag set. */ diff --git a/apps/www/src/test/integration/api.test.ts b/apps/www/src/test/integration/api.test.ts index 0028a58..2115d66 100644 --- a/apps/www/src/test/integration/api.test.ts +++ b/apps/www/src/test/integration/api.test.ts @@ -3094,6 +3094,27 @@ it('tells a signed-in player whether they can upload, and nobody else', async () }) expect(res.status).toBe(200) expect(await res.json()).toEqual({ granted: false }) + + // A Discord role named in STUDIO_ROLLOUT grants the same access without the flag, and the + // page reads it from the same helper `auth` stamps `betastudio` from, so the two agree. + // The account has to exist: a link alone, with no account behind it, grants nothing. + await updateAccount(env.DB, 8601, { username: 'StudioFan' }) + await linkPlatformIdentity(env.DB, 8601, PlatformType.Discord, '500000000000008601', [ + '1077000000000000031', + ]) + const original = env.STUDIO_ROLLOUT + try { + env.STUDIO_ROLLOUT = '1077000000000000031' + const rolled = await SELF.fetch('https://example.com/api/studio-access', { + headers: { authorization: `Bearer ${await tokenFor(8601, ['gameClient'])}` }, + }) + expect(await rolled.json()).toEqual({ granted: true }) + // The flag itself never moved; the staff list is still empty of this account. + expect((await getAccount(env.DB, 8601))?.hasStudio).not.toBe(true) + } finally { + if (original === undefined) delete env.STUDIO_ROLLOUT + else env.STUDIO_ROLLOUT = original + } }) it('lets staff add, list, and remove studio upload access', async () => {