[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
+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')
})