[www] Manage Avatar Studio and screen-sharing access (#144)

This commit is contained in:
dukedotdev
2026-10-26 20:05:27 -04:00
committed by GitHub
parent 44704fec51
commit f6214fb824
8 changed files with 356 additions and 67 deletions
+11 -10
View File
@@ -331,8 +331,8 @@ async function authedId(c: Context<App>): Promise<number | null> {
/** Whether one account row holds a role. Absent/false both mean no role. */
function holdsRole(account: Account, role: 'developer' | 'moderator' | 'screenshare'): boolean {
// Matches accountTokenRoles: screenshare is a feature gate on every account token.
if (role === 'screenshare') return true
// Match the same staff override used when minting account tokens.
if (role === 'screenshare') return account.hasScreenshare === true
return (role === 'developer' ? account.isDeveloper : account.isModerator) === true
}
@@ -382,10 +382,8 @@ async function roleFilterAnswer(c: Context<App>, role: 'developer' | 'moderator'
/**
* The role names beyond `gameClient` for an account's token `role` claim. Base roles
* (gameClient) are added by generateToken. `screenshare` rides on EVERY token — the
* client gates the screen-share feature on it and nothing grants it per-account, so it
* is unconditional (even with no account resolved). The rest are the operator-granted
* extras, plus `junior` off the account's own `isJunior` flag.
* (gameClient) are added by generateToken. `screenshare` requires an explicit staff
* grant via `hasScreenshare`. The other roles come from account flags and Studio access.
*
* `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
@@ -397,10 +395,13 @@ async function roleFilterAnswer(c: Context<App>, role: 'developer' | 'moderator'
* deterministic: screenshare, developer, moderator, junior, betastudio, then influencer.
*/
function accountRoles(
account: Pick<Account, 'isDeveloper' | 'isModerator' | 'isJunior' | 'isInfluencer'> | null,
account: Pick<
Account,
'isDeveloper' | 'isModerator' | 'isJunior' | 'isInfluencer' | 'hasScreenshare'
> | null,
studio: boolean
): string[] {
const roles = ['screenshare']
const roles: string[] = account?.hasScreenshare === true ? ['screenshare'] : []
if (!account) return roles
if (account.isDeveloper) roles.push('developer')
if (account.isModerator) roles.push('moderator')
@@ -1103,8 +1104,8 @@ const app = new Hono<App>()
'',
'**Roles.** The token embeds a `role` claim from the account, so developer/moderator',
'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',
'account flagged `isJunior`. `screenshare` requires a staff grant on the Studio Access',
'page. `betastudio` is stamped',
'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',
+1 -1
View File
@@ -301,7 +301,7 @@ export function roleLookup(role: 'developer' | 'moderator' | 'screenshare') {
summary: `Whether a player has the ${role} role`,
description:
role === 'screenshare'
? 'Returns a bare JSON boolean. Screenshare is enabled for every existing account, matching the access-token role policy; unknown accounts return 404. Studio uses this to validate room-owner video permissions.'
? 'Returns a bare JSON boolean. Screenshare is disabled by default and requires an explicit staff grant, matching the access-token role policy; unknown accounts return 404. Studio uses this to validate room-owner video permissions.'
: `Returns a bare JSON boolean (\`true\`/\`false\`), not an object. Off by default and ` +
`granted only by an operator via \`runx admin grant-${role}\`. The same flag also rides ` +
`in the access token's \`role\` claim, so the client rarely needs this route.\n\n` +
+37 -11
View File
@@ -692,8 +692,8 @@ describe('auth worker routes', () => {
expect(payload.iss).toBe('https://auth.recflare.net')
expect(payload.aud).toBe('https://auth.recflare.net')
expect(payload.role).toContain('gameClient')
// screenshare is a feature gate, not a grant — every token carries it.
expect(payload.role).toContain('screenshare')
// Screen sharing is disabled until staff explicitly grant it.
expect(payload.role).not.toContain('screenshare')
// A plain adult account carries nothing beyond those — no elevated roles.
expect(payload.role).not.toContain('developer')
expect(payload.role).not.toContain('moderator')
@@ -750,6 +750,37 @@ describe('auth worker routes', () => {
// refresh, and the Studio device grant — and a refresh after a revoke drops it. A
// token that kept the role for the rest of the day would leave a removed player
// uploading.
test('screen sharing requires a staff grant on login, refresh, and role lookup', async () => {
await seedAccount(9819, 'ScreenSharePlayer')
expect((await tokenFor(`account_id=9819&password=${LOGIN_PASSWORD}`)).role).not.toContain(
'screenshare'
)
await updateAccount(env.DB, 9819, { hasScreenshare: true })
const granted = await tokenFor(`account_id=9819&password=${LOGIN_PASSWORD}`)
expect(granted.role).toContain('screenshare')
expect(await (await exports.default.fetch(`${ORIGIN}/role/screenshare/9819`)).json()).toBe(true)
const session = await postToken(`account_id=9819&password=${LOGIN_PASSWORD}`)
const sessionBody = session.json as { refresh_token: string }
await updateAccount(env.DB, 9819, { hasScreenshare: false })
const refresh = await exports.default.fetch(`${ORIGIN}/connect/token`, {
method: 'POST',
headers: { 'content-type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'refresh_token',
refresh_token: sessionBody.refresh_token,
}),
})
expect(refresh.status).toBe(200)
const refreshed = (await refresh.json()) as { access_token: string }
const payload = JSON.parse(
atob(refreshed.access_token.split('.')[1].replace(/-/g, '+').replace(/_/g, '/'))
)
expect(payload.role).not.toContain('screenshare')
expect(await (await exports.default.fetch(`${ORIGIN}/role/screenshare/9819`)).json()).toBe(
false
)
})
test('POST /connect/token stamps betastudio only while the account has hasStudio', async () => {
// 9811/9812, not 9601–9605: those ids belong to the staff-login audit tests,
// which insert with OR IGNORE and then count `staff_login` rows.
@@ -761,7 +792,6 @@ describe('auth worker routes', () => {
await updateAccount(env.DB, 9811, { hasStudio: true })
expect((await tokenFor(`account_id=9811&password=${LOGIN_PASSWORD}`)).role).toEqual([
'gameClient',
'screenshare',
'betastudio',
])
@@ -780,7 +810,6 @@ describe('auth worker routes', () => {
.run()
expect((await tokenFor(`account_id=9812&password=${LOGIN_PASSWORD}`)).role).toEqual([
'gameClient',
'screenshare',
'developer',
'moderator',
'betastudio',
@@ -813,7 +842,6 @@ describe('auth worker routes', () => {
expect(signedIn.status).toBe(200)
expect(decodePayload(signedIn.json.access_token as string).role).toEqual([
'gameClient',
'screenshare',
'betastudio',
])
@@ -854,7 +882,7 @@ describe('auth worker routes', () => {
// 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'])
expect((await login(9813)).role).toEqual(['gameClient', '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.
@@ -945,7 +973,7 @@ describe('auth worker routes', () => {
)
.run()
const payload = await tokenFor(`account_id=92&password=${LOGIN_PASSWORD}`)
expect(payload.role).toEqual(expect.arrayContaining(['gameClient', 'screenshare', 'junior']))
expect(payload.role).toEqual(expect.arrayContaining(['gameClient', 'junior']))
expect(payload.role).not.toContain('developer')
// `rn.privilege` is a claim, not a scope — it sits beside `role`, never in `scope`.
expect(payload['rn.privilege']).toEqual(['BanVChat', 'BanRmChat'])
@@ -964,9 +992,7 @@ describe('auth worker routes', () => {
)
.run()
const payload = await tokenFor(`account_id=95&password=${LOGIN_PASSWORD}`)
expect(payload.role).toEqual(
expect.arrayContaining(['gameClient', 'screenshare', 'influencer'])
)
expect(payload.role).toEqual(expect.arrayContaining(['gameClient', 'influencer']))
expect(payload.role).not.toContain('developer')
// Off the flag, off the token — no other flag implies it.
@@ -2016,7 +2042,7 @@ describe('auth worker routes', () => {
for (const id of [42, 77]) {
const res = await exports.default.fetch(`${ORIGIN}/role/screenshare/${id}`)
expect(res.status).toBe(200)
expect(await res.json()).toBe(true)
expect(await res.json()).toBe(false)
}
})
+68 -29
View File
@@ -14,16 +14,29 @@ import { call, isAdmin, useAction } from './api'
const PAGE = '/settings/recroomstudio'
type AccessKind = keyof typeof accessLabels
const accessLabels = {
studio: 'Studio uploads',
'avatar-items': 'Avatar Studio items',
'avatar-costumes': 'Avatar Studio costumes',
'avatar-badges': 'Avatar Studio badges',
'avatar-consumables': 'Avatar Studio consumables',
screenshare: 'Screen sharing',
}
interface AccessRow {
accountId: number
username: string | null
displayName: string | null
enabled: boolean
}
export function StudioAccessPage({
function AccessPage({
kind,
account,
navigate,
}: {
kind: AccessKind
/** undefined while the session is still being checked, null when signed out. */
account: { accountId: number; username: string } | null | undefined
navigate: (to: string) => void
@@ -39,15 +52,17 @@ export function StudioAccessPage({
const add = useAction()
const load = useCallback(async () => {
const me = await call<{ granted: boolean }>('/api/studio-access', { authed: true })
setGranted(me.granted === true)
const me = await call<Record<AccessKind | 'granted', boolean>>('/api/studio-access', {
authed: true,
})
setGranted((kind === 'studio' ? me.granted : me[kind]) === true)
if (isAdmin()) {
const page = await call<{ accounts: AccessRow[] }>('/api/staff/studio-access', {
const page = await call<{ accounts: AccessRow[] }>(`/api/staff/studio-access?kind=${kind}`, {
authed: true,
})
setRows(page.accounts ?? [])
}
}, [])
}, [kind])
const accountId = account?.accountId
useEffect(() => {
@@ -80,13 +95,13 @@ export function StudioAccessPage({
void add.run(async () => {
const result = await call<{ username: string; alreadyGranted: boolean }>(
'/api/staff/studio-access',
{ method: 'POST', authed: true, json: { username: username.trim() } }
{ method: 'POST', authed: true, json: { username: username.trim(), kind } }
)
setUsername('')
await reload()
return result.alreadyGranted
? `@${result.username} is already on the list.`
: `@${result.username} can upload the next time they sign in to Studio.`
? `@${result.username} already has ${accessLabels[kind]} access.`
: `@${result.username} now has ${accessLabels[kind]} access.`
})
}
@@ -95,7 +110,14 @@ export function StudioAccessPage({
setRemoveError('')
void (async () => {
try {
await call(`/api/staff/studio-access/${row.accountId}`, { method: 'DELETE', authed: true })
await call(
row.enabled
? `/api/staff/studio-access/${row.accountId}?kind=${kind}`
: '/api/staff/studio-access',
row.enabled
? { method: 'DELETE', authed: true }
: { method: 'POST', authed: true, json: { accountId: row.accountId, kind } }
)
await reload()
} catch (err) {
setRemoveError(err instanceof Error ? err.message : String(err))
@@ -108,13 +130,16 @@ export function StudioAccessPage({
const signInHref = `/login?next=${encodeURIComponent(PAGE)}`
return (
<main className={staff ? 'shell wide' : 'shell'}>
<h1>RecFlare Studio access</h1>
<section>
<h2>{accessLabels[kind]}</h2>
<section className="card">
<p className="muted">
RecFlare Studio signs in with any account. Uploading is limited to accounts on this list.
Access is picked up the next time you sign in to Studio, or when that session refreshes. A
session you already have keeps its old access until then, and a session lasts a day.
{kind === 'studio'
? 'Manage permission to upload from RecFlare Studio.'
: kind.startsWith('avatar-')
? 'Grant this Avatar Studio feature independently. Studio upload access is also required. Roomie equipment still requires developer access.'
: 'Screen sharing is disabled by default. Staff can enable it for individual users.'}{' '}
Sign in again or refresh the session to pick up permission changes.
</p>
{account === undefined ? (
<p className="muted">Loading…</p>
@@ -130,7 +155,7 @@ export function StudioAccessPage({
>
Sign in
</a>{' '}
<span className="muted">to see whether this account can upload.</span>
<span className="muted">to see the access for this account.</span>
</p>
) : loading ? (
<p className="muted">Loading…</p>
@@ -138,22 +163,23 @@ export function StudioAccessPage({
<p className="error">{loadError}</p>
) : granted ? (
<p>
<strong>{account.username}</strong> can upload in RecFlare Studio. If the editor still
<strong>{account.username}</strong> has {accessLabels[kind]} access. If the editor still
says otherwise, sign out of it and sign in again.
</p>
) : (
<p>
<strong>{account.username}</strong> cannot upload in RecFlare Studio yet. A moderator
can add this account to the list.
<strong>{account.username}</strong> does not have {accessLabels[kind]} access. A
moderator can add this account to the list.
</p>
)}
</section>
{staff && account && (
<section className="card">
<h2>Whitelist</h2>
<h3>Whitelist</h3>
<p className="muted">
Adding a player puts the betastudio role on their next Studio sign-in. Developers are
not on this list unless someone adds them.
{kind === 'screenshare'
? 'Only users on this list can share their screen.'
: 'Add a username to grant access, or remove a player to revoke it.'}
</p>
<form
onSubmit={(e) => {
@@ -172,7 +198,7 @@ export function StudioAccessPage({
/>
</label>
<button type="submit" disabled={add.pending || username.trim() === ''}>
Add
{kind === 'screenshare' ? 'Enable' : 'Add'}
</button>
</form>
{add.error && <p className="error">{add.error}</p>}
@@ -180,7 +206,7 @@ export function StudioAccessPage({
{loading ? (
<p className="muted">Loading…</p>
) : loadError ? null : rows.length === 0 ? (
<p className="muted">Nobody is on the list.</p>
<p className="muted">No users on this list.</p>
) : (
<ul className="studio-access-list">
{rows.map((row) => (
@@ -192,13 +218,12 @@ export function StudioAccessPage({
{row.displayName && row.displayName !== row.username ? (
<span className="muted"> · {row.displayName}</span>
) : null}
{kind === 'screenshare' && (
<span className="muted"> - {row.enabled ? 'Enabled' : 'Disabled'}</span>
)}
</span>
<button
type="button"
disabled={removing === row.accountId}
onClick={() => remove(row)}
>
Remove
<button type="button" disabled={removing !== null} onClick={() => remove(row)}>
{kind === 'screenshare' ? (row.enabled ? 'Disable' : 'Enable') : 'Remove'}
</button>
</li>
))}
@@ -207,6 +232,20 @@ export function StudioAccessPage({
{removeError && <p className="error">{removeError}</p>}
</section>
)}
</section>
)
}
export function StudioAccessPage(props: {
account: { accountId: number; username: string } | null | undefined
navigate: (to: string) => void
}) {
return (
<main className="shell wide">
<h1>RecFlare Studio access</h1>
{(Object.keys(accessLabels) as AccessKind[]).map((kind) => (
<AccessPage key={kind} kind={kind} {...props} />
))}
</main>
)
}
+73 -9
View File
@@ -1,4 +1,6 @@
import {
AVATAR_STUDIO_ACCESS,
avatarStudioFlags,
getAccount,
getAccountByUsername,
listStudioAccounts,
@@ -33,17 +35,44 @@ 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: await hasStudioAccess(c.env.DB, account, c.env.STUDIO_ROLLOUT) })
const flags = await avatarStudioFlags(c.env.DB, accountId)
return c.json({
...Object.fromEntries(
Object.entries(AVATAR_STUDIO_ACCESS).map(([kind, mask]) => [kind, (flags & mask) === mask])
),
granted: await hasStudioAccess(c.env.DB, account, c.env.STUDIO_ROLLOUT),
avatar: (flags & 7) !== 0,
screenshare: account?.hasScreenshare === true,
})
}
type AccessKind = 'studio' | 'avatar' | 'screenshare' | keyof typeof AVATAR_STUDIO_ACCESS
function accessKind(value: unknown): AccessKind | null {
if (value === undefined) return 'studio'
if (value === 'studio' || value === 'avatar' || value === 'screenshare') return value
return typeof value === 'string' && Object.hasOwn(AVATAR_STUDIO_ACCESS, value)
? (value as AccessKind)
: null
}
function avatarMask(kind: AccessKind): number {
return kind === 'avatar'
? 7
: Object.hasOwn(AVATAR_STUDIO_ACCESS, kind)
? AVATAR_STUDIO_ACCESS[kind as keyof typeof AVATAR_STUDIO_ACCESS]
: 0
}
/** Every account with the flag set. */
export async function listStudioAccessHandler(c: Context<App>) {
const accounts = await listStudioAccounts(c.env.DB)
const kind = accessKind(c.req.query('kind'))
if (!kind) return c.json({ error: 'Unknown access type' }, 400)
const accounts = await listStudioAccounts(c.env.DB, kind)
return c.json({
accounts: accounts.map((account) => ({
accountId: account.accountId,
username: account.username,
displayName: account.displayName,
enabled: kind !== 'screenshare' || account.hasScreenshare === true,
})),
})
}
@@ -58,7 +87,9 @@ export async function grantStudioAccessHandler(c: Context<App>) {
if (body === null || typeof body !== 'object' || Array.isArray(body)) {
return c.json({ error: 'Expected a JSON body' }, 400)
}
const posted = body as { username?: unknown; accountId?: unknown }
const posted = body as { username?: unknown; accountId?: unknown; kind?: unknown }
const kind = accessKind(posted.kind)
if (!kind) return c.json({ error: 'Unknown access type' }, 400)
const username =
typeof posted.username === 'string' ? posted.username.trim().replace(/^@/, '') : ''
const accountId = postedAccountId(posted.accountId)
@@ -77,9 +108,15 @@ export async function grantStudioAccessHandler(c: Context<App>) {
return c.json({ error: 'Name a player by username or account id' }, 400)
}
const alreadyGranted = account.hasStudio === true
if (!alreadyGranted) await updateAccount(c.env.DB, account.accountId, { hasStudio: true })
await recordAudit(c, 'grant_studio_access', {
const alreadyGranted =
avatarMask(kind) !== 0
? ((await avatarStudioFlags(c.env.DB, account.accountId)) & avatarMask(kind)) ===
avatarMask(kind)
: kind === 'screenshare'
? account.hasScreenshare === true
: account.hasStudio === true
await setAccess(c, account.accountId, kind, true)
await recordAudit(c, `grant_${kind}_access`, {
playerId: account.accountId,
username: account.username,
alreadyGranted,
@@ -98,15 +135,22 @@ export async function grantStudioAccessHandler(c: Context<App>) {
* account is a 404.
*/
export async function revokeStudioAccessHandler(c: Context<App>) {
const kind = accessKind(c.req.query('kind'))
if (!kind) return c.json({ error: 'Unknown access type' }, 400)
const accountId = Number(c.req.param('id'))
if (!Number.isInteger(accountId) || accountId <= 0) {
return c.json({ error: 'A numeric player id is required' }, 400)
}
const account = await getAccount(c.env.DB, accountId)
if (!account) return c.json({ error: 'No player has that account id' }, 404)
const removed = account.hasStudio === true
if (removed) await updateAccount(c.env.DB, accountId, { hasStudio: false })
await recordAudit(c, 'revoke_studio_access', {
const removed =
avatarMask(kind) !== 0
? ((await avatarStudioFlags(c.env.DB, accountId)) & avatarMask(kind)) !== 0
: kind === 'screenshare'
? account.hasScreenshare === true
: account.hasStudio === true
await setAccess(c, accountId, kind, false)
await recordAudit(c, `revoke_${kind}_access`, {
playerId: accountId,
username: account.username,
removed,
@@ -114,6 +158,26 @@ export async function revokeStudioAccessHandler(c: Context<App>) {
return c.json({ accountId, granted: false, removed })
}
async function setAccess(c: Context<App>, accountId: number, kind: AccessKind, enabled: boolean) {
const mask = avatarMask(kind)
if (mask !== 0) {
const now = new Date().toISOString()
await c.env.DB.prepare(
`INSERT INTO avatar_studio_access (account_id, flags, created_at, modified_at)
VALUES (?, ?, ?, ?) ON CONFLICT(account_id) DO UPDATE SET
flags = ${enabled ? 'flags | ?' : 'flags & ~?'}, modified_at = excluded.modified_at`
)
.bind(accountId, enabled ? mask : 0, now, now, mask)
.run()
} else {
await updateAccount(
c.env.DB,
accountId,
kind === 'screenshare' ? { hasScreenshare: enabled } : { hasStudio: enabled }
)
}
}
/** A positive integer account id from a JSON field, or null when it isn't one. */
function postedAccountId(value: unknown): number | null {
if (typeof value === 'number' && Number.isInteger(value) && value > 0) return value
+140 -5
View File
@@ -14,6 +14,7 @@ import {
updateAccount,
} from '@repo/domain/src/accounts-db'
import { AUDIT_LOG_SCHEMA_DDL } from '@repo/domain/src/audit-db'
import { AVATAR_STUDIO_SCHEMA, avatarStudioFlags } from '@repo/domain/src/avatar-studio'
import { PlatformType } from '@repo/domain/src/enums'
import { getPendingGifts, RECEIVED_GIFT_SCHEMA_DDL } from '@repo/domain/src/gifts-db'
import {
@@ -132,6 +133,7 @@ beforeAll(async () => {
for (const stmt of PRESENCE_SCHEMA_DDL) await env.DB.prepare(stmt).run()
// `account` likewise: owned by `auth`, read and (for the benefits claim) written here.
for (const stmt of ACCOUNT_SCHEMA_DDL) await env.DB.prepare(stmt).run()
for (const stmt of AVATAR_STUDIO_SCHEMA) await env.DB.prepare(stmt).run()
// And `platform_account`, where a claimed Discord identity is linked.
for (const stmt of PLATFORM_SCHEMA_DDL) await env.DB.prepare(stmt).run()
// `report` and `warning` are owned (and migrated) by `api`; www serves the staff panel
@@ -3267,7 +3269,7 @@ it('tells a signed-in player whether they can upload, and nobody else', async ()
headers: { authorization: `Bearer ${await tokenFor(8601, ['gameClient'])}` },
})
expect(res.status).toBe(200)
expect(await res.json()).toEqual({ granted: false })
expect(await res.json()).toMatchObject({ granted: false, avatar: false, screenshare: 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.
@@ -3282,7 +3284,7 @@ it('tells a signed-in player whether they can upload, and nobody else', async ()
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 })
expect(await rolled.json()).toMatchObject({ granted: true, avatar: false, screenshare: false })
// 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 {
@@ -3325,13 +3327,13 @@ it('lets staff add, list, and remove studio upload access', async () => {
const mine = await SELF.fetch('https://example.com/api/studio-access', {
headers: { authorization: `Bearer ${await tokenFor(8601, ['gameClient'])}` },
})
expect(await mine.json()).toEqual({ granted: true })
expect(await mine.json()).toMatchObject({ granted: true, avatar: false, screenshare: false })
const list = await staffGet('/api/staff/studio-access', 8110)
expect(list.status).toBe(200)
const body = (await list.json()) as { accounts: Array<{ accountId: number }> }
expect(body.accounts).toEqual([
{ accountId: 8601, username: 'StudioFan', displayName: expect.any(String) },
{ accountId: 8601, username: 'StudioFan', displayName: expect.any(String), enabled: true },
])
expect((await getAccount(env.DB, 8601))?.hasStudio).toBe(true)
@@ -3348,7 +3350,7 @@ it('lets staff add, list, and remove studio upload access', async () => {
const after = await SELF.fetch('https://example.com/api/studio-access', {
headers: { authorization: `Bearer ${await tokenFor(8601, ['gameClient'])}` },
})
expect(await after.json()).toEqual({ granted: false })
expect(await after.json()).toMatchObject({ granted: false, avatar: false, screenshare: false })
expect(await auditRows('grant_studio_access', 8601)).toEqual([
{
@@ -3424,3 +3426,136 @@ it('lets staff opt themselves in and out of the sandbox pool, and only themselve
[8120, false],
])
})
it('manages Avatar Studio flags and screen sharing independently, with staff authorization', async () => {
await updateAccount(env.DB, 8611, { username: 'AvatarFan', hasStudio: true })
await updateAccount(env.DB, 8612, { username: 'ScreenFan' })
const url = '/api/staff/studio-access'
for (const kind of ['avatar', 'screenshare']) {
const denied = await SELF.fetch(`https://example.com${url}`, {
method: 'POST',
headers: {
authorization: `Bearer ${await tokenFor(8612)}`,
'content-type': 'application/json',
},
body: JSON.stringify({ accountId: 8612, kind }),
})
expect(denied.status).toBe(403)
expect((await SELF.fetch(`https://example.com${url}?kind=${kind}`)).status).toBe(401)
const deniedDelete = await SELF.fetch(`https://example.com${url}/8611?kind=${kind}`, {
method: 'DELETE',
headers: { authorization: `Bearer ${await tokenFor(8612)}` },
})
expect(deniedDelete.status).toBe(403)
}
expect((await staffPost(url, 8110, { accountId: 8611, kind: 'unknown' })).status).toBe(400)
expect((await staffGet(`${url}?kind=unknown`, 8110)).status).toBe(400)
expect((await staffDelete(`${url}/8611?kind=unknown`, 8110)).status).toBe(400)
expect((await staffPost(url, 8110, { accountId: 99999999, kind: 'avatar' })).status).toBe(404)
await env.DB.prepare('INSERT INTO avatar_studio_access VALUES (?, ?, ?, ?)')
.bind(8611, 9, 'before', 'before')
.run()
expect((await staffPost(url, 8110, { username: '@AvatarFan', kind: 'avatar' })).status).toBe(200)
expect(await avatarStudioFlags(env.DB, 8611)).toBe(15)
const repeated = await staffPost(url, 8110, { accountId: 8611, kind: 'avatar' })
expect(await repeated.json()).toMatchObject({ alreadyGranted: true })
const avatarList = await staffGet(`${url}?kind=avatar`, 8110)
expect(await avatarList.json()).toMatchObject({ accounts: [{ accountId: 8611, enabled: true }] })
expect((await getAccount(env.DB, 8611))?.hasScreenshare).toBeUndefined()
await staffDelete(`${url}/8611?kind=avatar`, 8110)
expect(await avatarStudioFlags(env.DB, 8611)).toBe(8)
expect((await getAccount(env.DB, 8611))?.hasStudio).toBe(true)
expect((await staffPost(url, 8110, { username: 'ScreenFan', kind: 'screenshare' })).status).toBe(
200
)
expect((await getAccount(env.DB, 8612))?.hasScreenshare).toBe(true)
const screenList = await staffGet(`${url}?kind=screenshare`, 8110)
expect(await screenList.json()).toMatchObject({ accounts: [{ accountId: 8612, enabled: true }] })
const status = await SELF.fetch('https://example.com/api/studio-access', {
headers: { authorization: `Bearer ${await tokenFor(8612)}` },
})
expect(await status.json()).toMatchObject({ granted: false, avatar: false, screenshare: true })
await staffDelete(`${url}/8612?kind=screenshare`, 8110)
expect((await getAccount(env.DB, 8612))?.hasScreenshare).toBe(false)
expect(await (await staffGet(`${url}?kind=screenshare`, 8110)).json()).toEqual({ accounts: [] })
expect(await auditRows('grant_avatar_access', 8611)).toHaveLength(2)
expect(await auditRows('revoke_screenshare_access', 8612)).toHaveLength(1)
})
it('grants and revokes each Avatar Studio feature without changing other permissions', async () => {
const features = {
'avatar-items': 1,
'avatar-costumes': 2,
'avatar-badges': 4,
'avatar-consumables': 8,
}
const url = '/api/staff/studio-access'
await updateAccount(env.DB, 8621, {
username: 'FeatureFan',
hasStudio: true,
hasScreenshare: false,
})
await env.DB.prepare('INSERT INTO avatar_studio_access VALUES (?, ?, ?, ?)')
.bind(8621, 16, 'original', 'original')
.run()
let expected = 16
const status = async () => {
const response = await SELF.fetch('https://example.com/api/studio-access', {
headers: { authorization: `Bearer ${await tokenFor(8621)}` },
})
expect(response.status).toBe(200)
return response.json() as Promise<Record<string, boolean>>
}
for (const [kind, mask] of Object.entries(features)) {
expect((await status())[kind]).toBe(false)
const denied = await SELF.fetch(`https://example.com${url}`, {
method: 'POST',
headers: {
authorization: `Bearer ${await tokenFor(8621)}`,
'content-type': 'application/json',
},
body: JSON.stringify({ accountId: 8621, kind }),
})
expect(denied.status).toBe(403)
const deniedDelete = await SELF.fetch(`https://example.com${url}/8621?kind=${kind}`, {
method: 'DELETE',
headers: { authorization: `Bearer ${await tokenFor(8621)}` },
})
expect(deniedDelete.status).toBe(403)
expect((await staffPost(url, 8110, { username: '@FeatureFan', kind })).status).toBe(200)
expected |= mask
expect(await avatarStudioFlags(env.DB, 8621)).toBe(expected)
const repeated = await staffPost(url, 8110, { accountId: 8621, kind })
expect(await repeated.json()).toMatchObject({ alreadyGranted: true })
expect((await status())[kind]).toBe(true)
const list = await staffGet(`${url}?kind=${kind}`, 8110)
expect(await list.json()).toMatchObject({
accounts: expect.arrayContaining([
expect.objectContaining({ accountId: 8621, enabled: true }),
]),
})
}
for (const [kind, mask] of Object.entries(features)) {
expect((await staffDelete(`${url}/8621?kind=${kind}`, 8110)).status).toBe(200)
expected &= ~mask
expect(await avatarStudioFlags(env.DB, 8621)).toBe(expected)
expect((await status())[kind]).toBe(false)
for (const [otherKind, otherMask] of Object.entries(features)) {
expect((await status())[otherKind]).toBe((expected & otherMask) !== 0)
}
const list = await staffGet(`${url}?kind=${kind}`, 8110)
const remaining = (await list.json()) as { accounts: Array<{ accountId: number }> }
expect(remaining.accounts.some((a) => a.accountId === 8621)).toBe(false)
const repeat = await staffDelete(`${url}/8621?kind=${kind}`, 8110)
expect(await repeat.json()).toMatchObject({ removed: false })
expect(await auditRows(`grant_${kind}_access`, 8621)).toHaveLength(2)
expect(await auditRows(`revoke_${kind}_access`, 8621)).toHaveLength(2)
}
expect((await getAccount(env.DB, 8621))?.hasStudio).toBe(true)
expect((await getAccount(env.DB, 8621))?.hasScreenshare).toBe(false)
expect(
await env.DB.prepare('SELECT created_at FROM avatar_studio_access WHERE account_id=8621').first(
'created_at'
)
).toBe('original')
})
+18 -2
View File
@@ -11,6 +11,7 @@
* it from `@repo/domain` (each uses the subset it needs).
*/
import { AVATAR_STUDIO_ACCESS } from './avatar-studio'
import { bindPlaceholders, chunkForBinds } from './d1-binds'
/**
@@ -206,6 +207,8 @@ export interface Account {
* open keeps its old access until it signs in again or refreshes.
*/
hasStudio?: boolean
/** Staff override for screen sharing. Absent means screen sharing is disabled. */
hasScreenshare?: boolean
}
interface AccountRow {
@@ -500,10 +503,23 @@ export async function listInfluencerIds(db: D1Database): Promise<number[]> {
* Every account flagged `hasStudio`, lowest id first — the website's staff list. A JSON
* scan rather than a generated column: the list is short and only staff read it.
*/
export async function listStudioAccounts(db: D1Database): Promise<Account[]> {
export async function listStudioAccounts(
db: D1Database,
kind: 'studio' | 'avatar' | 'screenshare' | keyof typeof AVATAR_STUDIO_ACCESS = 'studio'
): Promise<Account[]> {
const mask =
kind === 'avatar'
? 7
: Object.hasOwn(AVATAR_STUDIO_ACCESS, kind)
? AVATAR_STUDIO_ACCESS[kind as keyof typeof AVATAR_STUDIO_ACCESS]
: 0
const { results } = await db
.prepare(
`SELECT data FROM account WHERE json_extract(data, '$.hasStudio') = 1 ORDER BY account_id`
mask !== 0
? `SELECT data FROM account WHERE account_id IN (SELECT account_id FROM avatar_studio_access WHERE (flags & ${mask}) != 0) ORDER BY account_id`
: kind === 'screenshare'
? `SELECT data FROM account WHERE json_extract(data, '$.hasScreenshare') = 1 ORDER BY account_id`
: `SELECT data FROM account WHERE json_extract(data, '$.hasStudio') = 1 ORDER BY account_id`
)
.all<AccountRow>()
return parseAll(results)
+8
View File
@@ -28,6 +28,14 @@ export interface AvatarStudioSave {
update_current: number
created_at: string
}
/** Independent authoring permissions reported by Studio's access override. */
export const AVATAR_STUDIO_ACCESS = {
'avatar-items': 1,
'avatar-costumes': 2,
'avatar-badges': 4,
'avatar-consumables': 8,
} as const
export async function avatarStudioFlags(db: D1Database, accountId: number): Promise<number> {
const row = await db
.prepare('SELECT flags FROM avatar_studio_access WHERE account_id=?')