mirror of
https://github.com/djdevin/recflare.git
synced 2026-10-25 15:09:18 -07:00
[studio] rollout support
This commit is contained in:
@@ -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
|
||||
# `<roleId>=<tokens>` pairs. How often is the schedule's call: `triggers.crons` in
|
||||
|
||||
@@ -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
|
||||
|
||||
+5
-2
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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=?'
|
||||
)
|
||||
|
||||
@@ -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,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. */
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
Reference in New Issue
Block a user