[studio] beta studio (#114)

* rrs stuff

* studio service yay

* rrs whitelist

* moree studiooooooo fellowwww

* rec room studio room loading yay

* fixes yay

* fix 2 because idk  yayuh

* Update cdn.app.ts

* ccontent length fix?

* Preserve content-length and upgrade metadata

Return a real zero-filled byte array for CDN HEAD responses so the runtime preserves Content-Length while still sending no payload. In notify, re-create the Request with its original headers, set OWNER_HEADER from the validated token, and forward that Request to hub.fetch to avoid losing WebSocket upgrade metadata (which could trigger 500s in Durable Objects). These changes ensure the editor's size checks and WebSocket upgrades remain reliable.

* fix x524234

* shirt thing?

* [auth] remove grant table as we already have audit_log for now

* rename table to generic device_grant

* cleanup/lint

---------

Co-authored-by: Devin Zuczek <devin.zuczek@gmail.com>
This commit is contained in:
Crasho
2026-10-05 19:39:22 +01:00
committed by GitHub
parent 0900bcac25
commit 26270e9c25
52 changed files with 18231 additions and 103 deletions
+4
View File
@@ -33,6 +33,10 @@ Upstream hosts are derived from the shared base domain (`auth.<DOMAIN>`,
| POST | `/api/email` | accounts `POST /account/me/email` |
| POST | `/api/password` | auth `POST /account/me/changepassword` |
`/device` is a client-side page, not a BFF route. Rec Room Studio opens it (from
the `verification_uri` auth returns) so a signed-in player can approve the
editor's device code. The page calls auth `POST /connect/device/approve` itself.
On signup/login the access token returned by `auth` is stored in an httpOnly
`rf_token` cookie; the other routes read it and forward it as a Bearer token.
+25
View File
@@ -27,9 +27,11 @@ import {
usernamesFor,
where,
} from './api'
import { DeviceLinkPage } from './DeviceLink'
import { customAvatarItemIdFromPath, ItemPage } from './Item'
import { ModerationPage } from './Moderation'
import { StatsPage } from './Stats'
import { StudioAccessPage } from './StudioAccess'
import type { ReactNode } from 'react'
import type { Hosts } from './api'
@@ -1598,6 +1600,10 @@ export function App() {
navigate={navigate}
onAuthed={setAccount}
/>
) : path === '/device' ? (
// Studio opens this from verification_uri_complete. The code was minted by
// auth; this page only approves it with the website session.
<DeviceLinkPage account={account} search={search} navigate={navigate} />
) : path === '/account' ? (
<AccountPage account={account} config={config} navigate={navigate} onChange={setAccount} />
) : path === '/claim' ? (
@@ -1615,6 +1621,12 @@ export function App() {
// on any of them falls through to the SPA shell; the page then gates itself on
// the token's role, and every endpoint behind it re-checks.
<ModerationPage account={account} path={path} search={search} navigate={navigate} />
) : path === '/settings/recroomstudio' || path === '/settings/recroomstudio/' ? (
// The URL Studio already opens from the "not authorized to upload" dialog.
// A client-side route like /moderation: not in run_worker_first, so a cold
// load falls through to the SPA shell. The page shows one account's own
// status to whoever is signed in, and the whitelist editor only to staff.
<StudioAccessPage account={account} navigate={navigate} />
) : path === '/stats' ? (
// Unlinked on purpose — nothing in the nav or footer points here; it's for whoever
// is handed the URL. Public all the same, and a client-side route like the rest:
@@ -1706,6 +1718,19 @@ function NavBar({
Moderation
</Link>
)}
{isAdmin() && (
<Link
to="/settings/recroomstudio"
navigate={navigate}
className={
path === '/settings/recroomstudio' || path === '/settings/recroomstudio/'
? 'active'
: ''
}
>
Studio access
</Link>
)}
<Link to="/account" navigate={navigate} className={path === '/account' ? 'active' : ''}>
My account
</Link>
+92
View File
@@ -0,0 +1,92 @@
import { useEffect, useState } from 'react'
import { call, useAction, where } from './api'
/**
* The page RecFlare Studio opens after `POST /connect/deviceauthorization`.
*
* Studio shows `verification_uri` (`/device`) and opens `verification_uri_complete`
* (`/device?user_code=…`) in the browser. The API that created the code stays on
* `auth`; this page only approves or denies it, with the website session's token.
*/
export function DeviceLinkPage({
account,
search,
navigate,
}: {
/** undefined while the session is still being checked, null when signed out. */
account: { username: string } | null | undefined
search: string
navigate: (to: string) => void
}) {
const fromQuery = new URLSearchParams(search).get('user_code') ?? ''
const [code, setCode] = useState(fromQuery)
const { pending, error, done, run } = useAction()
useEffect(() => {
if (account !== null) return
const next = `/device${search}`
navigate(`/login?next=${encodeURIComponent(next)}`)
}, [account, navigate, search])
if (!account) {
return (
<main className="shell">
<p className="muted">{account === undefined ? 'Loading…' : 'Redirecting to sign in…'}</p>
</main>
)
}
const submit = (path: 'approve' | 'deny') => {
void run(async () => {
await call(`${where().auth}/connect/device/${path}`, {
authed: true,
form: { user_code: code.trim() },
})
return path === 'approve'
? 'Studio is signed in. You can close this tab and go back to the editor.'
: 'Studio was not signed in. You can close this tab.'
})
}
return (
<main className="shell">
<section className="card">
<h2>Sign in to RecFlare Studio</h2>
<p className="muted">
The editor asked to use <strong>{account.username}</strong>. The code below is the one
Studio is showing. Allowing it signs the editor into this account.
</p>
<form
onSubmit={(e) => {
e.preventDefault()
submit('approve')
}}
>
<label>
Code
<input
value={code}
autoComplete="off"
spellCheck={false}
onChange={(e) => setCode(e.target.value)}
required
/>
</label>
<button type="submit" disabled={pending || done !== ''}>
Allow
</button>
</form>
<button
type="button"
disabled={pending || done !== '' || code.trim() === ''}
onClick={() => submit('deny')}
>
Don’t allow
</button>
{error && <p className="error">{error}</p>}
{done && <p className="ok">{done}</p>}
</section>
</main>
)
}
+212
View File
@@ -0,0 +1,212 @@
import { useCallback, useEffect, useState } from 'react'
import { call, isAdmin, useAction } from './api'
/**
* The page RecFlare Studio opens when an account cannot upload.
*
* The editor's dialog links to `/settings/recroomstudio`. Anyone can land here
* and, once signed in, see whether this account is on the upload list. Staff
* also get the list itself: adding a player puts `betastudio` on their next
* Studio sign-in or token refresh. A session already open in the editor keeps
* its old access until then.
*/
const PAGE = '/settings/recroomstudio'
interface AccessRow {
accountId: number
username: string | null
displayName: string | null
}
export function StudioAccessPage({
account,
navigate,
}: {
/** undefined while the session is still being checked, null when signed out. */
account: { accountId: number; username: string } | null | undefined
navigate: (to: string) => void
}) {
const staff = account !== undefined && account !== null && isAdmin()
const [granted, setGranted] = useState(false)
const [rows, setRows] = useState<AccessRow[]>([])
const [loading, setLoading] = useState(false)
const [loadError, setLoadError] = useState('')
const [username, setUsername] = useState('')
const [removing, setRemoving] = useState<number | null>(null)
const [removeError, setRemoveError] = useState('')
const add = useAction()
const load = useCallback(async () => {
const me = await call<{ granted: boolean }>('/api/studio-access', { authed: true })
setGranted(me.granted === true)
if (isAdmin()) {
const page = await call<{ accounts: AccessRow[] }>('/api/staff/studio-access', {
authed: true,
})
setRows(page.accounts ?? [])
}
}, [])
const accountId = account?.accountId
useEffect(() => {
if (accountId === undefined) return
let cancelled = false
setLoading(true)
setLoadError('')
void load()
.catch((err: unknown) => {
if (!cancelled) setLoadError(err instanceof Error ? err.message : String(err))
})
.finally(() => {
if (!cancelled) setLoading(false)
})
return () => {
cancelled = true
}
}, [accountId, load])
const reload = async () => {
try {
await load()
setLoadError('')
} catch (err) {
setLoadError(err instanceof Error ? err.message : String(err))
}
}
const submitAdd = () => {
void add.run(async () => {
const result = await call<{ username: string; alreadyGranted: boolean }>(
'/api/staff/studio-access',
{ method: 'POST', authed: true, json: { username: username.trim() } }
)
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.`
})
}
const remove = (row: AccessRow) => {
setRemoving(row.accountId)
setRemoveError('')
void (async () => {
try {
await call(`/api/staff/studio-access/${row.accountId}`, { method: 'DELETE', authed: true })
await reload()
} catch (err) {
setRemoveError(err instanceof Error ? err.message : String(err))
} finally {
setRemoving(null)
}
})()
}
const signInHref = `/login?next=${encodeURIComponent(PAGE)}`
return (
<main className={staff ? 'shell wide' : 'shell'}>
<h1>RecFlare Studio access</h1>
<section className="card">
<p className="muted">
RecFlare Studio signs in with any account. Uploading is limited to accounts on this list.
Access is picked up the next time you sign in to Studio, or when that session refreshes. A
session you already have keeps its old access until then, and a session lasts a day.
</p>
{account === undefined ? (
<p className="muted">Loading…</p>
) : account === null ? (
<p>
<a
className="cta"
href={signInHref}
onClick={(e) => {
e.preventDefault()
navigate(signInHref)
}}
>
Sign in
</a>{' '}
<span className="muted">to see whether this account can upload.</span>
</p>
) : loading ? (
<p className="muted">Loading…</p>
) : loadError ? (
<p className="error">{loadError}</p>
) : granted ? (
<p>
<strong>{account.username}</strong> can upload in RecFlare Studio. If the editor still
says otherwise, sign out of it and sign in again.
</p>
) : (
<p>
<strong>{account.username}</strong> cannot upload in RecFlare Studio yet. A moderator
can add this account to the list.
</p>
)}
</section>
{staff && account && (
<section className="card">
<h2>Whitelist</h2>
<p className="muted">
Adding a player puts the betastudio role on their next Studio sign-in. Developers are
not on this list unless someone adds them.
</p>
<form
onSubmit={(e) => {
e.preventDefault()
submitAdd()
}}
>
<label>
Username
<input
value={username}
autoComplete="off"
spellCheck={false}
onChange={(e) => setUsername(e.target.value)}
required
/>
</label>
<button type="submit" disabled={add.pending || username.trim() === ''}>
Add
</button>
</form>
{add.error && <p className="error">{add.error}</p>}
{add.done && <p className="ok">{add.done}</p>}
{loading ? (
<p className="muted">Loading…</p>
) : loadError ? null : rows.length === 0 ? (
<p className="muted">Nobody is on the list.</p>
) : (
<ul className="studio-access-list">
{rows.map((row) => (
<li key={row.accountId} className="studio-access-row">
<span>
<strong>
{row.username ? `@${row.username}` : `Account ${row.accountId}`}
</strong>
{row.displayName && row.displayName !== row.username ? (
<span className="muted"> · {row.displayName}</span>
) : null}
</span>
<button
type="button"
disabled={removing === row.accountId}
onClick={() => remove(row)}
>
Remove
</button>
</li>
))}
</ul>
)}
{removeError && <p className="error">{removeError}</p>}
</section>
)}
</main>
)
}
+20
View File
@@ -1226,6 +1226,26 @@ button[type='submit']:disabled {
color: var(--ok);
}
/* The studio whitelist: a name on the left, Remove on the right. */
.studio-access-list {
list-style: none;
margin: 16px 0 0;
padding: 0;
}
.studio-access-row {
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
padding: 10px 0;
border-top: 1px solid var(--line);
}
.studio-access-row button {
flex: none;
}
/* ---- Stats ---------------------------------------------------------------- */
/*
+141
View File
@@ -0,0 +1,141 @@
import {
getAccount,
getAccountByUsername,
listStudioAccounts,
updateAccount,
writeAuditLog,
} from '@repo/domain'
import { logger } from '@repo/hono-helpers'
import { validateAndGetAccountId } from '@repo/jwt'
import type { Context } from 'hono'
import type { Account } from '@repo/domain'
import type { App } from './context'
/**
* Studio upload access, as the website manages it.
*
* RecFlare Studio opens `/settings/recroomstudio` from the dialog that says the
* account cannot upload. That page reads {@link studioAccessStatusHandler} for
* the signed-in player. Staff add and remove people through the routes under
* `/api/staff/studio-access`, behind the same `requireStaff` gate as moderation.
* The grant is the account's `hasStudio` flag; `auth` stamps `betastudio` from it
* on the next Studio sign-in or refresh. Who granted what is on `audit_log`.
*/
/** The signed-in player's own flag, and nothing else. 401 with no session. */
export async function studioAccessStatusHandler(c: Context<App>) {
const accountId = await validateAndGetAccountId(c.req.raw, await c.env.JWT_SECRET.get())
if (accountId === null) return c.json({ error: 'Unauthorized' }, 401)
const account = await getAccount(c.env.DB, accountId)
return c.json({ granted: account?.hasStudio === true })
}
/** Every account with the flag set. */
export async function listStudioAccessHandler(c: Context<App>) {
const accounts = await listStudioAccounts(c.env.DB)
return c.json({
accounts: accounts.map((account) => ({
accountId: account.accountId,
username: account.username,
displayName: account.displayName,
})),
})
}
/**
* Add a player, by username or account id. Unknown players are a 404 rather than
* a flag on an account that doesn't exist. A player who already has it keeps it,
* and the reply says so.
*/
export async function grantStudioAccessHandler(c: Context<App>) {
const body = await c.req.json().catch(() => null)
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 username =
typeof posted.username === 'string' ? posted.username.trim().replace(/^@/, '') : ''
const accountId = postedAccountId(posted.accountId)
let account: Account | null = null
if (username !== '') {
account = await getAccountByUsername(c.env.DB, username)
if (!account) return c.json({ error: `No player is called @${username}` }, 404)
if (accountId !== null && accountId !== account.accountId) {
return c.json({ error: 'That username and account id are different players' }, 400)
}
} else if (accountId !== null) {
account = await getAccount(c.env.DB, accountId)
if (!account) return c.json({ error: 'No player has that account id' }, 404)
} else {
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', {
playerId: account.accountId,
username: account.username,
alreadyGranted,
})
return c.json({
accountId: account.accountId,
username: account.username,
granted: true,
alreadyGranted,
})
}
/**
* Take a player off the list. A player who did not have it answers 200 with
* `removed: false`, so a double click is not an error. An id that names no
* account is a 404.
*/
export async function revokeStudioAccessHandler(c: Context<App>) {
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', {
playerId: accountId,
username: account.username,
removed,
})
return c.json({ accountId, granted: false, removed })
}
/** 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
if (typeof value === 'string' && /^\d+$/.test(value)) {
const parsed = Number.parseInt(value, 10)
return parsed > 0 ? parsed : null
}
return null
}
/**
* Record a change on `audit_log`. Written after the change has committed and
* never throws: the flag has already changed, and a failed insert must not tell
* the moderator it didn't.
*/
async function recordAudit(
c: Context<App>,
action: string,
data: Record<string, unknown>
): Promise<void> {
try {
await writeAuditLog(c.env.DB, { playerId: c.get('staffId'), action, data })
} catch (err) {
logger.error('could not write an audit log row', {
action,
moderatorId: c.get('staffId'),
error: err instanceof Error ? err.message : String(err),
})
}
}
+111
View File
@@ -821,6 +821,14 @@ async function staffGet(path: string, accountId: number, roles: string[] = ['mod
})
}
/** DELETE a staff endpoint as a moderator (or whatever `roles` names). */
async function staffDelete(path: string, accountId: number, roles: string[] = ['moderator']) {
return SELF.fetch(`https://example.com${path}`, {
method: 'DELETE',
headers: { authorization: `Bearer ${await tokenFor(accountId, roles)}` },
})
}
/** POST a JSON body to a staff endpoint as a moderator (or whatever `roles` names). */
async function staffPost(
path: string,
@@ -854,6 +862,7 @@ it('refuses every staff endpoint without a token, and without a staff role', asy
'/api/staff/bans',
'/api/staff/players/1',
'/api/staff/players/1/linked',
'/api/staff/studio-access',
]
const writes = [
'/api/staff/players/1/gift-tokens',
@@ -861,6 +870,7 @@ it('refuses every staff endpoint without a token, and without a staff role', asy
'/api/staff/players/1/gift-xp',
'/api/staff/players/1/username-changes',
'/api/staff/players/1/clear-password',
'/api/staff/studio-access',
'/api/staff/players/1/grant-plus',
]
writes.push(
@@ -905,6 +915,16 @@ it('refuses every staff endpoint without a token, and without a staff role', asy
body: JSON.stringify({ banned: true, days: 1 }),
})
expect(player.status).toBe(403)
const remove = await SELF.fetch('https://example.com/api/staff/studio-access/1', {
method: 'DELETE',
})
expect(remove.status).toBe(401)
const playerRemove = await SELF.fetch('https://example.com/api/staff/studio-access/1', {
method: 'DELETE',
headers: { authorization: `Bearer ${await tokenFor(8101, ['gameClient'])}` },
})
expect(playerRemove.status).toBe(403)
})
// The reporter is the CALLER, never a body field — that is the record of who raised a
@@ -2782,3 +2802,94 @@ it('decodes a bot member read: roles, gone, halt and error', async () => {
})
).resolves.toEqual({ kind: 'error', status: null })
})
// ---- Studio upload access ---------------------------------------------------
//
// RecFlare Studio treats the JWT role `betastudio` as permission to upload. Staff
// set the account's `hasStudio` flag here; auth stamps it onto the next login and
// the next refresh. A signed-in player can ask about themselves. They cannot see or
// edit the list.
it('tells a signed-in player whether they can upload, and nobody else', async () => {
expect((await SELF.fetch('https://example.com/api/studio-access')).status).toBe(401)
const res = await SELF.fetch('https://example.com/api/studio-access', {
headers: { authorization: `Bearer ${await tokenFor(8601, ['gameClient'])}` },
})
expect(res.status).toBe(200)
expect(await res.json()).toEqual({ granted: false })
})
it('lets staff add, list, and remove studio upload access', async () => {
await updateAccount(env.DB, 8601, { username: 'StudioFan' })
await updateAccount(env.DB, 8602, { username: 'OtherFan' })
await updateAccount(env.DB, 8110, { username: 'Moderator' })
expect((await staffPost('/api/staff/studio-access', 8110, {})).status).toBe(400)
expect((await staffPost('/api/staff/studio-access', 8110, { username: 'Nobody' })).status).toBe(
404
)
expect(
(
await staffPost('/api/staff/studio-access', 8110, {
username: 'StudioFan',
accountId: 8602,
})
).status
).toBe(400)
const added = await staffPost('/api/staff/studio-access', 8110, { username: '@StudioFan' })
expect(added.status).toBe(200)
expect(await added.json()).toEqual({
accountId: 8601,
username: 'StudioFan',
granted: true,
alreadyGranted: false,
})
const again = await staffPost('/api/staff/studio-access', 8110, { accountId: 8601 })
expect(again.status).toBe(200)
expect(await again.json()).toMatchObject({ accountId: 8601, alreadyGranted: true })
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 })
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) },
])
expect((await getAccount(env.DB, 8601))?.hasStudio).toBe(true)
expect((await staffDelete('/api/staff/studio-access/nope', 8110)).status).toBe(400)
expect((await staffDelete('/api/staff/studio-access/8609', 8110)).status).toBe(404)
const removed = await staffDelete('/api/staff/studio-access/8601', 8110)
expect(removed.status).toBe(200)
expect(await removed.json()).toEqual({ accountId: 8601, granted: false, removed: true })
expect((await getAccount(env.DB, 8601))?.hasStudio).toBe(false)
const repeat = await staffDelete('/api/staff/studio-access/8601', 8110)
expect(await repeat.json()).toEqual({ accountId: 8601, granted: false, removed: false })
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 auditRows('grant_studio_access', 8601)).toEqual([
{
actor: 8110,
data: { playerId: 8601, username: 'StudioFan', alreadyGranted: false },
},
{
actor: 8110,
data: { playerId: 8601, username: 'StudioFan', alreadyGranted: true },
},
])
expect(await auditRows('revoke_studio_access', 8601)).toEqual([
{ actor: 8110, data: { playerId: 8601, username: 'StudioFan', removed: true } },
{ actor: 8110, data: { playerId: 8601, username: 'StudioFan', removed: false } },
])
})
+16
View File
@@ -60,6 +60,12 @@ import {
searchReportsHandler,
topReportedHandler,
} from './staff'
import {
grantStudioAccessHandler,
listStudioAccessHandler,
revokeStudioAccessHandler,
studioAccessStatusHandler,
} from './studio-access'
import { passwordSignupOpen, turnstileKeys, verifyTurnstile } from './turnstile'
import {
accountsBase,
@@ -525,6 +531,11 @@ const app = new Hono<App>()
return c.json({ hasPlus: true, discordUsername: membership.username, tokensAwarded })
})
// Whether the signed-in account has studio upload access (`hasStudio`). The page
// Studio opens (`/settings/recroomstudio`) asks this; it does not list anyone
// else. Staff manage the flag on the routes below.
.get('/api/studio-access', studioAccessStatusHandler)
// ---- Staff moderation panel ---------------------------------------------
// The endpoints behind `/moderation` in the SPA. They live here rather than on `api`
// (which owns the `report` table) because they are a recflare addition with no
@@ -565,6 +576,11 @@ const app = new Hono<App>()
.post('/api/staff/players/:id/grant-plus', requireDeveloper, grantPlusHandler)
.post('/api/staff/players/:id/username-changes', addUsernameChangeHandler)
.post('/api/staff/players/:id/clear-password', clearPasswordHandler)
// RecFlare Studio upload access. The same staff gate as moderation: a moderator
// decides who can upload, and the role lands on the player's next token.
.get('/api/staff/studio-access', listStudioAccessHandler)
.post('/api/staff/studio-access', grantStudioAccessHandler)
.delete('/api/staff/studio-access/:id', revokeStudioAccessHandler)
// ---- Privacy policy -----------------------------------------------------
// Server-rendered rather than a SPA route so the page has real text without