[studio] rollout support

This commit is contained in:
Devin Zuczek
2026-10-24 15:00:49 -04:00
parent 4e2cdd87eb
commit 8397997234
12 changed files with 218 additions and 37 deletions
+5 -2
View File
@@ -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.
+26 -29
View File
@@ -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<boolean> {
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<App>, 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<Account, 'isDeveloper' | 'isModerator' | 'isJunior' | 'isInfluencer'> | 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<App>()
'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<App>()
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
+12
View File
@@ -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
+60
View File
@@ -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<boolean> {
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<boolean> {
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,
@@ -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
+7
View File
@@ -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 */
+9 -2
View File
@@ -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<App>()
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<App>()
}
// 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=?'
)
+8
View File
@@ -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
+8 -3
View File
@@ -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<App>) {
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. */
+21
View File
@@ -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 () => {