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. */
|
||||
function holdsRole(account: Account, role: 'developer' | 'moderator' | 'screenshare'): boolean {
|
||||
// Matches accountTokenRoles: screenshare is a feature gate on every account token.
|
||||
if (role === 'screenshare') return true
|
||||
// Match the same staff override used when minting account tokens.
|
||||
if (role === 'screenshare') return account.hasScreenshare === true
|
||||
return (role === 'developer' ? account.isDeveloper : account.isModerator) === true
|
||||
}
|
||||
|
||||
@@ -382,10 +382,8 @@ async function roleFilterAnswer(c: Context<App>, role: 'developer' | 'moderator'
|
||||
|
||||
/**
|
||||
* The role names beyond `gameClient` for an account's token `role` claim. Base roles
|
||||
* (gameClient) are added by generateToken. `screenshare` rides on EVERY token — the
|
||||
* client gates the screen-share feature on it and nothing grants it per-account, so it
|
||||
* is unconditional (even with no account resolved). The rest are the operator-granted
|
||||
* extras, plus `junior` off the account's own `isJunior` flag.
|
||||
* (gameClient) are added by generateToken. `screenshare` requires an explicit staff
|
||||
* grant via `hasScreenshare`. The other roles come from account flags and Studio access.
|
||||
*
|
||||
* `betastudio` rides on `studio` — the account's Studio access as `hasStudioAccess`
|
||||
* resolves it: the `hasStudio` flag staff set from the website, OR a Discord link holding
|
||||
@@ -397,10 +395,13 @@ async function roleFilterAnswer(c: Context<App>, role: 'developer' | 'moderator'
|
||||
* deterministic: screenshare, developer, moderator, junior, betastudio, then influencer.
|
||||
*/
|
||||
function accountRoles(
|
||||
account: Pick<Account, 'isDeveloper' | 'isModerator' | 'isJunior' | 'isInfluencer'> | null,
|
||||
account: Pick<
|
||||
Account,
|
||||
'isDeveloper' | 'isModerator' | 'isJunior' | 'isInfluencer' | 'hasScreenshare'
|
||||
> | null,
|
||||
studio: boolean
|
||||
): string[] {
|
||||
const roles = ['screenshare']
|
||||
const roles: string[] = account?.hasScreenshare === true ? ['screenshare'] : []
|
||||
if (!account) return roles
|
||||
if (account.isDeveloper) roles.push('developer')
|
||||
if (account.isModerator) roles.push('moderator')
|
||||
@@ -1103,8 +1104,8 @@ const app = new Hono<App>()
|
||||
'',
|
||||
'**Roles.** The token embeds a `role` claim from the account, so developer/moderator',
|
||||
'powers refresh on every login and every refresh grant. `junior` rides along for an',
|
||||
'account flagged `isJunior`, and `screenshare` is on every token — it is a feature',
|
||||
'gate the client reads, not a privilege anyone is granted. `betastudio` is stamped',
|
||||
'account flagged `isJunior`. `screenshare` requires a staff grant on the Studio Access',
|
||||
'page. `betastudio` is stamped',
|
||||
'from the account’s `hasStudio` flag (staff set it on the website) or from a Discord',
|
||||
'link holding a role in the `STUDIO_ROLLOUT` var. RecFlare Studio',
|
||||
'treats that claim as permission to upload; `developer` does not confer it. A junior',
|
||||
|
||||
@@ -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` +
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user