From 585cf6863766a9899cfed4e9a361412fc5ca54b4 Mon Sep 17 00:00:00 2001 From: Devin Zuczek Date: Sat, 10 Oct 2026 16:27:35 -0400 Subject: [PATCH] [www] dev/mod sandbox support --- apps/www/src/client/App.tsx | 65 +++++++++++++++++++ apps/www/src/staff.ts | 42 +++++++++++++ apps/www/src/test/integration/api.test.ts | 76 +++++++++++++++++++++++ apps/www/src/www.app.ts | 7 +++ 4 files changed, 190 insertions(+) diff --git a/apps/www/src/client/App.tsx b/apps/www/src/client/App.tsx index 4360672..76039bc 100644 --- a/apps/www/src/client/App.tsx +++ b/apps/www/src/client/App.tsx @@ -3804,6 +3804,8 @@ function Dashboard({ ? [ { id: 'maintenance', label: 'Server maintenance', render: () => }, { id: 'coach', label: 'Coach message', render: () => }, + // The staff member's OWN voice-server pool, not a thing done to anyone else. + { id: 'sandbox', label: 'Sandbox', render: () => }, ] : []), // Narrower than the two above: the drop mints tokens, and www's route is developer-only. @@ -4120,6 +4122,69 @@ function MaintenanceForm() { ) } +/** + * Staff only: opt the signed-in account in to or out of the SANDBOX — the separate Tachyon + * (voice) server pool `match` hands a sandbox account on every connection-info read. It is + * the account's `isSandbox` flag, and www's route only ever sets the caller's own, so this + * lives on the account page rather than the staff card on a profile. The current state is + * read on mount; one button flips it. `match` reads the flag per room join, so the switch + * lands the next time they join a room, not in the instance they are standing in. + */ +function SandboxForm() { + // undefined = not fetched yet. + const [sandbox, setSandbox] = useState(undefined) + const [loadError, setLoadError] = useState('') + const { pending, error, done, run } = useAction() + + useEffect(() => { + void call<{ sandbox: boolean }>('/api/staff/sandbox', { authed: true }) + .then((res) => setSandbox(res.sandbox)) + .catch((e) => setLoadError(e instanceof Error ? e.message : String(e))) + }, []) + + return ( +
+

Sandbox

+

+ A sandbox account is put on the sandbox voice servers instead of the live ones, for testing + a Tachyon build before players get it. This only changes your own account. It takes effect + the next time you join a room. +

+ {loadError &&

{loadError}

} + {sandbox === undefined && !loadError ? ( +

Loading…

+ ) : sandbox !== undefined ? ( + <> +

+ You are currently {sandbox ? in : out of} the sandbox. +

+ {error &&

{error}

} + {done &&

{done}

} + + + ) : null} +
+ ) +} + /** * The most a token drop carries per player — a mirror of www's `MAX_TOKEN_DROP`, which is * the one that decides. Not imported: the staff module pulls hono and the JWT code into the diff --git a/apps/www/src/staff.ts b/apps/www/src/staff.ts index 37a2660..6382712 100644 --- a/apps/www/src/staff.ts +++ b/apps/www/src/staff.ts @@ -1556,3 +1556,45 @@ export async function grantPlusHandler(c: Context) { logger.info('staff granted plus', { moderatorId: staffId(c), playerId, hadPlus }) return c.json({ playerId, hasPlus: true, hadPlus }) } + +/** + * Whether the acting staff member's OWN account is a sandbox account — the `isSandbox` + * flag `match` reads on every `GET /player/connection-info` to pick the sandbox Tachyon + * pool over the live one. Self-only: the toggle on the account page is how a moderator or + * developer moves THEMSELVES onto the test servers, not how anyone is moved by someone else + * (an operator does that with `runx admin`, if at all). + */ +export async function sandboxStatusHandler(c: Context) { + const account = await getAccount(c.env.DB, staffId(c)) + return c.json({ sandbox: account?.isSandbox === true }) +} + +/** + * Opt the acting staff member in to or out of the sandbox pool: `{ sandbox: boolean }` sets + * `isSandbox` on their own account. Idempotent — setting what's already set succeeds, + * changes nothing and still answers the current state. Logged on `audit_log` like every + * other account change staff make from the website, with `changed` saying whether anything + * moved. + * + * `match` reads the account per connection-info request, so the switch is honoured the next + * time the player joins a room instance; the instance they're standing in keeps its server. + */ +export async function setSandboxHandler(c: Context) { + const body = (await c.req.json().catch(() => null)) as { sandbox?: unknown } | null + if (body === null || typeof body !== 'object' || typeof body.sandbox !== 'boolean') { + return c.json({ error: 'Expected a JSON body with a boolean `sandbox`' }, 400) + } + const playerId = staffId(c) + const account = await getAccount(c.env.DB, playerId) + const was = account?.isSandbox === true + const changed = was !== body.sandbox + if (changed) await updateAccount(c.env.DB, playerId, { isSandbox: body.sandbox }) + + await recordPlayerAudit(c, 'set_sandbox', { playerId, sandbox: body.sandbox, changed }) + logger.info('staff set their sandbox flag', { + moderatorId: playerId, + sandbox: body.sandbox, + changed, + }) + return c.json({ sandbox: body.sandbox, changed }) +} diff --git a/apps/www/src/test/integration/api.test.ts b/apps/www/src/test/integration/api.test.ts index a2cd331..cf7a1e1 100644 --- a/apps/www/src/test/integration/api.test.ts +++ b/apps/www/src/test/integration/api.test.ts @@ -863,6 +863,7 @@ it('refuses every staff endpoint without a token, and without a staff role', asy '/api/staff/players/1', '/api/staff/players/1/linked', '/api/staff/studio-access', + '/api/staff/sandbox', ] const writes = [ '/api/staff/players/1/gift-tokens', @@ -934,6 +935,22 @@ it('refuses every staff endpoint without a token, and without a staff role', asy headers: { authorization: `Bearer ${await tokenFor(8101, ['gameClient'])}` }, }) expect(playerRemove.status).toBe(403) + // The sandbox toggle is a PUT, so the loop over POSTs above doesn't cover it. + const sandboxUnauthed = await SELF.fetch('https://example.com/api/staff/sandbox', { + method: 'PUT', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ sandbox: true }), + }) + expect(sandboxUnauthed.status).toBe(401) + const sandboxPlayer = await SELF.fetch('https://example.com/api/staff/sandbox', { + method: 'PUT', + headers: { + authorization: `Bearer ${await tokenFor(8101, ['gameClient'])}`, + 'content-type': 'application/json', + }, + body: JSON.stringify({ sandbox: true }), + }) + expect(sandboxPlayer.status).toBe(403) }) // The reporter is the CALLER, never a body field — that is the record of who raised a @@ -2965,3 +2982,62 @@ it('lets staff add, list, and remove studio upload access', async () => { { actor: 8110, data: { playerId: 8601, username: 'StudioFan', removed: false } }, ]) }) + +// ---- Sandbox opt-in --------------------------------------------------------- +// `match` picks the Tachyon pool off the account's `isSandbox` flag. Staff flip their OWN +// flag from the account page; the route never takes a player id, so nobody can be moved by +// someone else here. + +it('lets staff opt themselves in and out of the sandbox pool, and only themselves', async () => { + await updateAccount(env.DB, 8120, { username: 'SandboxMod' }) + + const staffPut = (accountId: number, body: unknown, roles = ['moderator']) => + tokenFor(accountId, roles).then((token) => + SELF.fetch('https://example.com/api/staff/sandbox', { + method: 'PUT', + headers: { authorization: `Bearer ${token}`, 'content-type': 'application/json' }, + body: JSON.stringify(body), + }) + ) + + // Off until opted in, for a moderator and a developer alike. + let res = await staffGet('/api/staff/sandbox', 8120) + expect(res.status).toBe(200) + expect(await res.json()).toEqual({ sandbox: false }) + expect((await staffGet('/api/staff/sandbox', 8120, ['developer'])).status).toBe(200) + + // Not a boolean, not a body. + expect((await staffPut(8120, { sandbox: 'yes' })).status).toBe(400) + expect((await staffPut(8120, {})).status).toBe(400) + + res = await staffPut(8120, { sandbox: true }) + expect(res.status).toBe(200) + expect(await res.json()).toEqual({ sandbox: true, changed: true }) + expect((await getAccount(env.DB, 8120))?.isSandbox).toBe(true) + expect(await (await staffGet('/api/staff/sandbox', 8120)).json()).toEqual({ sandbox: true }) + + // Idempotent: opting in again changes nothing and says so. + res = await staffPut(8120, { sandbox: true }) + expect(await res.json()).toEqual({ sandbox: true, changed: false }) + + // A developer's token works the same, and the flag is theirs, not the moderator's. + res = await staffPut(8121, { sandbox: true }, ['developer']) + expect(await res.json()).toEqual({ sandbox: true, changed: true }) + expect((await getAccount(env.DB, 8121))?.isSandbox).toBe(true) + + res = await staffPut(8120, { sandbox: false }) + expect(await res.json()).toEqual({ sandbox: false, changed: true }) + expect((await getAccount(env.DB, 8120))?.isSandbox).toBe(false) + expect((await getAccount(env.DB, 8121))?.isSandbox).toBe(true) + + // Each flip is on the audit log against the staff member who made it. + const rows = await env.DB.prepare( + "SELECT player_id, data FROM audit_log WHERE action = 'set_sandbox' ORDER BY audit_log_id" + ).all<{ player_id: number; data: string }>() + expect(rows.results.map((r) => [r.player_id, JSON.parse(r.data).sandbox])).toEqual([ + [8120, true], + [8120, true], + [8121, true], + [8120, false], + ]) +}) diff --git a/apps/www/src/www.app.ts b/apps/www/src/www.app.ts index 6283297..1f183a1 100644 --- a/apps/www/src/www.app.ts +++ b/apps/www/src/www.app.ts @@ -58,7 +58,9 @@ import { requireDeveloper, requireStaff, runDiscordRoleGiftHandler, + sandboxStatusHandler, searchReportsHandler, + setSandboxHandler, syncDiscordRolesHandler, topReportedHandler, } from './staff' @@ -583,6 +585,11 @@ const app = new Hono() .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) + // The signed-in staff member's OWN sandbox flag: read it, and opt in or out. Self-only, + // so plain staff (not developer-only) — it moves nobody but the caller, and `match` + // honours it on their next room join. + .get('/api/staff/sandbox', sandboxStatusHandler) + .put('/api/staff/sandbox', setSandboxHandler) // 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)