The panel is a React page (web/nui/) shown inside the game or in a browser. Everything it shows or changes goes through the shared server operation validator. The game transport below uses client events; the browser transport uses Discord sessions and authenticated HTTP.
panel (React, in an iframe)
│ postMessage web/nui/src/lib/rpc.ts
▼
NUI page (nui/index.html + panel.js) forwards to the game client
│ NUI callback `panelRequest`
▼
client/panel.lua sanity-checks, forwards
│ net event `simpleac:panel:req`
▼
server/panel/router.ts rate limit → permission → validate → handler
│
▼
server/panel/handlers.ts calls the same services the HTTP API uses
│
▼ net event `simpleac:panel:res` (latent when large)
back the same way
Every operation is listed once in shared/contracts/panel.ts, with the permission it needs and a Zod schema for its input. The router refuses an operation before running anything if:
- the operation name is not in the table,
- the request is not an integer ID plus a JSON body under 32 KiB,
- the player is over the rate limit (30 burst, 15 per second),
- the player's permissions, recomputed for this request, do not include the operation's permission,
- the input fails its schema.
Permissions come from server/panel/access.ts: the simpleac.admin ACE grants everything, simpleac.<permission> ACEs grant one each, and sac_panel_members rows grant by identifier. Browser sessions resolve only their verified Discord identifier through principal ACEs and member grants. Any permission implies players.view. Nothing is cached between requests.
Errors are mapped to a code and a safe message. Anything unexpected is logged on the server and the client only sees internal_error.
- Add it to
panelOpsinshared/contracts/panel.tswith itspermissionandinputschema, and its result type toPanelOpOutput. - Implement it in
createPanelHandlers(server/panel/handlers.ts). TypeScript will not compile until every operation has a handler of the right type. - Call it from the UI with
rpc('your.op', input)and add fixture handling inweb/nui/src/lib/dev-backend.tsfor the development preview. - Add the text to
locales/en.json. The locale test fails if the UI uses a key that is missing.
Handlers reuse the services behind the HTTP API (server/services/*), so a rule enforced there is enforced for the panel too. Those services take an Actor; the game passes type: 'ingame_panel', and the browser passes type: 'staff' with web_panel origin, so the ledger records who acted and from where.
simpleac:panel:open resolves the staff member, writes a panel.opened ledger entry and replies with simpleac:panel:granted (their permissions, bypass state, server info). The client then takes NUI focus and sends the language strings (lib.getLocales()) with the session. Closing releases focus through the panelClosed callback.
Code: server/panel/watch.ts (sessions), server/relay/* (relay), nui/watch.js (sending side), web/nui/src/screens/LiveWatch.tsx (viewing side).
staff panel ──watch.start──▶ server ──begin──▶ watched player's game
│ draws the game view into a canvas,
│ captureStream → RTCPeerConnection
staff panel ◀──────────── offer (relayed by server) ┘
staff panel ──watch.answer──▶ server ──answer──▶ watched player's game
◀════════ video over WebRTC through the relay ════════
- The server holds the state machine: one session per staff member, the watched player must send the offer, only the owning staff member may answer or stop, the offer must arrive within 20 s, the answer within 30 s, and no session lasts over 30 minutes. A player leaving ends their sessions.
- The relay is a pure-JS TURN server (
node-turn) in a worker thread. Each session gets credentials whose username carries an expiry; they are removed when it ends. Both sides useiceTransportPolicy: 'relay', so neither address is exposed. - Quality: the stream is the player's resolution up to 1080p at 30 fps. Both ends add
x-google-start-bitrateto the SDP (web/nui/src/lib/sdp.ts,nui/watch.js) because the game's embedded browser otherwise starts at a few hundred kilobits and drops the picture to 480×270 while it ramps up.
bun run test: the router and permission rules (tests/unit/panel.test.ts), the live-watch sessions (watch.test.ts), the locale file (locale.test.ts).simpleac_panel_call <server id> <operation> [json](server console only) runs an operation exactly as that player would, with the same permission checks, and prints the reply.simpleac_panel_call 1 openshows whether the panel would open for them.bun run dev:webruns the panel in a browser against fixtures, with no game.
- Protection settings and profiles are read-only in the panel. Edit
configs/server/profile.lua; the database stores profile versions for the audit trail. - A screenshot shows the game's own rendering. Text drawn by other NUI pages (a chat box, a NUI menu) is not in it.