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)