[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
+12
View File
@@ -279,6 +279,18 @@ RECFLARE_DOMAIN=rec.example.com
# link Discord at /claim, and come back. # link Discord at /claim, and come back.
# RECFLARE_DISCORD_ROLLOUT_ROLE_IDS=1077000000000000001,1077000000000000002 # 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 # 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 # RecCenterTokens a holder is handed in a gift box EVERY TIME THE CRON FIRES, as
# `<roleId>=<tokens>` pairs. How often is the schedule's call: `triggers.crons` in # `<roleId>=<tokens>` pairs. How often is the schedule's call: `triggers.crons` in
+2 -1
View File
@@ -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 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 `betastudio` role — the claim RecFlare Studio reads as permission to upload. Staff
normally manage this from the website (`/settings/recroomstudio`); this is the 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 ```sh
bun runx admin grant-studio --username alice bun runx admin grant-studio --username alice
+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`. Grant those flags with `runx admin grant-developer` / `grant-moderator`.
`betastudio` is a separate claim. RecFlare Studio treats it as permission to upload, `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. 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 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 `runx admin grant-studio`). A player already signed into Studio keeps the old token
until the next sign-in or refresh. until the next sign-in or refresh.
+26 -29
View File
@@ -70,13 +70,15 @@ import {
TokenResponse, TokenResponse,
} from './openapi' } from './openapi'
import { import {
accountHoldsDiscordRole,
countAccountsForPlatformIdentity, countAccountsForPlatformIdentity,
getLinksForAccount,
getLinksForIdentity, getLinksForIdentity,
getLinksForPlatformId, getLinksForPlatformId,
getLinksForPlatformIdentity, getLinksForPlatformIdentity,
hasStudioAccess,
isPlatformIdentityLinked, isPlatformIdentityLinked,
linkPlatformIdentity, linkPlatformIdentity,
parseRoleIdList,
} from './platform-db' } from './platform-db'
import { consumeRefreshToken, issueRefreshToken } from './refresh-db' import { consumeRefreshToken, issueRefreshToken } from './refresh-db'
import { verifySteamTicket } from './steam-ticket' 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 * `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). * whitespace (`parseRoleIdList`, the grammar `www`'s parseRoleIds reads
* Unset or blank parses to an empty list, which `accountMayUseRolloutBuild` treats as a * DISCORD_BENEFITS_ROLE_IDS with). Unset or blank parses to an empty list, which
* gate closed to everyone but staff. * `accountMayUseRolloutBuild` treats as a gate closed to everyone but staff.
*/ */
function rolloutRoleIds(raw: string | undefined): string[] { const rolloutRoleIds = parseRoleIdList
return (raw ?? '')
.split(/[\s,]+/)
.map((id) => id.trim())
.filter((id) => id !== '')
}
/** /**
* Whether an account may sign in on a rollout-gated build. STAFF — a developer or moderator * 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> { ): Promise<boolean> {
const account = await getAccount(db, accountId) const account = await getAccount(db, accountId)
if (account && (holdsRole(account, 'developer') || holdsRole(account, 'moderator'))) return true if (account && (holdsRole(account, 'developer') || holdsRole(account, 'moderator'))) return true
if (roleIds.length === 0) return false return accountHoldsDiscordRole(db, accountId, roleIds)
const links = await getLinksForAccount(db, accountId)
return links.some(
(link) => link.platform === PlatformType.Discord && link.roles.some((r) => roleIds.includes(r))
)
} }
/** New players start in the Orientation room (RoomId 13) — the new-user flow. */ /** 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 * is unconditional (even with no account resolved). The rest are the operator-granted
* extras, plus `junior` off the account's own `isJunior` flag. * extras, plus `junior` off the account's own `isJunior` flag.
* *
* `betastudio` rides on the account's `hasStudio` flag, and developer does not imply it. * `betastudio` rides on `studio` — the account's Studio access as `hasStudioAccess`
* RecFlare Studio treats that exact claim as Full access, which is what lets the editor * resolves it: the `hasStudio` flag staff set from the website, OR a Discord link holding
* upload; staff set the flag from the website. `influencer` rides on `isInfluencer` * a role in the `STUDIO_ROLLOUT` var — and developer does not imply it. RecFlare Studio
* (`runx admin grant-influencer`) and lives only in the token — no `/role/*` lookup * treats that exact claim as Full access, which is what lets the editor upload. The
* answers it. Order is stable so tokens are deterministic: screenshare, developer, * caller resolves it (it costs a link read) so this stays a pure projection of the
* moderator, junior, betastudio, then influencer. * 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( function accountRoles(
account: Pick< account: Pick<Account, 'isDeveloper' | 'isModerator' | 'isJunior' | 'isInfluencer'> | null,
Account, studio: boolean
'isDeveloper' | 'isModerator' | 'isJunior' | 'hasStudio' | 'isInfluencer'
> | null
): string[] { ): string[] {
const roles = ['screenshare'] const roles = ['screenshare']
if (!account) return roles if (!account) return roles
if (account.isDeveloper) roles.push('developer') if (account.isDeveloper) roles.push('developer')
if (account.isModerator) roles.push('moderator') if (account.isModerator) roles.push('moderator')
if (account.isJunior) roles.push('junior') if (account.isJunior) roles.push('junior')
if (account.hasStudio) roles.push('betastudio') if (studio) roles.push('betastudio')
if (account.isInfluencer) roles.push('influencer') if (account.isInfluencer) roles.push('influencer')
return roles return roles
} }
@@ -500,7 +493,7 @@ async function issueSession(
roleAccount.platformId ?? '', roleAccount.platformId ?? '',
accountPlatform(roleAccount), accountPlatform(roleAccount),
jwtSecret, jwtSecret,
accountRoles(roleAccount), accountRoles(roleAccount, await hasStudioAccess(c.env.DB, roleAccount, c.env.STUDIO_ROLLOUT)),
accountPrivileges(roleAccount), accountPrivileges(roleAccount),
GAME_VERSION, GAME_VERSION,
roleAccount.hasPlus === true 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', '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', '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', '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', 'treats that claim as permission to upload; `developer` does not confer it. A junior',
'also carries', 'also carries',
'the `rn.privilege` CLAIM (`BanVChat`, `BanRmChat`) — scope-shaped name, but the', 'the `rn.privilege` CLAIM (`BanVChat`, `BanRmChat`) — scope-shaped name, but the',
@@ -1706,7 +1700,10 @@ const app = new Hono<App>()
platformId, platformId,
platform, platform,
jwtSecret, jwtSecret,
accountRoles(roleAccount), accountRoles(
roleAccount,
await hasStudioAccess(c.env.DB, roleAccount, c.env.STUDIO_ROLLOUT)
),
accountPrivileges(roleAccount), accountPrivileges(roleAccount),
version, version,
// Rec Room Plus, off the same account read as the roles above — `econ` decides the // 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. * Logins on every other build are never touched by this.
*/ */
DISCORD_ROLLOUT_ROLE_IDS?: string 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: * 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 * 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 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). */ /** Every platform identity linked to an account (a player's PC and headset, say). */
export async function getLinksForAccount( export async function getLinksForAccount(
db: D1Database, db: D1Database,
@@ -7,6 +7,7 @@ import '../../auth.app'
import { import {
AUDIT_LOG_SCHEMA_DDL, AUDIT_LOG_SCHEMA_DDL,
GAME_VERSION, GAME_VERSION,
getAccount,
getAccountsByDeviceId, getAccountsByDeviceId,
hashPassword, hashPassword,
PRESENCE_SCHEMA_DDL, PRESENCE_SCHEMA_DDL,
@@ -823,6 +824,53 @@ describe('auth worker routes', () => {
expect(decodePayload(refreshed.json.access_token as string).role).not.toContain('betastudio') 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 // 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 // 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 // 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 CLOUD_BUILD_TEMPLATE_VERSION?: string
/** R2 key of an operator-published template catalog; unset disables downloads. */ /** R2 key of an operator-published template catalog; unset disables downloads. */
STUDIO_DISTRIBUTION_MANIFEST?: string 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 */ /** Variables can be extended */
+9 -2
View File
@@ -11,6 +11,7 @@ import {
import { withNotFound, withOnError } from '@repo/hono-helpers' import { withNotFound, withOnError } from '@repo/hono-helpers'
import { studioBuildValidationId, validateAndGetAccountId, validateAndGetRoles } from '@repo/jwt' import { studioBuildValidationId, validateAndGetAccountId, validateAndGetRoles } from '@repo/jwt'
import { hasStudioAccess } from '../../auth/src/platform-db'
import cloudBuilds from './cloud-builds' import cloudBuilds from './cloud-builds'
import distribution from './distribution' import distribution from './distribution'
import { getUserBuildCount, listCloudBuilds, storeLocalCloudBuild } from './local-builds' 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)) if (!(await getRoomCoOwnerIds(c.env.DB, payload.roomId))?.includes(id))
return c.body(null, 403) return c.body(null, 403)
const account = await getAccount(c.env.DB, id) 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 ( if (
caller !== null && caller !== null &&
@@ -223,7 +224,13 @@ const app = new Hono<App>()
} }
// Inspection authorization above belongs to the caller; this grant belongs to the target. // Inspection authorization above belongs to the caller; this grant belongs to the target.
if (caller !== null && caller !== id) 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( const grant = await c.env.DB.prepare(
'SELECT flags,created_at,modified_at FROM avatar_studio_access WHERE account_id=?' '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. * all closes the claim, exactly as an unset one does.
*/ */
DISCORD_BENEFITS_ROLE_IDS?: string 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 * 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 * 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 { logger } from '@repo/hono-helpers'
import { validateAndGetAccountId } from '@repo/jwt' import { validateAndGetAccountId } from '@repo/jwt'
import { hasStudioAccess } from '../../auth/src/platform-db'
import type { Context } from 'hono' import type { Context } from 'hono'
import type { Account } from '@repo/domain' import type { Account } from '@repo/domain'
import type { App } from './context' 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 * the signed-in player. Staff add and remove people through the routes under
* `/api/staff/studio-access`, behind the same `requireStaff` gate as moderation. * `/api/staff/studio-access`, behind the same `requireStaff` gate as moderation.
* The grant is the account's `hasStudio` flag; `auth` stamps `betastudio` from it * 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>) { export async function studioAccessStatusHandler(c: Context<App>) {
const accountId = await validateAndGetAccountId(c.req.raw, await c.env.JWT_SECRET.get()) const accountId = await validateAndGetAccountId(c.req.raw, await c.env.JWT_SECRET.get())
if (accountId === null) return c.json({ error: 'Unauthorized' }, 401) if (accountId === null) return c.json({ error: 'Unauthorized' }, 401)
const account = await getAccount(c.env.DB, accountId) 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. */ /** 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(res.status).toBe(200)
expect(await res.json()).toEqual({ granted: false }) 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 () => { it('lets staff add, list, and remove studio upload access', async () => {