diff --git a/apps/auth/src/auth.app.ts b/apps/auth/src/auth.app.ts index 97a708c..edbe135 100644 --- a/apps/auth/src/auth.app.ts +++ b/apps/auth/src/auth.app.ts @@ -331,8 +331,8 @@ async function authedId(c: Context): Promise { /** 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, 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, role: 'developer' | 'moderator' * deterministic: screenshare, developer, moderator, junior, betastudio, then influencer. */ function accountRoles( - account: Pick | 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() '', '**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', diff --git a/apps/auth/src/openapi.ts b/apps/auth/src/openapi.ts index 86df6bf..1d581cb 100644 --- a/apps/auth/src/openapi.ts +++ b/apps/auth/src/openapi.ts @@ -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` + diff --git a/apps/auth/src/test/integration/api.test.ts b/apps/auth/src/test/integration/api.test.ts index af3a617..261b077 100644 --- a/apps/auth/src/test/integration/api.test.ts +++ b/apps/auth/src/test/integration/api.test.ts @@ -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) } }) diff --git a/apps/www/src/client/StudioAccess.tsx b/apps/www/src/client/StudioAccess.tsx index 6742e24..dc7b6d2 100644 --- a/apps/www/src/client/StudioAccess.tsx +++ b/apps/www/src/client/StudioAccess.tsx @@ -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>('/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 ( -
-

RecFlare Studio access

+
+

{accessLabels[kind]}

- 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.

{account === undefined ? (

Loading…

@@ -130,7 +155,7 @@ export function StudioAccessPage({ > Sign in {' '} - to see whether this account can upload. + to see the access for this account.

) : loading ? (

Loading…

@@ -138,22 +163,23 @@ export function StudioAccessPage({

{loadError}

) : granted ? (

- {account.username} can upload in RecFlare Studio. If the editor still + {account.username} has {accessLabels[kind]} access. If the editor still says otherwise, sign out of it and sign in again.

) : (

- {account.username} cannot upload in RecFlare Studio yet. A moderator - can add this account to the list. + {account.username} does not have {accessLabels[kind]} access. A + moderator can add this account to the list.

)}
{staff && account && (
-

Whitelist

+

Whitelist

- 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.'}

{ @@ -172,7 +198,7 @@ export function StudioAccessPage({ />
{add.error &&

{add.error}

} @@ -180,7 +206,7 @@ export function StudioAccessPage({ {loading ? (

Loading…

) : loadError ? null : rows.length === 0 ? ( -

Nobody is on the list.

+

No users on this list.

) : (
    {rows.map((row) => ( @@ -192,13 +218,12 @@ export function StudioAccessPage({ {row.displayName && row.displayName !== row.username ? ( · {row.displayName} ) : null} + {kind === 'screenshare' && ( + - {row.enabled ? 'Enabled' : 'Disabled'} + )} - ))} @@ -207,6 +232,20 @@ export function StudioAccessPage({ {removeError &&

    {removeError}

    }
)} +
+ ) +} + +export function StudioAccessPage(props: { + account: { accountId: number; username: string } | null | undefined + navigate: (to: string) => void +}) { + return ( +
+

RecFlare Studio access

+ {(Object.keys(accessLabels) as AccessKind[]).map((kind) => ( + + ))}
) } diff --git a/apps/www/src/studio-access.ts b/apps/www/src/studio-access.ts index 69a6df3..933c9bb 100644 --- a/apps/www/src/studio-access.ts +++ b/apps/www/src/studio-access.ts @@ -1,4 +1,6 @@ import { + AVATAR_STUDIO_ACCESS, + avatarStudioFlags, getAccount, getAccountByUsername, listStudioAccounts, @@ -33,17 +35,44 @@ export async function studioAccessStatusHandler(c: Context) { 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) { - 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) { 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) { 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) { * account is a 404. */ export async function revokeStudioAccessHandler(c: Context) { + 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) { return c.json({ accountId, granted: false, removed }) } +async function setAccess(c: Context, 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 diff --git a/apps/www/src/test/integration/api.test.ts b/apps/www/src/test/integration/api.test.ts index 205d2ec..b8e106e 100644 --- a/apps/www/src/test/integration/api.test.ts +++ b/apps/www/src/test/integration/api.test.ts @@ -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> + } + 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') +}) diff --git a/packages/domain/src/accounts-db.ts b/packages/domain/src/accounts-db.ts index 346a6fd..6dcb826 100644 --- a/packages/domain/src/accounts-db.ts +++ b/packages/domain/src/accounts-db.ts @@ -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 { * 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 { +export async function listStudioAccounts( + db: D1Database, + kind: 'studio' | 'avatar' | 'screenshare' | keyof typeof AVATAR_STUDIO_ACCESS = 'studio' +): Promise { + 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() return parseAll(results) diff --git a/packages/domain/src/avatar-studio.ts b/packages/domain/src/avatar-studio.ts index 7f682db..fd48857 100644 --- a/packages/domain/src/avatar-studio.ts +++ b/packages/domain/src/avatar-studio.ts @@ -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 { const row = await db .prepare('SELECT flags FROM avatar_studio_access WHERE account_id=?')