mirror of
https://github.com/djdevin/recflare.git
synced 2026-10-25 23:18:51 -07:00
[www] dev/mod sandbox support
This commit is contained in:
@@ -3804,6 +3804,8 @@ function Dashboard({
|
|||||||
? [
|
? [
|
||||||
{ id: 'maintenance', label: 'Server maintenance', render: () => <MaintenanceForm /> },
|
{ id: 'maintenance', label: 'Server maintenance', render: () => <MaintenanceForm /> },
|
||||||
{ id: 'coach', label: 'Coach message', render: () => <CoachMessageForm /> },
|
{ id: 'coach', label: 'Coach message', render: () => <CoachMessageForm /> },
|
||||||
|
// The staff member's OWN voice-server pool, not a thing done to anyone else.
|
||||||
|
{ id: 'sandbox', label: 'Sandbox', render: () => <SandboxForm /> },
|
||||||
]
|
]
|
||||||
: []),
|
: []),
|
||||||
// Narrower than the two above: the drop mints tokens, and www's route is developer-only.
|
// 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<boolean | undefined>(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 (
|
||||||
|
<section className="card">
|
||||||
|
<h2>Sandbox</h2>
|
||||||
|
<p className="muted">
|
||||||
|
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.
|
||||||
|
</p>
|
||||||
|
{loadError && <p className="error">{loadError}</p>}
|
||||||
|
{sandbox === undefined && !loadError ? (
|
||||||
|
<p className="muted">Loading…</p>
|
||||||
|
) : sandbox !== undefined ? (
|
||||||
|
<>
|
||||||
|
<p>
|
||||||
|
You are currently {sandbox ? <strong>in</strong> : <strong>out of</strong>} the sandbox.
|
||||||
|
</p>
|
||||||
|
{error && <p className="error">{error}</p>}
|
||||||
|
{done && <p className="ok">{done}</p>}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={pending}
|
||||||
|
onClick={() =>
|
||||||
|
void run(async () => {
|
||||||
|
const next = !sandbox
|
||||||
|
const res = await call<{ sandbox: boolean; changed: boolean }>(
|
||||||
|
'/api/staff/sandbox',
|
||||||
|
{ authed: true, method: 'PUT', json: { sandbox: next } }
|
||||||
|
)
|
||||||
|
setSandbox(res.sandbox)
|
||||||
|
return res.sandbox
|
||||||
|
? 'You are in the sandbox. Join a room to land on the sandbox servers.'
|
||||||
|
: 'You are out of the sandbox. Join a room to land back on the live servers.'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{pending ? 'Saving…' : sandbox ? 'Leave sandbox' : 'Join sandbox'}
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The most a token drop carries per player — a mirror of www's `MAX_TOKEN_DROP`, which is
|
* 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
|
* the one that decides. Not imported: the staff module pulls hono and the JWT code into the
|
||||||
|
|||||||
@@ -1556,3 +1556,45 @@ export async function grantPlusHandler(c: Context<App>) {
|
|||||||
logger.info('staff granted plus', { moderatorId: staffId(c), playerId, hadPlus })
|
logger.info('staff granted plus', { moderatorId: staffId(c), playerId, hadPlus })
|
||||||
return c.json({ playerId, hasPlus: true, 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<App>) {
|
||||||
|
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<App>) {
|
||||||
|
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 })
|
||||||
|
}
|
||||||
|
|||||||
@@ -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',
|
||||||
'/api/staff/players/1/linked',
|
'/api/staff/players/1/linked',
|
||||||
'/api/staff/studio-access',
|
'/api/staff/studio-access',
|
||||||
|
'/api/staff/sandbox',
|
||||||
]
|
]
|
||||||
const writes = [
|
const writes = [
|
||||||
'/api/staff/players/1/gift-tokens',
|
'/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'])}` },
|
headers: { authorization: `Bearer ${await tokenFor(8101, ['gameClient'])}` },
|
||||||
})
|
})
|
||||||
expect(playerRemove.status).toBe(403)
|
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
|
// 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 } },
|
{ 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],
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|||||||
@@ -58,7 +58,9 @@ import {
|
|||||||
requireDeveloper,
|
requireDeveloper,
|
||||||
requireStaff,
|
requireStaff,
|
||||||
runDiscordRoleGiftHandler,
|
runDiscordRoleGiftHandler,
|
||||||
|
sandboxStatusHandler,
|
||||||
searchReportsHandler,
|
searchReportsHandler,
|
||||||
|
setSandboxHandler,
|
||||||
syncDiscordRolesHandler,
|
syncDiscordRolesHandler,
|
||||||
topReportedHandler,
|
topReportedHandler,
|
||||||
} from './staff'
|
} from './staff'
|
||||||
@@ -583,6 +585,11 @@ const app = new Hono<App>()
|
|||||||
.post('/api/staff/players/:id/grant-plus', requireDeveloper, grantPlusHandler)
|
.post('/api/staff/players/:id/grant-plus', requireDeveloper, grantPlusHandler)
|
||||||
.post('/api/staff/players/:id/username-changes', addUsernameChangeHandler)
|
.post('/api/staff/players/:id/username-changes', addUsernameChangeHandler)
|
||||||
.post('/api/staff/players/:id/clear-password', clearPasswordHandler)
|
.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
|
// 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.
|
// decides who can upload, and the role lands on the player's next token.
|
||||||
.get('/api/staff/studio-access', listStudioAccessHandler)
|
.get('/api/staff/studio-access', listStudioAccessHandler)
|
||||||
|
|||||||
Reference in New Issue
Block a user