/** * `GET /api/v1/offices/:id/devices` and * `POST /api/v1/offices/:id/devices/command`. * * The read is a sibling of `/presence` and behaves exactly like it: viewer * first, then the office, 401 for anonymous, 404 for anything this caller may * not see, and never a shared cache. The reasoning is written out at length in * `routes/presence.ts` and is not repeated here; what follows is what is * *different* about devices, which is the write. * * ### Two routes, and that is the security property * * A command never rides in the read body and a read never applies one. This is * the first write surface in this product that changes something another viewer * can see, and folding it into the GET would mean a body that mutates a * microphone is a body a shared cache is entitled to keep and replay. That is * precisely the outcome the fail-closed `private, no-store` default in * `cache.ts` exists to prevent, and the way to not have that problem is to not * have that shape: reads are GETs and are never cached, writes are POSTs with a * body of their own. * * Neither route ever calls `publicCache`. Stated rather than merely omitted, * because the absence of a call is not self-evidently a decision — the same * note `routes/presence.ts` leaves for the same reason. * * ### What a command is checked against * * The pack, resolved by this process. `devices/store.ts` builds a `Plan` and * accepts a declaration only if the prop it is anchored to exists, is on the * level it claims, and *is* the hardware the declaration names. So a command * carries an id, and the id either names one of those or it does not — nothing * in the request describes the device, which means nothing in the request can * describe it wrongly. `officeHasMediaBinding()` makes the identical move for a * screen share. * * ### Reading is the demo; writing is the account * * An anonymous visitor gets 401 here and gets a **locally simulated studio** * from `src/devices/adapter.ts` instead — alive, labelled, and honest about * what it is. That is the anon-first posture applied to a route that genuinely * cannot be opened: the readings describe a room somebody is standing in, and * a command changes it for everybody else in there. What an account buys is the * shared room, not the demonstration. */ import type { FastifyInstance } from "fastify"; import { bundledOffice } from "../media/index.ts"; import { isDeviceCommandOp, type DeviceCommand } from "../../../src/devices/types.ts"; import type { Office } from "../../../src/interiors/types.ts"; import type { DeviceCommandBody, DeviceCommandResultBody, ErrorBody, } from "../../../src/server/wire.ts"; import type { Services } from "../services.ts"; const UNAUTHORIZED: ErrorBody = { error: "unauthorized", message: "Device state is for signed-in members.", }; const NOT_FOUND: ErrorBody = { error: "not_found", message: "No such office." }; /** * The largest command body this route will read, in bytes. * * A `DeviceCommand` is three short fields and the largest legitimate one is * well under two hundred bytes. The cap is not about those — it is about the * body nobody meant to send, and it is set here rather than trusted to a global * because this is the only route on the box that accepts one. */ const MAX_COMMAND_BYTES = 2048; export function registerDevices(app: FastifyInstance, services: Services): void { app.get<{ Params: { id: string } }>("/api/v1/offices/:id/devices", async (req, reply) => { // Viewer first, before the id is looked at. The ordering is the property // that stops this being an enumeration oracle — an anonymous caller gets // the same 401 for a real office, a private one and an invented one — and // `routes/presence.ts` explains why doing it the other way round is the bug. const viewer = await services.auth.resolve(req); if (!viewer.authenticated) { return reply.code(401).header("www-authenticate", "Bearer").send(UNAUTHORIZED); } const office = await officeFor(services, req.params.id); if (office === null) return reply.code(404).send(NOT_FOUND); // No `publicCache`, ever. See the header. return services.devices.current(office); }); app.post<{ Params: { id: string }; Body: unknown }>( "/api/v1/offices/:id/devices/command", { bodyLimit: MAX_COMMAND_BYTES }, async (req, reply) => { const viewer = await services.auth.resolve(req); if (!viewer.authenticated) { return reply.code(401).header("www-authenticate", "Bearer").send(UNAUTHORIZED); } const office = await officeFor(services, req.params.id); if (office === null) return reply.code(404).send(NOT_FOUND); const command = readCommand(req.body); if (command === null) { const error: ErrorBody = { error: "bad_request", message: "Not a device command." }; return reply.code(400).send(error); } const outcome = services.devices.command(office, command); if (!outcome.ok) { // One status and one message for every way a command can be wrong. A // caller told *which* of its guesses missed is a caller being helped to // guess again, and the honest audience for the distinction is the log. req.log.info({ officeId: office.id, reason: outcome.reason }, "device command refused"); const error: ErrorBody = { error: "bad_request", message: "That command was refused." }; return reply.code(400).send(error); } const body: DeviceCommandResultBody = { officeId: office.id, device: outcome.device, observedAt: outcome.device.observedAt, }; return body; }, ); } /** * The office this request is about, or `null` if there is not one this viewer * may see. * * Served packs win over bundled ones, so an operator who has put their own * `lumbridge-hq.json` in `TERA_OFFICES_DIR` gets theirs. A bundled pack is the * fallback and not a leak: it is compiled into the browser bundle that made the * request, so its authored device list is already in the caller's hands — see * `bundledOffice`. Without it, every deployment that has not configured an * offices directory would answer 404 for the very studios it is rendering. * * Visibility is honoured the way `routes/offices.ts` honours it: a private pack * is 404 to anyone who is not signed in. By the time this is called the viewer * already is, so the check that remains is the one for a document that does not * exist at all. */ async function officeFor(services: Services, id: string): Promise { const doc = await services.offices.get(id); if (doc !== null) return doc.floor; return bundledOffice(id); } /** * One command, read out of an untrusted body. * * Shape only. Whether the device exists, whether it accepts this op and whether * the value is in range are all decided against the resolved pack in * `devices/index.ts`, which is the only place that can decide them — this * function's whole job is to make sure there is something of the right shape to * hand it. */ function readCommand(raw: unknown): DeviceCommand | null { if (raw === null || typeof raw !== "object" || Array.isArray(raw)) return null; const body = raw as Partial; const command: unknown = body.command; if (command === null || typeof command !== "object" || Array.isArray(command)) return null; const c = command as Record; if (typeof c.deviceId !== "string" || c.deviceId === "") return null; if (!isDeviceCommandOp(c.op)) return null; // A value of the wrong type is dropped rather than passed through, which // makes it a *missing* value — and `normalizeDeviceCommand` refuses a command // whose op needs one and has none. The refusal is therefore made in the one // place that knows which ops need what, rather than half here. const value = typeof c.value === "boolean" || typeof c.value === "number" ? c.value : undefined; return { deviceId: c.deviceId, op: c.op, ...(value === undefined ? {} : { value }) }; }