From f6214fb82449df40a629df1eccc8e639e739ad96 Mon Sep 17 00:00:00 2001
From: dukedotdev
Date: Mon, 26 Oct 2026 20:05:27 -0400
Subject: [PATCH] [www] Manage Avatar Studio and screen-sharing access (#144)
---
apps/auth/src/auth.app.ts | 21 +--
apps/auth/src/openapi.ts | 2 +-
apps/auth/src/test/integration/api.test.ts | 48 +++++--
apps/www/src/client/StudioAccess.tsx | 97 +++++++++-----
apps/www/src/studio-access.ts | 82 ++++++++++--
apps/www/src/test/integration/api.test.ts | 145 ++++++++++++++++++++-
packages/domain/src/accounts-db.ts | 20 ++-
packages/domain/src/avatar-studio.ts | 8 ++
8 files changed, 356 insertions(+), 67 deletions(-)
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.'}