1
0
This repository has been archived on 2026-08-25. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
tera/server/src/routes/devices.ts
T
karti db074e9cf7 feat: tone-mapped render rig, studio devices, LA fidelity pass, UI overhaul
The build the studios needed, across eight workstreams and one strict file
partition.

**The render rig was the quality ceiling.** The renderer ran three's
NoToneMapping default while atmosphere drove the sun to 2.35 and assets set
emissives to 3.2, so every value above 1.0 hard-clipped to flat white — which is
why walls blew out and every fitting looked like a white rectangle. ACES filmic
tone mapping and an explicit output colour space land in `stage.ts`, and the
atmosphere intensity table and palette headroom are re-tuned against the new
curve rather than left tuned for the clipping we removed.

`engine/environmentRig.ts` builds a PMREM environment at runtime, procedurally,
so nothing binary is committed. There was no environment map anywhere before, so
every `metalness > 0` role had nothing to reflect and rendered dull grey — a
defect the code already documented against itself in `office/optimus.ts`, where a
whole material role was abandoned over it, and worked around in `modelX.ts` with
a fake emissive that this change deletes. Atmosphere remains the sole light
owner; the rig derives from the `LightingState` it already produced.

**Studio hardware exists.** There was no device concept anywhere in the product:
no type, no route, no state. `devices/types.ts` fixes a declaration/state/
capability/command contract that a smart light, a thermostat, a door sensor and a
charger all fit without a schema change, and both studios now carry a desk mic
and a computer speaker with deterministic simulated behaviour behind an adapter
seam a real API can occupy later. Reads are the demo and are open; commands are a
signed-in action and are kept off the read body entirely, because a shared cache
replaying a GET that turned a microphone on is exactly what the fail-closed
cache default exists to prevent.

**The ADS-B licence hole is closed.** `TERA_ADSB_ENDPOINT` accepted any URL, the
response was served publicly cacheable, and the attribution hardcoded adsb.lol
regardless of where the endpoint pointed — one env var away from republishing
non-redistributable data under an open-terms credit. The host is now allowlisted,
the credit is derived from the host actually configured, public cacheability is
conditional on redistributability, and a refused endpoint demotes to simulated
flights and says so in `degraded[]`. The gate is on the source, not the feature:
live aircraft and their detail cards stay open to anonymous visitors.

**The LA studio was never the smaller pack** — 16 rooms and 248 props against
SF's 4 and 28. Its deficit was fidelity per square metre: 98 of those props were
ceiling troffers, it bound no props to seats, placed none of the habitat kit, and
12 of its 16 rooms had no viewpoint. Density comes from new asset kinds rather
than more instances, because `furnish.ts` draws once per kind and folds colour
into the batch key, so repeat instances add nothing the eye can read.

**The interface stops being forty imperative mutations.** Every visibility
decision moves into a pure, tested `ui/chromeState.ts` and one applier, so the
chrome has coverage for the first time. Deleted: ~100 lines of CSS and two
bindings targeting elements that no longer exist, and a `body:has()` rule that
shifted the desktop layout by 160px for touch controls hidden there. Fixed: the
office picker tabs that drew their label and their badge on top of each other.
Added: a first-run flow, because the product is two verbs and neither was ever
stated on screen. Mobile is designed on its own terms instead of being the
desktop with things hidden — the plan view comes back, and the keyboard-only
shortcuts button is replaced by touch controls.

`arena/studioOps.ts` frames the whole thing as the multi-variable environment it
is, wrapping the same simulators the renderer drives rather than a headless copy.

Also removed `input/vehicle.ts`, which nothing but its own test imported.

Tests 385 -> 961, all passing. Typecheck, build, performance budgets across six
matrix cells, no-binaries, provenance, dependency licences, zero-config boot and
arena source hashes all green.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-21 19:44:24 -07:00

176 lines
7.9 KiB
TypeScript

/**
* `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<Office | null> {
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<DeviceCommandBody>;
const command: unknown = body.command;
if (command === null || typeof command !== "object" || Array.isArray(command)) return null;
const c = command as Record<string, unknown>;
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 }) };
}