[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
+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 ---------------------------------------------------------------- */
/*