mirror of
https://github.com/djdevin/recflare.git
synced 2026-10-27 07:58:55 -07:00
[www] Manage Avatar Studio and screen-sharing access (#144)
This commit is contained in:
+11
-10
@@ -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. */
|
/** Whether one account row holds a role. Absent/false both mean no role. */
|
||||||
function holdsRole(account: Account, role: 'developer' | 'moderator' | 'screenshare'): boolean {
|
function holdsRole(account: Account, role: 'developer' | 'moderator' | 'screenshare'): boolean {
|
||||||
// Matches accountTokenRoles: screenshare is a feature gate on every account token.
|
// Match the same staff override used when minting account tokens.
|
||||||
if (role === 'screenshare') return true
|
if (role === 'screenshare') return account.hasScreenshare === true
|
||||||
return (role === 'developer' ? account.isDeveloper : account.isModerator) === 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
|
* 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
|
* (gameClient) are added by generateToken. `screenshare` requires an explicit staff
|
||||||
* client gates the screen-share feature on it and nothing grants it per-account, so it
|
* grant via `hasScreenshare`. The other roles come from account flags and Studio access.
|
||||||
* 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 `studio` — the account's Studio access as `hasStudioAccess`
|
* `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
|
* 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.
|
* deterministic: screenshare, developer, moderator, junior, betastudio, then influencer.
|
||||||
*/
|
*/
|
||||||
function accountRoles(
|
function accountRoles(
|
||||||
account: Pick<Account, 'isDeveloper' | 'isModerator' | 'isJunior' | 'isInfluencer'> | null,
|
account: Pick<
|
||||||
|
Account,
|
||||||
|
'isDeveloper' | 'isModerator' | 'isJunior' | 'isInfluencer' | 'hasScreenshare'
|
||||||
|
> | null,
|
||||||
studio: boolean
|
studio: boolean
|
||||||
): string[] {
|
): string[] {
|
||||||
const roles = ['screenshare']
|
const roles: string[] = account?.hasScreenshare === true ? ['screenshare'] : []
|
||||||
if (!account) return roles
|
if (!account) return roles
|
||||||
if (account.isDeveloper) roles.push('developer')
|
if (account.isDeveloper) roles.push('developer')
|
||||||
if (account.isModerator) roles.push('moderator')
|
if (account.isModerator) roles.push('moderator')
|
||||||
@@ -1103,8 +1104,8 @@ const app = new Hono<App>()
|
|||||||
'',
|
'',
|
||||||
'**Roles.** The token embeds a `role` claim from the account, so developer/moderator',
|
'**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',
|
'powers refresh on every login and every refresh grant. `junior` rides along for an',
|
||||||
'account flagged `isJunior`, and `screenshare` is on every token — it is a feature',
|
'account flagged `isJunior`. `screenshare` requires a staff grant on the Studio Access',
|
||||||
'gate the client reads, not a privilege anyone is granted. `betastudio` is stamped',
|
'page. `betastudio` is stamped',
|
||||||
'from the account’s `hasStudio` flag (staff set it on the website) or from a Discord',
|
'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',
|
'link holding a role in the `STUDIO_ROLLOUT` var. RecFlare Studio',
|
||||||
'treats that claim as permission to upload; `developer` does not confer it. A junior',
|
'treats that claim as permission to upload; `developer` does not confer it. A junior',
|
||||||
|
|||||||
@@ -301,7 +301,7 @@ export function roleLookup(role: 'developer' | 'moderator' | 'screenshare') {
|
|||||||
summary: `Whether a player has the ${role} role`,
|
summary: `Whether a player has the ${role} role`,
|
||||||
description:
|
description:
|
||||||
role === 'screenshare'
|
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 ` +
|
: `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 ` +
|
`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` +
|
`in the access token's \`role\` claim, so the client rarely needs this route.\n\n` +
|
||||||
|
|||||||
@@ -692,8 +692,8 @@ describe('auth worker routes', () => {
|
|||||||
expect(payload.iss).toBe('https://auth.recflare.net')
|
expect(payload.iss).toBe('https://auth.recflare.net')
|
||||||
expect(payload.aud).toBe('https://auth.recflare.net')
|
expect(payload.aud).toBe('https://auth.recflare.net')
|
||||||
expect(payload.role).toContain('gameClient')
|
expect(payload.role).toContain('gameClient')
|
||||||
// screenshare is a feature gate, not a grant — every token carries it.
|
// Screen sharing is disabled until staff explicitly grant it.
|
||||||
expect(payload.role).toContain('screenshare')
|
expect(payload.role).not.toContain('screenshare')
|
||||||
// A plain adult account carries nothing beyond those — no elevated roles.
|
// A plain adult account carries nothing beyond those — no elevated roles.
|
||||||
expect(payload.role).not.toContain('developer')
|
expect(payload.role).not.toContain('developer')
|
||||||
expect(payload.role).not.toContain('moderator')
|
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
|
// 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
|
// token that kept the role for the rest of the day would leave a removed player
|
||||||
// uploading.
|
// 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 () => {
|
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,
|
// 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.
|
// 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 })
|
await updateAccount(env.DB, 9811, { hasStudio: true })
|
||||||
expect((await tokenFor(`account_id=9811&password=${LOGIN_PASSWORD}`)).role).toEqual([
|
expect((await tokenFor(`account_id=9811&password=${LOGIN_PASSWORD}`)).role).toEqual([
|
||||||
'gameClient',
|
'gameClient',
|
||||||
'screenshare',
|
|
||||||
'betastudio',
|
'betastudio',
|
||||||
])
|
])
|
||||||
|
|
||||||
@@ -780,7 +810,6 @@ describe('auth worker routes', () => {
|
|||||||
.run()
|
.run()
|
||||||
expect((await tokenFor(`account_id=9812&password=${LOGIN_PASSWORD}`)).role).toEqual([
|
expect((await tokenFor(`account_id=9812&password=${LOGIN_PASSWORD}`)).role).toEqual([
|
||||||
'gameClient',
|
'gameClient',
|
||||||
'screenshare',
|
|
||||||
'developer',
|
'developer',
|
||||||
'moderator',
|
'moderator',
|
||||||
'betastudio',
|
'betastudio',
|
||||||
@@ -813,7 +842,6 @@ describe('auth worker routes', () => {
|
|||||||
expect(signedIn.status).toBe(200)
|
expect(signedIn.status).toBe(200)
|
||||||
expect(decodePayload(signedIn.json.access_token as string).role).toEqual([
|
expect(decodePayload(signedIn.json.access_token as string).role).toEqual([
|
||||||
'gameClient',
|
'gameClient',
|
||||||
'screenshare',
|
|
||||||
'betastudio',
|
'betastudio',
|
||||||
])
|
])
|
||||||
|
|
||||||
@@ -854,7 +882,7 @@ describe('auth worker routes', () => {
|
|||||||
|
|
||||||
// Same list grammar as DISCORD_ROLLOUT_ROLE_IDS; any one named role qualifies.
|
// Same list grammar as DISCORD_ROLLOUT_ROLE_IDS; any one named role qualifies.
|
||||||
env.STUDIO_ROLLOUT = ` ${otherRole}x ,\n${studioRole} `
|
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.
|
// A Discord link holding only other roles is not in the rollout.
|
||||||
expect((await login(9814)).role).not.toContain('betastudio')
|
expect((await login(9814)).role).not.toContain('betastudio')
|
||||||
// The account flag never moved: this is the var, not a grant written anywhere.
|
// The account flag never moved: this is the var, not a grant written anywhere.
|
||||||
@@ -945,7 +973,7 @@ describe('auth worker routes', () => {
|
|||||||
)
|
)
|
||||||
.run()
|
.run()
|
||||||
const payload = await tokenFor(`account_id=92&password=${LOGIN_PASSWORD}`)
|
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')
|
expect(payload.role).not.toContain('developer')
|
||||||
// `rn.privilege` is a claim, not a scope — it sits beside `role`, never in `scope`.
|
// `rn.privilege` is a claim, not a scope — it sits beside `role`, never in `scope`.
|
||||||
expect(payload['rn.privilege']).toEqual(['BanVChat', 'BanRmChat'])
|
expect(payload['rn.privilege']).toEqual(['BanVChat', 'BanRmChat'])
|
||||||
@@ -964,9 +992,7 @@ describe('auth worker routes', () => {
|
|||||||
)
|
)
|
||||||
.run()
|
.run()
|
||||||
const payload = await tokenFor(`account_id=95&password=${LOGIN_PASSWORD}`)
|
const payload = await tokenFor(`account_id=95&password=${LOGIN_PASSWORD}`)
|
||||||
expect(payload.role).toEqual(
|
expect(payload.role).toEqual(expect.arrayContaining(['gameClient', 'influencer']))
|
||||||
expect.arrayContaining(['gameClient', 'screenshare', 'influencer'])
|
|
||||||
)
|
|
||||||
expect(payload.role).not.toContain('developer')
|
expect(payload.role).not.toContain('developer')
|
||||||
|
|
||||||
// Off the flag, off the token — no other flag implies it.
|
// 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]) {
|
for (const id of [42, 77]) {
|
||||||
const res = await exports.default.fetch(`${ORIGIN}/role/screenshare/${id}`)
|
const res = await exports.default.fetch(`${ORIGIN}/role/screenshare/${id}`)
|
||||||
expect(res.status).toBe(200)
|
expect(res.status).toBe(200)
|
||||||
expect(await res.json()).toBe(true)
|
expect(await res.json()).toBe(false)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -14,16 +14,29 @@ import { call, isAdmin, useAction } from './api'
|
|||||||
|
|
||||||
const PAGE = '/settings/recroomstudio'
|
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 {
|
interface AccessRow {
|
||||||
accountId: number
|
accountId: number
|
||||||
username: string | null
|
username: string | null
|
||||||
displayName: string | null
|
displayName: string | null
|
||||||
|
enabled: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export function StudioAccessPage({
|
function AccessPage({
|
||||||
|
kind,
|
||||||
account,
|
account,
|
||||||
navigate,
|
navigate,
|
||||||
}: {
|
}: {
|
||||||
|
kind: AccessKind
|
||||||
/** undefined while the session is still being checked, null when signed out. */
|
/** undefined while the session is still being checked, null when signed out. */
|
||||||
account: { accountId: number; username: string } | null | undefined
|
account: { accountId: number; username: string } | null | undefined
|
||||||
navigate: (to: string) => void
|
navigate: (to: string) => void
|
||||||
@@ -39,15 +52,17 @@ export function StudioAccessPage({
|
|||||||
const add = useAction()
|
const add = useAction()
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
const load = useCallback(async () => {
|
||||||
const me = await call<{ granted: boolean }>('/api/studio-access', { authed: true })
|
const me = await call<Record<AccessKind | 'granted', boolean>>('/api/studio-access', {
|
||||||
setGranted(me.granted === true)
|
authed: true,
|
||||||
|
})
|
||||||
|
setGranted((kind === 'studio' ? me.granted : me[kind]) === true)
|
||||||
if (isAdmin()) {
|
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,
|
authed: true,
|
||||||
})
|
})
|
||||||
setRows(page.accounts ?? [])
|
setRows(page.accounts ?? [])
|
||||||
}
|
}
|
||||||
}, [])
|
}, [kind])
|
||||||
|
|
||||||
const accountId = account?.accountId
|
const accountId = account?.accountId
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -80,13 +95,13 @@ export function StudioAccessPage({
|
|||||||
void add.run(async () => {
|
void add.run(async () => {
|
||||||
const result = await call<{ username: string; alreadyGranted: boolean }>(
|
const result = await call<{ username: string; alreadyGranted: boolean }>(
|
||||||
'/api/staff/studio-access',
|
'/api/staff/studio-access',
|
||||||
{ method: 'POST', authed: true, json: { username: username.trim() } }
|
{ method: 'POST', authed: true, json: { username: username.trim(), kind } }
|
||||||
)
|
)
|
||||||
setUsername('')
|
setUsername('')
|
||||||
await reload()
|
await reload()
|
||||||
return result.alreadyGranted
|
return result.alreadyGranted
|
||||||
? `@${result.username} is already on the list.`
|
? `@${result.username} already has ${accessLabels[kind]} access.`
|
||||||
: `@${result.username} can upload the next time they sign in to Studio.`
|
: `@${result.username} now has ${accessLabels[kind]} access.`
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -95,7 +110,14 @@ export function StudioAccessPage({
|
|||||||
setRemoveError('')
|
setRemoveError('')
|
||||||
void (async () => {
|
void (async () => {
|
||||||
try {
|
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()
|
await reload()
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setRemoveError(err instanceof Error ? err.message : String(err))
|
setRemoveError(err instanceof Error ? err.message : String(err))
|
||||||
@@ -108,13 +130,16 @@ export function StudioAccessPage({
|
|||||||
const signInHref = `/login?next=${encodeURIComponent(PAGE)}`
|
const signInHref = `/login?next=${encodeURIComponent(PAGE)}`
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className={staff ? 'shell wide' : 'shell'}>
|
<section>
|
||||||
<h1>RecFlare Studio access</h1>
|
<h2>{accessLabels[kind]}</h2>
|
||||||
<section className="card">
|
<section className="card">
|
||||||
<p className="muted">
|
<p className="muted">
|
||||||
RecFlare Studio signs in with any account. Uploading is limited to accounts on this list.
|
{kind === 'studio'
|
||||||
Access is picked up the next time you sign in to Studio, or when that session refreshes. A
|
? 'Manage permission to upload from RecFlare Studio.'
|
||||||
session you already have keeps its old access until then, and a session lasts a day.
|
: 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>
|
</p>
|
||||||
{account === undefined ? (
|
{account === undefined ? (
|
||||||
<p className="muted">Loading…</p>
|
<p className="muted">Loading…</p>
|
||||||
@@ -130,7 +155,7 @@ export function StudioAccessPage({
|
|||||||
>
|
>
|
||||||
Sign in
|
Sign in
|
||||||
</a>{' '}
|
</a>{' '}
|
||||||
<span className="muted">to see whether this account can upload.</span>
|
<span className="muted">to see the access for this account.</span>
|
||||||
</p>
|
</p>
|
||||||
) : loading ? (
|
) : loading ? (
|
||||||
<p className="muted">Loading…</p>
|
<p className="muted">Loading…</p>
|
||||||
@@ -138,22 +163,23 @@ export function StudioAccessPage({
|
|||||||
<p className="error">{loadError}</p>
|
<p className="error">{loadError}</p>
|
||||||
) : granted ? (
|
) : granted ? (
|
||||||
<p>
|
<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.
|
says otherwise, sign out of it and sign in again.
|
||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<p>
|
<p>
|
||||||
<strong>{account.username}</strong> cannot upload in RecFlare Studio yet. A moderator
|
<strong>{account.username}</strong> does not have {accessLabels[kind]} access. A
|
||||||
can add this account to the list.
|
moderator can add this account to the list.
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
{staff && account && (
|
{staff && account && (
|
||||||
<section className="card">
|
<section className="card">
|
||||||
<h2>Whitelist</h2>
|
<h3>Whitelist</h3>
|
||||||
<p className="muted">
|
<p className="muted">
|
||||||
Adding a player puts the betastudio role on their next Studio sign-in. Developers are
|
{kind === 'screenshare'
|
||||||
not on this list unless someone adds them.
|
? 'Only users on this list can share their screen.'
|
||||||
|
: 'Add a username to grant access, or remove a player to revoke it.'}
|
||||||
</p>
|
</p>
|
||||||
<form
|
<form
|
||||||
onSubmit={(e) => {
|
onSubmit={(e) => {
|
||||||
@@ -172,7 +198,7 @@ export function StudioAccessPage({
|
|||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<button type="submit" disabled={add.pending || username.trim() === ''}>
|
<button type="submit" disabled={add.pending || username.trim() === ''}>
|
||||||
Add
|
{kind === 'screenshare' ? 'Enable' : 'Add'}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
{add.error && <p className="error">{add.error}</p>}
|
{add.error && <p className="error">{add.error}</p>}
|
||||||
@@ -180,7 +206,7 @@ export function StudioAccessPage({
|
|||||||
{loading ? (
|
{loading ? (
|
||||||
<p className="muted">Loading…</p>
|
<p className="muted">Loading…</p>
|
||||||
) : loadError ? null : rows.length === 0 ? (
|
) : 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">
|
<ul className="studio-access-list">
|
||||||
{rows.map((row) => (
|
{rows.map((row) => (
|
||||||
@@ -192,13 +218,12 @@ export function StudioAccessPage({
|
|||||||
{row.displayName && row.displayName !== row.username ? (
|
{row.displayName && row.displayName !== row.username ? (
|
||||||
<span className="muted"> · {row.displayName}</span>
|
<span className="muted"> · {row.displayName}</span>
|
||||||
) : null}
|
) : null}
|
||||||
|
{kind === 'screenshare' && (
|
||||||
|
<span className="muted"> - {row.enabled ? 'Enabled' : 'Disabled'}</span>
|
||||||
|
)}
|
||||||
</span>
|
</span>
|
||||||
<button
|
<button type="button" disabled={removing !== null} onClick={() => remove(row)}>
|
||||||
type="button"
|
{kind === 'screenshare' ? (row.enabled ? 'Disable' : 'Enable') : 'Remove'}
|
||||||
disabled={removing === row.accountId}
|
|
||||||
onClick={() => remove(row)}
|
|
||||||
>
|
|
||||||
Remove
|
|
||||||
</button>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
@@ -207,6 +232,20 @@ export function StudioAccessPage({
|
|||||||
{removeError && <p className="error">{removeError}</p>}
|
{removeError && <p className="error">{removeError}</p>}
|
||||||
</section>
|
</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>
|
</main>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import {
|
import {
|
||||||
|
AVATAR_STUDIO_ACCESS,
|
||||||
|
avatarStudioFlags,
|
||||||
getAccount,
|
getAccount,
|
||||||
getAccountByUsername,
|
getAccountByUsername,
|
||||||
listStudioAccounts,
|
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())
|
const accountId = await validateAndGetAccountId(c.req.raw, await c.env.JWT_SECRET.get())
|
||||||
if (accountId === null) return c.json({ error: 'Unauthorized' }, 401)
|
if (accountId === null) return c.json({ error: 'Unauthorized' }, 401)
|
||||||
const account = await getAccount(c.env.DB, accountId)
|
const account = await getAccount(c.env.DB, accountId)
|
||||||
return c.json({ granted: 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. */
|
/** Every account with the flag set. */
|
||||||
export async function listStudioAccessHandler(c: Context<App>) {
|
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({
|
return c.json({
|
||||||
accounts: accounts.map((account) => ({
|
accounts: accounts.map((account) => ({
|
||||||
accountId: account.accountId,
|
accountId: account.accountId,
|
||||||
username: account.username,
|
username: account.username,
|
||||||
displayName: account.displayName,
|
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)) {
|
if (body === null || typeof body !== 'object' || Array.isArray(body)) {
|
||||||
return c.json({ error: 'Expected a JSON body' }, 400)
|
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 =
|
const username =
|
||||||
typeof posted.username === 'string' ? posted.username.trim().replace(/^@/, '') : ''
|
typeof posted.username === 'string' ? posted.username.trim().replace(/^@/, '') : ''
|
||||||
const accountId = postedAccountId(posted.accountId)
|
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)
|
return c.json({ error: 'Name a player by username or account id' }, 400)
|
||||||
}
|
}
|
||||||
|
|
||||||
const alreadyGranted = account.hasStudio === true
|
const alreadyGranted =
|
||||||
if (!alreadyGranted) await updateAccount(c.env.DB, account.accountId, { hasStudio: true })
|
avatarMask(kind) !== 0
|
||||||
await recordAudit(c, 'grant_studio_access', {
|
? ((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,
|
playerId: account.accountId,
|
||||||
username: account.username,
|
username: account.username,
|
||||||
alreadyGranted,
|
alreadyGranted,
|
||||||
@@ -98,15 +135,22 @@ export async function grantStudioAccessHandler(c: Context<App>) {
|
|||||||
* account is a 404.
|
* account is a 404.
|
||||||
*/
|
*/
|
||||||
export async function revokeStudioAccessHandler(c: Context<App>) {
|
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'))
|
const accountId = Number(c.req.param('id'))
|
||||||
if (!Number.isInteger(accountId) || accountId <= 0) {
|
if (!Number.isInteger(accountId) || accountId <= 0) {
|
||||||
return c.json({ error: 'A numeric player id is required' }, 400)
|
return c.json({ error: 'A numeric player id is required' }, 400)
|
||||||
}
|
}
|
||||||
const account = await getAccount(c.env.DB, accountId)
|
const account = await getAccount(c.env.DB, accountId)
|
||||||
if (!account) return c.json({ error: 'No player has that account id' }, 404)
|
if (!account) return c.json({ error: 'No player has that account id' }, 404)
|
||||||
const removed = account.hasStudio === true
|
const removed =
|
||||||
if (removed) await updateAccount(c.env.DB, accountId, { hasStudio: false })
|
avatarMask(kind) !== 0
|
||||||
await recordAudit(c, 'revoke_studio_access', {
|
? ((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,
|
playerId: accountId,
|
||||||
username: account.username,
|
username: account.username,
|
||||||
removed,
|
removed,
|
||||||
@@ -114,6 +158,26 @@ export async function revokeStudioAccessHandler(c: Context<App>) {
|
|||||||
return c.json({ accountId, granted: false, removed })
|
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. */
|
/** A positive integer account id from a JSON field, or null when it isn't one. */
|
||||||
function postedAccountId(value: unknown): number | null {
|
function postedAccountId(value: unknown): number | null {
|
||||||
if (typeof value === 'number' && Number.isInteger(value) && value > 0) return value
|
if (typeof value === 'number' && Number.isInteger(value) && value > 0) return value
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
updateAccount,
|
updateAccount,
|
||||||
} from '@repo/domain/src/accounts-db'
|
} from '@repo/domain/src/accounts-db'
|
||||||
import { AUDIT_LOG_SCHEMA_DDL } from '@repo/domain/src/audit-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 { PlatformType } from '@repo/domain/src/enums'
|
||||||
import { getPendingGifts, RECEIVED_GIFT_SCHEMA_DDL } from '@repo/domain/src/gifts-db'
|
import { getPendingGifts, RECEIVED_GIFT_SCHEMA_DDL } from '@repo/domain/src/gifts-db'
|
||||||
import {
|
import {
|
||||||
@@ -132,6 +133,7 @@ beforeAll(async () => {
|
|||||||
for (const stmt of PRESENCE_SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
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.
|
// `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 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.
|
// And `platform_account`, where a claimed Discord identity is linked.
|
||||||
for (const stmt of PLATFORM_SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
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
|
// `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'])}` },
|
headers: { authorization: `Bearer ${await tokenFor(8601, ['gameClient'])}` },
|
||||||
})
|
})
|
||||||
expect(res.status).toBe(200)
|
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
|
// 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.
|
// 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', {
|
const rolled = await SELF.fetch('https://example.com/api/studio-access', {
|
||||||
headers: { authorization: `Bearer ${await tokenFor(8601, ['gameClient'])}` },
|
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.
|
// The flag itself never moved; the staff list is still empty of this account.
|
||||||
expect((await getAccount(env.DB, 8601))?.hasStudio).not.toBe(true)
|
expect((await getAccount(env.DB, 8601))?.hasStudio).not.toBe(true)
|
||||||
} finally {
|
} 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', {
|
const mine = await SELF.fetch('https://example.com/api/studio-access', {
|
||||||
headers: { authorization: `Bearer ${await tokenFor(8601, ['gameClient'])}` },
|
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)
|
const list = await staffGet('/api/staff/studio-access', 8110)
|
||||||
expect(list.status).toBe(200)
|
expect(list.status).toBe(200)
|
||||||
const body = (await list.json()) as { accounts: Array<{ accountId: number }> }
|
const body = (await list.json()) as { accounts: Array<{ accountId: number }> }
|
||||||
expect(body.accounts).toEqual([
|
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)
|
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', {
|
const after = await SELF.fetch('https://example.com/api/studio-access', {
|
||||||
headers: { authorization: `Bearer ${await tokenFor(8601, ['gameClient'])}` },
|
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([
|
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],
|
[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')
|
||||||
|
})
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
* it from `@repo/domain` (each uses the subset it needs).
|
* it from `@repo/domain` (each uses the subset it needs).
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { AVATAR_STUDIO_ACCESS } from './avatar-studio'
|
||||||
import { bindPlaceholders, chunkForBinds } from './d1-binds'
|
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.
|
* open keeps its old access until it signs in again or refreshes.
|
||||||
*/
|
*/
|
||||||
hasStudio?: boolean
|
hasStudio?: boolean
|
||||||
|
/** Staff override for screen sharing. Absent means screen sharing is disabled. */
|
||||||
|
hasScreenshare?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
interface AccountRow {
|
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
|
* 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.
|
* 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
|
const { results } = await db
|
||||||
.prepare(
|
.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>()
|
.all<AccountRow>()
|
||||||
return parseAll(results)
|
return parseAll(results)
|
||||||
|
|||||||
@@ -28,6 +28,14 @@ export interface AvatarStudioSave {
|
|||||||
update_current: number
|
update_current: number
|
||||||
created_at: string
|
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> {
|
export async function avatarStudioFlags(db: D1Database, accountId: number): Promise<number> {
|
||||||
const row = await db
|
const row = await db
|
||||||
.prepare('SELECT flags FROM avatar_studio_access WHERE account_id=?')
|
.prepare('SELECT flags FROM avatar_studio_access WHERE account_id=?')
|
||||||
|
|||||||
Reference in New Issue
Block a user