diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml index f1391cd..c5d07c9 100644 --- a/deploy/docker-compose.yml +++ b/deploy/docker-compose.yml @@ -37,6 +37,7 @@ services: TERA_MARKERS_SOURCE: "${TERA_MARKERS_SOURCE:-}" TERA_MARKERS_FILE: "${TERA_MARKERS_FILE:-}" TERA_OFFICES_DIR: "${TERA_OFFICES_DIR:-}" + TERA_PRESENCE_DIR: "${TERA_PRESENCE_DIR:-}" TERA_AUTH_MODE: "${TERA_AUTH_MODE:-}" TERA_AUTH_ENTRY_URL: "${TERA_AUTH_ENTRY_URL:-}" TERA_AUTH_REVALIDATE_URL: "${TERA_AUTH_REVALIDATE_URL:-}" diff --git a/server/README.md b/server/README.md index 0a9c74f..d833e8b 100644 --- a/server/README.md +++ b/server/README.md @@ -30,6 +30,7 @@ without breaking the typecheck, which is the wrong order to find out. | `GET /api/v1/weather` | `?city=` or `?lat=&lng=`; **400** for a place this box does not serve | `WeatherBody` | public, `TERA_WEATHER_TTL` | | `GET /api/v1/markers` | — | `MarkersBody` | **private; 401 unless signed in** — public empty body when `TERA_MARKERS_SOURCE=none` | | `GET /api/v1/offices/:id` | — | `OfficeDoc` | public offices only | +| `GET /api/v1/offices/:id/presence` | — | `PresenceBody` | **private; 401 unless signed in** — never publicly cached | Every body is declared once, in `src/server/wire.ts` in the **root** package — type-only, so it compiles to nothing and both the browser build and this service @@ -231,6 +232,7 @@ Setting it is a licence claim you are making on the record. | variable | default | what it does | | --- | --- | --- | | `TERA_OFFICES_DIR` | *(empty)* | One `.json` per office. Empty means no offices. | +| `TERA_PRESENCE_DIR` | *(empty)* | One `.json` per roster. Empty means nobody is in. **Keep this directory separate from the offices one** — a pack is publishable and a roster never is. | | `TERA_AUTH_MODE` | `none` | `none`, `sso`, `jwt`. | | `TERA_AUTH_ENTRY_URL` | *(empty)* | Where a browser sends someone to sign in. `sso`. | | `TERA_AUTH_REVALIDATE_URL` | *(empty)* | Server-side token check. `sso`. | diff --git a/server/src/app.ts b/server/src/app.ts index 7f39f8b..9663e2c 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -19,6 +19,7 @@ import { registerFlights } from "./routes/flights.ts"; import { registerHealth } from "./routes/health.ts"; import { registerMarkers } from "./routes/markers.ts"; import { registerOffices } from "./routes/offices.ts"; +import { registerPresence } from "./routes/presence.ts"; import { registerSession } from "./routes/session.ts"; import { registerWeather } from "./routes/weather.ts"; import { createServices } from "./services.ts"; @@ -47,6 +48,7 @@ export function buildApp(config: Config = loadConfig()): FastifyInstance { registerWeather(app, services); registerMarkers(app, services); registerOffices(app, services); + registerPresence(app, services); registerSession(app, services); app.setNotFoundHandler(async (_req, reply) => { diff --git a/server/src/config.ts b/server/src/config.ts index 4471c2f..b76b7e1 100644 --- a/server/src/config.ts +++ b/server/src/config.ts @@ -148,6 +148,12 @@ export interface Config { flights: FlightsConfig; markers: MarkersConfig; offices: { dir: string }; + /** + * Where the rosters are. Separate from `offices.dir` because the two hold + * different kinds of secret — see `presence/store.ts`. Unset is the default + * and means nobody is in any building, which renders correctly. + */ + presence: { dir: string }; auth: AuthConfig; /** One sentence per demotion. Empty on a fully-configured box. */ degraded: string[]; @@ -189,6 +195,7 @@ export function loadConfig(env: Env = process.env): Config { flights, markers, offices: { dir: str(env, "TERA_OFFICES_DIR", "") }, + presence: { dir: str(env, "TERA_PRESENCE_DIR", "") }, auth, degraded, }; diff --git a/server/src/presence/store.ts b/server/src/presence/store.ts new file mode 100644 index 0000000..89a4c9e --- /dev/null +++ b/server/src/presence/store.ts @@ -0,0 +1,128 @@ +/** + * Who is in the building, off disk. + * + * `TERA_PRESENCE_DIR` holds one `.json` per office, in the same shape + * and by the same rules as `offices/store.ts`: hand-written, no database, no + * build step. The two are deliberately separate directories rather than one + * document with a `people` field on it, and that separation is the whole design + * rather than a filing preference. + * + * ### Why occupancy is not part of the office pack + * + * `src/interiors/types.ts` says a `Presence` binds to a `seatId` and never to a + * coordinate, so that the office geometry can be published and the people cannot + * be inferred from it. That rule buys nothing if the two live in the same file: + * an operator who wants to make their floorplan public would have to strip the + * roster out of it by hand every time, and the first time they forget, the leak + * is permanent and public. Two directories means the safe thing is the default + * thing — `TERA_OFFICES_DIR` can be world-readable, backed up and copied around, + * and it still contains no people. + * + * It is also the honest shape for where the data comes from. A floorplan changes + * when somebody moves a wall; a roster changes when somebody sits down. The pack + * is a document and this is a reading, so the reading carries `observedAt` and + * the pack does not. + * + * ### A box with no presence directory has no people + * + * That is the zero-config default and it is not an error: the reference office + * renders perfectly with nobody in it, which is what `presence.ts` means when it + * says the public building is "the same building, before anyone arrived". The + * route above turns that into an empty list and a 200, never a 404 — see + * `routes/presence.ts` for why the absence of a roster must not be reported the + * same way as the absence of an office. + */ + +import { readFile } from "node:fs/promises"; +import { join } from "node:path"; +import type { PresenceBody } from "../../../src/server/wire.ts"; +import type { Presence } from "../../../src/interiors/types.ts"; + +/** + * Ids are the filename, so this is a path-traversal boundary and not a style + * preference — the same pattern `offices/store.ts` uses, and it has to be the + * same one, because the two directories are addressed by the same id and a + * pattern that accepted more here would be the weaker of the two. + */ +const ID_PATTERN = /^[a-z0-9][a-z0-9-]{0,63}$/; + +/** + * A ceiling on how many people one office may report. + * + * Not a policy about office size — it is a bound on what a bad file can do to a + * browser. Every presence is a mesh and a draw call, so a roster with fifty + * thousand rows in it, whether by mistake or otherwise, is a tab that stops + * responding. Dropping the tail is visible and recoverable; the alternative is + * not. + */ +const MAX_PEOPLE = 2000; + +export interface PresenceStore { + /** Never throws and never 404s. An office with no roster has an empty one. */ + get(officeId: string): Promise; +} + +export function createPresenceStore(dir: string): PresenceStore { + return { + async get(officeId: string): Promise { + const empty: PresenceBody = { officeId, people: [] }; + if (dir === "" || !ID_PATTERN.test(officeId)) return empty; + try { + const parsed: unknown = JSON.parse( + await readFile(join(dir, `${officeId}.json`), "utf8"), + ); + return normalise(parsed, officeId) ?? empty; + } catch { + // Missing, unreadable and unparseable are the same answer: nobody is + // recorded as being here. Distinguishing them out loud would leak the + // directory listing one status code at a time, which is the reasoning + // `offices/store.ts` already committed to for the same directory shape. + return empty; + } + }, + }; +} + +function normalise(parsed: unknown, officeId: string): PresenceBody | null { + if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) return null; + const doc = parsed as { people?: unknown; observedAt?: unknown }; + if (!Array.isArray(doc.people)) return null; + + const people: Presence[] = []; + for (const row of doc.people) { + const person = normalisePerson(row); + // A row that cannot be placed is dropped rather than repaired, and there is + // no id or seat invented for it. `presence.ts` drops an unplaceable presence + // for the same reason at the other end of the wire: putting somebody at a + // made-up desk turns a private id into a public coordinate, which is the one + // thing this whole split exists to stop. + if (person !== null) people.push(person); + if (people.length >= MAX_PEOPLE) break; + } + + return { + officeId, + people, + ...(typeof doc.observedAt === "string" ? { observedAt: doc.observedAt } : {}), + }; +} + +function normalisePerson(row: unknown): Presence | null { + if (row === null || typeof row !== "object" || Array.isArray(row)) return null; + const p = row as Partial; + if (typeof p.seatId !== "string" || p.seatId === "") return null; + + // The id defaults to the seat, because a roster where one desk is listed twice + // is a mistake in the file and not something to render twice; `presence.ts` + // keys its meshes by presence id, so two rows sharing one would be one person. + // The label defaults to nothing rather than to the seat id: a hover card + // reading "eng-04" is the desk answering a question about the person. + return { + id: typeof p.id === "string" && p.id !== "" ? p.id : p.seatId, + seatId: p.seatId, + label: typeof p.label === "string" ? p.label : "", + colorKey: typeof p.colorKey === "string" ? p.colorKey : "in", + ...(typeof p.url === "string" ? { url: p.url } : {}), + ...(typeof p.blurb === "string" ? { blurb: p.blurb } : {}), + }; +} diff --git a/server/src/routes/presence.ts b/server/src/routes/presence.ts new file mode 100644 index 0000000..fa7d8d9 --- /dev/null +++ b/server/src/routes/presence.ts @@ -0,0 +1,80 @@ +/** + * `GET /api/v1/offices/:id/presence` — the route that makes signing in mean + * something. + * + * Until this existed, `member` and `anon` were told apart by the client and by + * nothing else *inside the office*: `access.ts` picked `officeDepth`, the scene + * built a presence layer at full depth, and then nobody ever called + * `setPresence`, so both tiers rendered the identical empty building. A tier + * that changes nothing you can see is not a tier, and — exactly as + * `routes/markers.ts` argues for the city — a distinction drawn only in the + * browser is a UI hiding a control over a body the API hands to whoever asks. + * This is where the refusal actually happens. + * + * ### Occupancy always takes a session. Markers only sometimes do. + * + * `markers.ts` serves its feed to anonymous callers when no feed is configured, + * on the grounds that there is nothing there to protect. The same reasoning does + * **not** transfer, and the difference is worth stating rather than inferring: + * a marker is a company at an address and a presence is a person at a desk. So + * the auth check here is unconditional. A box with no roster still answers 200 + * and an empty list — see below — but it answers it to a caller who signed in. + * + * ### 401 here, and why that is not the enumeration oracle §6 forbids + * + * CONTRACT.md §6 says a private office answers 404 rather than 403 so the id + * space cannot be walked for a tenant list, and `offices.ts` implements exactly + * that. This route resolves the viewer **first**, before it has looked at the id + * at all, so an anonymous caller gets the same 401 for every id in the world — + * real, private, or invented. Nothing is learned. Only a caller who has already + * signed in reaches the point where ids differ, and at that point they can + * already read `/offices/:id`, so this leaks nothing that route does not. + * + * Doing it in the other order is the bug this comment exists to prevent: check + * the office first and an anonymous 404-for-unknown against 401-for-known + * distinguishes them perfectly, which is the oracle wearing a different status + * code. + * + * ### An office with no roster is 200 and empty, never 404 + * + * `store.ts` cannot fail, and that is deliberate. "This office does not exist" + * and "nobody has told me who is in this office" are different facts with + * different fixes — one is a wrong URL, the other is an unset + * `TERA_PRESENCE_DIR` — and collapsing them into one 404 sends an operator + * looking for the wrong problem. The empty building is also the correct render: + * it is what the office looks like before anyone arrives. + */ + +import type { FastifyInstance } from "fastify"; +import type { ErrorBody } from "../../../src/server/wire.ts"; +import type { Services } from "../services.ts"; + +const UNAUTHORIZED: ErrorBody = { + error: "unauthorized", + message: "Occupancy is for signed-in members.", +}; + +const NOT_FOUND: ErrorBody = { error: "not_found", message: "No such office." }; + +export function registerPresence(app: FastifyInstance, services: Services): void { + app.get<{ Params: { id: string } }>("/api/v1/offices/:id/presence", async (req, reply) => { + // Viewer first, before the id is looked at. See the header — the order is + // the security property, not an implementation detail. + const viewer = await services.auth.resolve(req); + if (!viewer.authenticated) { + return reply.code(401).header("www-authenticate", "Bearer").send(UNAUTHORIZED); + } + + // Then the office, by the same rule `offices.ts` applies: an office this + // viewer may not see is indistinguishable from one that is not there. + const doc = await services.offices.get(req.params.id); + if (doc === null) return reply.code(404).send(NOT_FOUND); + + // No `publicCache`, ever. This body took a credential to obtain and names + // people; a shared cache holding it would hand one member's copy to the next + // caller, which is precisely what the fail-closed default in `cache.ts` + // exists to prevent. Saying so here rather than staying silent, because the + // absence of a call is not self-evidently a decision. + return services.presence.get(req.params.id); + }); +} diff --git a/server/src/services.ts b/server/src/services.ts index 0a508cf..be2d0a4 100644 --- a/server/src/services.ts +++ b/server/src/services.ts @@ -11,6 +11,7 @@ import { createAuth, type AuthService } from "./auth/index.ts"; import { createFlightsService, type FlightsService } from "./flights/index.ts"; import { createMarkerStore, type MarkerStore } from "./markers/store.ts"; import { createOfficeStore, type OfficeStore } from "./offices/store.ts"; +import { createPresenceStore, type PresenceStore } from "./presence/store.ts"; import { createWeatherService, type WeatherService } from "./weather/index.ts"; import type { Config } from "./config.ts"; @@ -20,6 +21,7 @@ export interface Services { flights: FlightsService; markers: MarkerStore; offices: OfficeStore; + presence: PresenceStore; auth: AuthService; /** Epoch milliseconds, for `uptimeSeconds` on the health body. */ startedAt: number; @@ -37,6 +39,7 @@ export function createServices(config: Config, log: ServiceLog): Services { flights: createFlightsService(config, log), markers: createMarkerStore(config, log), offices: createOfficeStore(config.offices.dir), + presence: createPresenceStore(config.presence.dir), auth: createAuth(config.auth), startedAt: Date.now(), }; diff --git a/server/src/test/presence.test.ts b/server/src/test/presence.test.ts new file mode 100644 index 0000000..55483a9 --- /dev/null +++ b/server/src/test/presence.test.ts @@ -0,0 +1,285 @@ +/** + * Occupancy: the refusal, the ordering that makes it safe, and the file. + * + * Two assertions carry this file and both are negative. + * + * The first is the refusal itself. Before this route, `member` and `anon` + * differed *inside the office* only in the browser: the client picked a depth + * and the server never refused anybody anything. `markers.test.ts` makes the + * same argument for the city and it is the same argument — a tier drawn in the + * client is a UI hiding a control over a body the API hands to whoever asks. + * + * The second is the ordering, and it is the subtler one. CONTRACT.md §6 forbids + * an endpoint that lets the id space be walked for a tenant list. This route + * resolves the viewer before it looks at the id, so an anonymous caller gets an + * identical 401 for a real office, a private one and one that was never created. + * Check the office first and the status codes tell them apart perfectly, which + * is the enumeration oracle wearing a different number. The test for that is + * three requests and one `deepEqual`. + * + * Follows `offices.test.ts`: a temp directory, `buildApp` over a fake + * environment, `inject()` rather than a socket, and HS256 by hand. + */ + +import assert from "node:assert/strict"; +import { createHmac } from "node:crypto"; +import { mkdtemp, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { after, before, describe, it } from "node:test"; +import { buildApp } from "../app.ts"; +import { loadConfig } from "../config.ts"; +import type { PresenceBody } from "../../../src/server/wire.ts"; + +const SECRET = "not-a-real-secret-and-never-was"; + +/** A minimal floor. `Plan` is what makes sense of it; the API only carries it. */ +const floor = { id: "hq", name: "HQ", levels: [], viewpoints: [] }; + +let offices = ""; +let presence = ""; + +before(async () => { + offices = await mkdtemp(join(tmpdir(), "tera-offices-")); + presence = await mkdtemp(join(tmpdir(), "tera-presence-")); + + await writeFile( + join(offices, "open.json"), + JSON.stringify({ id: "open", name: "Open office", visibility: "public", floor }), + ); + await writeFile( + join(offices, "closed.json"), + JSON.stringify({ id: "closed", name: "Closed office", visibility: "private", floor }), + ); + // An office with no roster beside it. Deliberately: "no such office" and + // "nobody has told me who is in this one" are different facts. + await writeFile( + join(offices, "empty.json"), + JSON.stringify({ id: "empty", name: "Empty office", visibility: "public", floor }), + ); + + await writeFile( + join(presence, "open.json"), + JSON.stringify({ + observedAt: "2026-08-06T09:00:00.000Z", + people: [ + { id: "p-01", seatId: "eng-01", label: "Tobias Quillon", colorKey: "in" }, + { id: "p-02", seatId: "eng-02", label: "Ines Marchetti", colorKey: "focus" }, + // No id: the seat stands in for one, because a roster listing one desk + // twice is a mistake in the file and not two people. + { seatId: "eng-03", label: "Dara Oyelaran-Pike" }, + // No seat: unplaceable, and dropped rather than repaired. Inventing a + // desk would turn a private id into a public coordinate. + { id: "p-04", label: "Nobody In Particular" }, + "not even an object", + ], + }), + ); + await writeFile(join(presence, "closed.json"), JSON.stringify({ people: [] })); + await writeFile(join(presence, "broken.json"), "{ this is not json"); +}); + +function appWith(env: Record) { + const config = loadConfig({ + TERA_OFFICES_DIR: offices, + TERA_PRESENCE_DIR: presence, + ...env, + }); + config.logLevel = "silent"; + return buildApp(config); +} + +function hs256(claims: Record): string { + const encode = (value: unknown): string => + Buffer.from(JSON.stringify(value)).toString("base64url"); + const signed = `${encode({ alg: "HS256", typ: "JWT" })}.${encode(claims)}`; + return `${signed}.${createHmac("sha256", SECRET).update(signed).digest("base64url")}`; +} + +const jwt = { TERA_AUTH_MODE: "jwt", TERA_AUTH_JWT_SECRET: SECRET }; + +function bearer(): { authorization: string } { + return { authorization: `Bearer ${hs256({ sub: "someone", exp: nextHour() })}` }; +} + +/** + * An hour out, computed rather than written down: a fixed `exp` in a test file + * is a test that starts failing on a date nobody chose. + */ +function nextHour(): number { + return Math.floor(Date.now() / 1000) + 3600; +} + +describe("the refusal", () => { + it("refuses an anonymous caller even for a public office", async () => { + const app = appWith(jwt); + after(() => app.close()); + + const res = await app.inject({ method: "GET", url: "/api/v1/offices/open/presence" }); + assert.equal(res.statusCode, 401); + assert.equal(res.headers["www-authenticate"], "Bearer"); + // The point: the office itself is public and readable by this same caller. + const doc = await app.inject({ method: "GET", url: "/api/v1/offices/open" }); + assert.equal(doc.statusCode, 200); + }); + + it("tells a real, a private and an absent office apart for nobody", async () => { + const app = appWith(jwt); + after(() => app.close()); + + const real = await app.inject({ method: "GET", url: "/api/v1/offices/open/presence" }); + const priv = await app.inject({ method: "GET", url: "/api/v1/offices/closed/presence" }); + const gone = await app.inject({ method: "GET", url: "/api/v1/offices/no-such-thing/presence" }); + + assert.equal(real.statusCode, 401); + assert.equal(priv.statusCode, 401); + assert.equal(gone.statusCode, 401); + assert.deepEqual(real.json(), priv.json()); + assert.deepEqual(real.json(), gone.json()); + }); + + it("never lets a shared cache keep a roster", async () => { + const app = appWith(jwt); + after(() => app.close()); + + const res = await app.inject({ + method: "GET", + url: "/api/v1/offices/open/presence", + headers: bearer(), + }); + assert.equal(res.statusCode, 200); + assert.equal(res.headers["cache-control"], "private, no-store"); + }); + + it("is unreachable by everyone when nobody can sign in", async () => { + // `TERA_AUTH_MODE=none` means no caller is ever authenticated, so this route + // refuses the whole world. That is the fail-closed direction and the same one + // a private office already takes. + const app = appWith({}); + after(() => app.close()); + + const res = await app.inject({ + method: "GET", + url: "/api/v1/offices/open/presence", + headers: bearer(), + }); + assert.equal(res.statusCode, 401); + }); +}); + +describe("the file", () => { + it("serves the roster to a signed-in caller", async () => { + const app = appWith(jwt); + after(() => app.close()); + + const res = await app.inject({ + method: "GET", + url: "/api/v1/offices/open/presence", + headers: bearer(), + }); + assert.equal(res.statusCode, 200); + const body = res.json(); + assert.equal(body.officeId, "open"); + assert.equal(body.observedAt, "2026-08-06T09:00:00.000Z"); + assert.deepEqual( + body.people.map((p) => p.seatId), + ["eng-01", "eng-02", "eng-03"], + ); + }); + + it("drops the unplaceable and keeps the rest of the file", async () => { + const app = appWith(jwt); + after(() => app.close()); + + const body = ( + await app.inject({ + method: "GET", + url: "/api/v1/offices/open/presence", + headers: bearer(), + }) + ).json(); + + // A row with no seat and a row that is not an object are both gone, and + // neither took the three good rows with it. + assert.equal(body.people.length, 3); + assert.ok(!body.people.some((p) => p.label === "Nobody In Particular")); + }); + + it("stands the seat in for a missing id and defaults the colour", async () => { + const app = appWith(jwt); + after(() => app.close()); + + const body = ( + await app.inject({ + method: "GET", + url: "/api/v1/offices/open/presence", + headers: bearer(), + }) + ).json(); + + const third = body.people[2]; + assert.equal(third?.id, "eng-03"); + assert.equal(third?.colorKey, "in"); + }); + + it("answers 200 and an empty list for an office with no roster", async () => { + // Not 404. An unset roster is a different problem from a wrong URL, and + // sending an operator looking for the wrong one costs them an afternoon. + const app = appWith(jwt); + after(() => app.close()); + + const res = await app.inject({ + method: "GET", + url: "/api/v1/offices/empty/presence", + headers: bearer(), + }); + assert.equal(res.statusCode, 200); + assert.deepEqual(res.json().people, []); + }); + + it("answers 404 to a signed-in caller for an office that is not there", async () => { + const app = appWith(jwt); + after(() => app.close()); + + const res = await app.inject({ + method: "GET", + url: "/api/v1/offices/no-such-thing/presence", + headers: bearer(), + }); + assert.equal(res.statusCode, 404); + }); + + it("treats an unreadable roster as an empty one", async () => { + // `broken.json` is not JSON. The building still renders; it renders empty. + const app = appWith(jwt); + after(() => app.close()); + + await writeFile( + join(offices, "broken.json"), + JSON.stringify({ id: "broken", name: "Broken", visibility: "public", floor }), + ); + + const res = await app.inject({ + method: "GET", + url: "/api/v1/offices/broken/presence", + headers: bearer(), + }); + assert.equal(res.statusCode, 200); + assert.deepEqual(res.json().people, []); + }); + + it("has no people at all when no presence directory is configured", async () => { + const config = loadConfig({ TERA_OFFICES_DIR: offices, ...jwt }); + config.logLevel = "silent"; + const app = buildApp(config); + after(() => app.close()); + + const res = await app.inject({ + method: "GET", + url: "/api/v1/offices/open/presence", + headers: bearer(), + }); + assert.equal(res.statusCode, 200); + assert.deepEqual(res.json().people, []); + }); +}); diff --git a/src/adapters/http.ts b/src/adapters/http.ts index 06e2ffb..cf22a6a 100644 --- a/src/adapters/http.ts +++ b/src/adapters/http.ts @@ -50,6 +50,7 @@ import type { HealthBody, MarkersBody, OfficeDoc, + PresenceBody, WeatherBody, } from "../server/wire.ts"; import { SAMPLE_MARKERS, SAMPLE_PALETTE } from "./sample.ts"; @@ -214,6 +215,16 @@ export interface TeraClient { * a bundled office of its own already has the better answer. */ office(id: string): Promise; + /** + * Occupancy for one office, or `null` when this deployment will not say. + * + * Always an authenticated call — `routes/presence.ts` refuses an anonymous + * one whatever the deployment's other settings are, because a marker is a + * company at an address and a presence is a person at a desk. A build with no + * API behind it gets `null` and renders the empty building, which is the + * correct picture of an office nobody has told it about. + */ + presence(officeId: string): Promise; } /** @@ -323,6 +334,19 @@ export function createTeraClient(options: TeraApiOptions = {}): TeraClient { }, office: (id) => get(`/offices/${encodeURIComponent(id)}`), + + /** + * Who is in that office. + * + * `null` for every refusal, which here folds three different facts into one: + * no API at all, an API that wants a session this browser does not have, and + * an office the server will not name. The caller does the same thing with + * all three — draw the building with nobody in it — and a taxonomy it does + * not branch on is a taxonomy nobody maintains. `get` already logs nothing + * and throws nothing; see the note on coarseness at the top of this file. + */ + presence: (officeId) => + get(`/offices/${encodeURIComponent(officeId)}/presence`), }; } diff --git a/src/adapters/sample.ts b/src/adapters/sample.ts index 74a8dbe..ccd8b4f 100644 --- a/src/adapters/sample.ts +++ b/src/adapters/sample.ts @@ -28,6 +28,7 @@ */ import { regionOf, syntheticRoutes, type SimRoute } from "../engine/flights.ts"; +import type { Presence } from "../interiors/types.ts"; import type { City, Marker, MarkerPalette } from "../engine/types.ts"; /** @@ -355,3 +356,89 @@ export function sampleRoutesFor(city: Pick): S if (city.id === "socal") return SAMPLE_SOCAL_ROUTES; return syntheticRoutes(regionOf(city)); } + +// ---- The office ------------------------------------------------------------ + +/** + * A fabricated roster for the reference office, so that "sign in" has something + * to show in a clone with no server behind it. + * + * **Every person here is invented**, by the same rule and for the same reason as + * the companies above: the names are deliberately unmistakable for anyone's + * — Quillon, Marchetti, Oyelaran-Pike — and none of them works anywhere. Real + * occupancy is the most private thing this project touches. It says who was at + * their desk and, by omission, who was not, which is an attendance record; it + * arrives at runtime over an authenticated endpoint (`routes/presence.ts`) and + * it never, ever lands in this repo. + * + * The demo is worth having for the same reason the markers are. An empty + * building teaches nobody what the office is *for*, and the entire presence + * layer — the figures, the seat binding, the hover card that names somebody — + * is invisible without data that exercises it. It also makes the tier honest + * locally: `access.ts` hands a clone `member`, and a member who is shown the + * same empty room as a stranger has been told the tier means something when it + * does not. + * + * The seat ids are real ids from `offices/lumbridge-hq.ts` and have to be: a + * presence whose seat is not in the plan is dropped by `presence.ts`, silently + * and correctly, so a typo here is an invisible absence rather than an error. + * They are also spread deliberately — two meeting rooms mid-session, a full + * bench, a half-empty one, three focus booths of which one is taken — because a + * floor where every desk is occupied reads as a texture rather than as people. + */ +export const SAMPLE_PRESENCE: Presence[] = [ + // Reception, and someone waiting. + { id: "p-01", seatId: "reception-01", label: "Wren Abaddon", colorKey: "in" }, + { id: "p-02", seatId: "lobby-01", label: "Visitor", colorKey: "guest" }, + + // The eng bench, full along the window row and thinning behind it. + { id: "p-03", seatId: "eng-01", label: "Tobias Quillon", colorKey: "in" }, + { id: "p-04", seatId: "eng-02", label: "Ines Marchetti", colorKey: "in" }, + { id: "p-05", seatId: "eng-03", label: "Dara Oyelaran-Pike", colorKey: "in" }, + { id: "p-06", seatId: "eng-04", label: "Sunniva Holt", colorKey: "in" }, + { id: "p-07", seatId: "eng-05", label: "Casimir Vane", colorKey: "focus" }, + { id: "p-08", seatId: "eng-06", label: "Perpetua Nkemelu", colorKey: "in" }, + { id: "p-09", seatId: "eng-08", label: "Rafferty Osgood", colorKey: "in" }, + { id: "p-10", seatId: "eng-11", label: "Marisol Thibault", colorKey: "focus" }, + + // Ops, about half in. + { id: "p-11", seatId: "ops-01", label: "Anouk Sterling", colorKey: "in" }, + { id: "p-12", seatId: "ops-02", label: "Emeka Farrow", colorKey: "in" }, + { id: "p-13", seatId: "ops-05", label: "Bettina Kovač", colorKey: "in" }, + { id: "p-14", seatId: "ops-07", label: "Xiomara Belfry", colorKey: "focus" }, + { id: "p-15", seatId: "ops-10", label: "Aurelio Pinsent", colorKey: "in" }, + + // Design. + { id: "p-16", seatId: "design-01", label: "Halcyon Reeve", colorKey: "in" }, + { id: "p-17", seatId: "design-03", label: "Zephyrine Mbeki", colorKey: "in" }, + { id: "p-18", seatId: "design-04", label: "Ptolemy Sandoval", colorKey: "in" }, + + // One focus booth of three, which is the honest ratio. + { id: "p-19", seatId: "booth-02", label: "Ottoline Grieve", colorKey: "focus" }, + + // Alcatraz, mid-session — six of ten, which is what a real meeting looks like. + { id: "p-20", seatId: "alcatraz-01", label: "Cormac Dellwood", colorKey: "meeting" }, + { id: "p-21", seatId: "alcatraz-02", label: "Solveig Amankwah", colorKey: "meeting" }, + { id: "p-22", seatId: "alcatraz-03", label: "Ignatius Pell", colorKey: "meeting" }, + { id: "p-23", seatId: "alcatraz-06", label: "Rosalind Achebe", colorKey: "meeting" }, + { id: "p-24", seatId: "alcatraz-07", label: "Vasco Underhill", colorKey: "meeting" }, + { id: "p-25", seatId: "alcatraz-08", label: "Clementine Roux", colorKey: "meeting" }, +]; + +/** + * `colorKey` -> colour, for the fabricated roster. + * + * Four states and no more, because the palette is the legend whether or not one + * is drawn: a viewer can tell "at their desk" from "heads-down" from "in a + * meeting" at a glance, and a fifth shade would be a distinction nobody could + * name. Amber for focus is the interface's own accent doing what it does + * everywhere else in this app — marking the thing you are meant to notice — + * and the guest green is the only hue that is not in the office's own materials, + * because a visitor is the one person in the building who is not part of it. + */ +export const SAMPLE_PRESENCE_PALETTE: Record = { + in: 0x6f9ec4, + focus: 0xf2b134, + meeting: 0xc4796f, + guest: 0x7fb886, +}; diff --git a/src/engine/officeMinimap.ts b/src/engine/officeMinimap.ts index de5950f..3f9c110 100644 --- a/src/engine/officeMinimap.ts +++ b/src/engine/officeMinimap.ts @@ -36,6 +36,7 @@ import * as THREE from "three"; import type { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js"; import { kit, type AssetRegistry } from "../assets/kit.ts"; import type { LevelPlan, Plan, ResolvedRoom } from "../interiors/plan.ts"; +import type { Presence } from "../interiors/types.ts"; /** What the pointer is over, for a readout line the caller owns. */ export interface OfficePlanHoverInfo { @@ -44,6 +45,15 @@ export interface OfficePlanHoverInfo { z: number; /** The room under the pointer, by name, or `null` out in the circulation. */ room: string | null; + /** + * Who is at the desk under the pointer, by label, or `null`. + * + * A label and never an id. The plan is drawn from the pack, which knows seat + * `eng-04` and nothing else; the name arrives separately over an authenticated + * request, and handing back the id when there is nobody there would leak the + * seating chart into a widget that is otherwise pure geometry. + */ + person: string | null; /** The level being drawn, by name. Printed only when there is more than one. */ level: string; } @@ -78,6 +88,16 @@ export interface OfficeMinimap { * asked for and arriving. */ setActiveView(id: string | null): void; + /** + * Who is in, so the plan can mark their desks. + * + * Takes `Presence[]` rather than a set of seat ids so the hover readout can + * name somebody without a second lookup by the caller. A presence whose seat + * is not on this plan is dropped, exactly as `presence.ts` drops it in the + * scene and for the same reason: there is nowhere to put it, and inventing a + * spot would turn a private id into a public coordinate. + */ + setPresence(people: readonly Presence[]): void; /** Call from the stage tick. Cheap by construction — see the file header. */ tick(): void; /** Re-do the backing store at the current size and re-rasterise the plan. */ @@ -195,6 +215,10 @@ export function createOfficeMinimap(options: OfficeMinimapOptions): OfficeMinima */ let level: LevelPlan | null = plan.levels[0] ?? null; let activeViewId: string | null = null; + /** Occupied seats on this storey: x, y device pixels per person, laid out once. */ + let occupiedPx = new Float64Array(0); + /** Seat id -> label, for the hover readout. Every seat in the building, not just this storey. */ + let peopleBySeat = new Map(); // Laid-out geometry. Flat arrays and paths of device pixels, rebuilt on resize // and on a change of storey, so the draw loop reads numbers and never projects. @@ -438,6 +462,7 @@ export function createOfficeMinimap(options: OfficeMinimapOptions): OfficeMinima layoutLabels(); layoutViewpoints(); + layoutOccupied(); } /** @@ -469,6 +494,25 @@ export function createOfficeMinimap(options: OfficeMinimapOptions): OfficeMinima ctx.restore(); } + /** + * Where the occupied desks are, in device pixels. + * + * Resolved through `plan.seat()` rather than through the caller, because the + * seat's position is the plan's fact and a second copy of it would be a second + * thing to get wrong. Seats on another storey resolve fine and are skipped + * here — they are drawn when that storey is. + */ + function layoutOccupied() { + if (!level || scale <= 0) return; + const points: number[] = []; + for (const seatId of peopleBySeat.keys()) { + const seat = plan.seat(seatId); + if (!seat || seat.levelId !== level.id) continue; + points.push(toPxX(seat.position.x), toPxY(seat.position.z)); + } + occupiedPx = new Float64Array(points); + } + function layoutViewpoints() { if (!level) return; const here = plan.viewpoints.filter((v) => v.levelId === level?.id); @@ -672,6 +716,34 @@ export function createOfficeMinimap(options: OfficeMinimapOptions): OfficeMinima } } + /** + * An occupied desk, as a filled dot with a dark rim. + * + * Drawn in the overlay rather than into the static raster, because occupancy + * is the one thing on this plan that changes without the building changing — + * re-rasterising fifteen rooms and two hundred props to move one dot would be + * the wrong trade by three orders of magnitude. + * + * One colour for everybody, deliberately, where the scene has four. The scene + * has the room to distinguish heads-down from in-a-meeting and this does not: + * at three device pixels a hue is a guess, and four guesses on one plan is a + * legend nobody asked for. The plan answers "is anyone there", the room + * answers "who, and what are they doing". + */ + function drawOccupied(ctx: Ctx) { + if (occupiedPx.length === 0) return; + const r = 2.4 * dpr; + ctx.lineWidth = dpr; + ctx.fillStyle = theme.occupied; + ctx.strokeStyle = theme.occupiedEdge; + for (let i = 0; i < occupiedPx.length; i += 2) { + ctx.beginPath(); + ctx.arc(occupiedPx[i] ?? 0, occupiedPx[i + 1] ?? 0, r, 0, Math.PI * 2); + ctx.fill(); + ctx.stroke(); + } + } + function drawPing(ctx: Ctx, now: number) { if (pinging === 0) return; const t = (now - pinging) / PING_MS; @@ -698,6 +770,7 @@ export function createOfficeMinimap(options: OfficeMinimapOptions): OfficeMinima ctx.clip(); drawFootprint(ctx); + drawOccupied(ctx); drawViewpoints(ctx); crosshair(ctx, toPxX(controls.target.x), toPxY(controls.target.z), theme.target, 5 * dpr); drawCamera(ctx); @@ -756,6 +829,38 @@ export function createOfficeMinimap(options: OfficeMinimapOptions): OfficeMinima return plan.roomAt(level.id, { x, z }); } + /** + * Whoever is sitting within a desk's width of the pointer, or nobody. + * + * A radius rather than a hit test on the seat itself, because a seat is a + * point and a pointer on a 14 rem widget is worth about fifteen centimetres of + * office. Three quarters of a metre is close enough to be unambiguous — desks + * in a bench are 1.7 m apart — and forgiving enough to be usable. + * + * Linear over the occupied seats, which is the right algorithm at this size: a + * full floor is a few dozen people, this runs on pointer moves already + * throttled by the browser, and a spatial index would be more code than the + * thing it indexes. + */ + function personNear(x: number, z: number): string | null { + if (!level) return null; + const reach = 0.75; + let best: string | null = null; + let bestGap = reach * reach; + for (const [seatId, label] of peopleBySeat) { + const seat = plan.seat(seatId); + if (!seat || seat.levelId !== level.id) continue; + const dx = seat.position.x - x; + const dz = seat.position.z - z; + const gap = dx * dx + dz * dz; + if (gap < bestGap) { + bestGap = gap; + best = label; + } + } + return best; + } + function seekTo(px: number, py: number) { if (!ready) return; const x = clampX(px); @@ -797,6 +902,7 @@ export function createOfficeMinimap(options: OfficeMinimapOptions): OfficeMinima x: wx, z: wz, room: hoverRoom, + person: personNear(wx, wz), level: level?.name ?? "", }); } @@ -964,6 +1070,18 @@ export function createOfficeMinimap(options: OfficeMinimapOptions): OfficeMinima dirty = true; }, + setPresence(people) { + peopleBySeat = new Map(); + for (const person of people) { + // Last writer wins on a duplicated seat, which matches what the scene + // does with two meshes at one position: you see one person. A roster + // that seats two people at one desk is wrong in the roster. + peopleBySeat.set(person.seatId, person.label); + } + layoutOccupied(); + dirty = true; + }, + tick() { if (!ready || !viewCtx) return; const now = performance.now(); @@ -1032,6 +1150,8 @@ interface Theme { propEdge: string; label: string; labelHalo: string; + occupied: string; + occupiedEdge: string; frame: string; footprintFill: string; footprintStroke: string; @@ -1086,6 +1206,11 @@ function buildTheme(): Theme { // The ground colour, near-opaque, so a name over a desk bank sits in its own // small clearing rather than in the middle of the desks. labelHalo: rgba(rgbOf(0x0a0d11), 0.82), + // Brighter than the furniture it sits on and cooler than the amber the + // camera owns, so a busy floor never competes with "where am I looking", + // which is still this widget's first job. + occupied: rgba(rgbOf(0x8ec3e8), 0.95), + occupiedEdge: rgba(rgbOf(0x0a0d11), 0.7), frame: rgba(rgbOf(0x9fb4c6), 0.3), // Faint, for the reason the city widget's is faint: on the whole-floor view // the footprint covers most of the widget, and a fill that is a hint over diff --git a/src/main.ts b/src/main.ts index f4a9fbb..04f8450 100644 --- a/src/main.ts +++ b/src/main.ts @@ -32,7 +32,13 @@ import { type TrafficSource, type WeatherWatch, } from "./adapters/http.ts"; -import { SAMPLE_MARKERS, SAMPLE_PALETTE, sampleRoutesFor } from "./adapters/sample.ts"; +import { + SAMPLE_MARKERS, + SAMPLE_PALETTE, + SAMPLE_PRESENCE, + SAMPLE_PRESENCE_PALETTE, + sampleRoutesFor, +} from "./adapters/sample.ts"; import { authFetch } from "./session.ts"; import { capabilitiesFor, resolveAccess, type Access } from "./access.ts"; import { createMinimap, type Minimap } from "./engine/minimap.ts"; @@ -49,7 +55,7 @@ import { createMinimap, type Minimap } from "./engine/minimap.ts"; * the bundle just gets big again. */ import type { OfficeScene } from "./interiors/officeScene.ts"; -import type { Office } from "./interiors/types.ts"; +import type { Office, Presence } from "./interiors/types.ts"; import type { MaterialRegistry } from "./assets/materials.ts"; // Type-only for the reason above, and it matters more here than it looks: // `officeMinimap.ts` imports the asset registry as a *value*, to read prop @@ -186,6 +192,18 @@ let officeMaterials: MaterialRegistry | null = null; * from which exist, and `showPlan()` is the only thing that answers it. */ let officePlan: OfficeMinimap | null = null; +/** + * Whether the people currently on the floor came from the deployment or from + * `sample.ts`. + * + * Kept because the difference has to be *said*. Every other gap on this page is + * a silence — an empty office, a missing scrubber — and a silence you cannot + * attribute is indistinguishable from a fault; here the failure is the opposite + * and worse, because fabricated people are not silent. Twenty-five invented + * names at real desks is a screenshot somebody will take, and it must not be + * possible to take it without the caption. + */ +let presenceIsSample = false; /** * `office.lumbridgecorp.com` and `tera.lumbridgecorp.com` are one bundle behind @@ -587,6 +605,8 @@ async function enterOffice() { background: 0x11161c, depth, materials, + // Ignored entirely at `"public"` depth, where no layer is built to colour. + presencePalette: SAMPLE_PRESENCE_PALETTE, // Two different questions, so two different callbacks. `onPresencePick` // answers "who is at this desk"; `onPlacePick` answers only "this is a // desk, and it is the fourteenth one" — which is all a stranger is told. @@ -603,6 +623,11 @@ async function enterOffice() { showDetail(null); refreshGodmodePlace(); renderLegend(); + // After the room is on screen, not before. Occupancy is a second request and + // the building is worth looking at while it is in flight; awaiting it here + // would hold the door shut on a network round trip to draw people into a + // scene the user cannot see yet. + void refreshPresence(); } /** @@ -660,7 +685,12 @@ function buildOfficePlan( // packs — "Level 1" on every hover is a word that never changes and // therefore never informs. const level = scene.plan.levels.length > 1 && info.level ? ` · ${info.level}` : ""; - minimapReadout.textContent = `${where}${level}${info.room ? ` · ${info.room}` : ""}`; + // The person last, and after the room, because the readout is read left to + // right as an address getting more specific: the floor, then the room, + // then who is in it. + const room = info.room ? ` · ${info.room}` : ""; + const person = info.person ? ` · ${info.person}` : ""; + minimapReadout.textContent = `${where}${level}${room}${person}`; }, }); } @@ -755,6 +785,39 @@ function officeName(): string { return officePack?.name ?? "Spaces"; } +/** + * Who is in the building, asked for once per entry. + * + * Only at full depth, and that is not an optimisation. `createOfficeScene` at + * `"public"` builds **no presence layer at all** — not an empty one, not a + * hidden one — so there is nothing here to populate and the request would be one + * this visitor's session is going to be refused anyway. `presence.ts` explains + * at length why the layer is absent rather than emptied; this is the call site + * that would otherwise quietly reintroduce it. + * + * The fallback is `markers`' fallback, one room in: an API that does not answer + * gets the fabricated roster, because a clone with no server is the flagship + * case and a member shown the same empty room as a stranger has been told the + * tier means something when it does not. An API that *does* answer is believed, + * including when it answers with nobody — an office where everyone has gone home + * is a real fact about an office, and overwriting it with invented people to + * make the demo livelier is the one thing this file must never do. + */ +async function refreshPresence() { + const scene = office; + if (!scene || scene.depth !== "full") return; + const body = await tera.presence(officePack?.id ?? "lumbridge-hq"); + const people: Presence[] = body?.people ?? SAMPLE_PRESENCE; + presenceIsSample = body === null; + // The scene may have been torn down while the request was in flight — a city + // switch disposes the office — and writing people into a disposed layer is a + // use-after-free with a friendly name. + if (office !== scene) return; + scene.setPresence(people); + officePlan?.setPresence(people); + renderOfficeBadge(); +} + // ---- Chrome --------------------------------------------------------------- const nav = document.querySelector("#chapters"); @@ -948,7 +1011,29 @@ function renderCredits() { function renderOfficeBadge() { if (!officeBadge) return; const publicOffice = inside && office !== null && office.depth === "public"; - officeBadge.hidden = !publicOffice; + /** + * The other thing worth saying in this spot, and the more urgent of the two. + * + * The public badge above explains an absence. This one explains a *presence*, + * which is the direction that can actually mislead somebody: twenty-five + * invented names sitting at real desks look exactly like a staff list, and the + * only thing standing between that and a screenshot presented as one is this + * sentence. `sample.ts` says the same thing to a reader of the source; this + * says it to the person looking at the room. + * + * It is not suppressed on a real deployment whose API happened to be down, + * because that is precisely the case where it is needed: a `/presence` that + * did not answer falls back to the fabricated roster, and a member who is not + * told that will read invented people as their colleagues. + */ + const sampleOffice = inside && office !== null && office.depth === "full" && presenceIsSample; + officeBadge.hidden = !publicOffice && !sampleOffice; + if (sampleOffice) { + officeBadge.replaceChildren( + document.createTextNode("Sample occupancy — these people are invented."), + ); + return; + } if (!publicOffice) return; officeBadge.replaceChildren( document.createTextNode("Public view — the building, not the people. "), diff --git a/src/server/wire.ts b/src/server/wire.ts index e8b33ff..314ba0c 100644 --- a/src/server/wire.ts +++ b/src/server/wire.ts @@ -25,12 +25,13 @@ * | `GET /weather` | `WeatherBody` | yes | * | `GET /markers` | `MarkersBody` | yes | * | `GET /offices/:id` | `OfficeDoc` | public offices only | + * | `GET /offices/:id/presence` | `PresenceBody` | never | * * See CONTRACT.md §5. */ import type { Marker } from "../engine/types.ts"; -import type { Office } from "../interiors/types.ts"; +import type { Office, Presence } from "../interiors/types.ts"; /** Path prefix every route lives under. Stated here so both sides read it once. */ export type ApiBase = "/api/v1"; @@ -276,6 +277,27 @@ export interface OfficeDoc { updated?: string; } +/** + * Who is in one office, right now. + * + * Separate from `OfficeDoc` on purpose, and the separation is load-bearing + * rather than tidy. A `Presence` carries a `seatId` and no coordinate precisely + * so that the geometry can be published while the people are not; putting the + * roster inside the pack would mean an operator who wants a public floorplan has + * to strip the people out of it by hand, and the first time they forget, the + * leak is permanent. Two documents means the safe thing is the default thing. + * + * It is also the honest shape for the data: a floorplan changes when somebody + * moves a wall and a roster changes when somebody sits down, which is why this + * one carries `observedAt` and the pack does not. + */ +export interface PresenceBody { + officeId: string; + people: Presence[]; + /** ISO-8601. Absent when the source did not say, which is not an error. */ + observedAt?: string; +} + /** * `private` means the endpoint answers 404 to anyone who may not see it — see * `ErrorBody`. `unlisted` is served to anybody with the id but never appears in