1
0

Signing in puts people in the building

`member` and `anon` were told apart inside the office by the client and by
nothing else. `access.ts` picked an `officeDepth`, `createOfficeScene` built a
presence layer at full depth — and then nobody ever called `setPresence`, so
both tiers rendered the identical empty room. A tier that changes nothing you
can see is not a tier, and `routes/markers.ts` had already written down why one
drawn only in the browser is worse than none: it is a UI hiding a control over a
body the API hands to whoever asks.

So the refusal happens on the server now.

`GET /api/v1/offices/:id/presence` is the one route that always takes a session,
whatever else the deployment is configured for. `markers.ts` serves its feed to
anonymous callers when no feed is configured, on the grounds that there is
nothing there to protect; that reasoning does not transfer, and the difference is
the whole point — a marker is a company at an address and a presence is a person
at a desk.

The ordering inside the handler is the security property, not a detail. It
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 404-for-unknown against 401-for-known tells them apart
perfectly, which is the enumeration oracle CONTRACT.md §6 forbids, wearing a
different status code. Three requests and one `deepEqual` hold that down.

`TERA_PRESENCE_DIR` is a second directory rather than a `people` field on the
pack, and that is the design. `types.ts` says a `Presence` binds to a `seatId`
and never to a coordinate so the geometry can be published while the people
cannot — which buys nothing if both live in one file, because an operator who
wants a public floorplan then has to strip the roster out by hand, and the first
time they forget the leak is permanent. Two directories makes the safe thing the
default thing. An office with no roster is 200 and empty, never 404: "no such
office" and "nobody has told me who is in this one" are different problems with
different fixes, and one 404 sends an operator after the wrong one.

On the client, occupancy arrives after the room is on screen rather than before —
the building is worth looking at while a second request is in flight. An API that
answers is believed, including when it answers with nobody; an office where
everyone has gone home is a real fact and overwriting it with invented people to
liven up the demo is the one thing this must never do. An API that does not
answer falls back to a fabricated roster, exactly as the markers do, 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.

Those twenty-five people are invented and the page says so. `sample.ts` says it
to a reader of the source; `#office-badge` now says "Sample occupancy — these
people are invented" to the person looking at the room, and it is not suppressed
when a real deployment's API merely happened to be down — that is exactly the
case where a member would otherwise read invented names as their colleagues.
Fabricated names at real desks look like a staff list, and a screenshot of one
must not be possible to take without the caption.

The floor plan marks the occupied desks, one colour for everybody where the
scene has four: at three device pixels a hue is a guess. The plan answers "is
anyone there" and the room answers "who, and what are they doing". Hovering a
desk names them, and the readout reads as an address getting more specific —
metres, then room, then person.

server: 127 tests pass, 11 of them new. Client typechecks and builds; the office
chunk absorbed the plan renderer and the entry chunk moved 2.3 kB for the sample
roster. Checked in the browser at office.lumbridgecorp.com: FULL VIEW, the badge,
figures at the benches, dots on the plan, and "3.7, 16.7 m · Alcatraz ·
Clementine Roux" under the pointer.
This commit is contained in:
2026-08-06 01:58:22 -07:00
parent df534c3530
commit 3e9b97ed8b
13 changed files with 856 additions and 5 deletions
+1
View File
@@ -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:-}"
+2
View File
@@ -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 `<id>.json` per office. Empty means no offices. |
| `TERA_PRESENCE_DIR` | *(empty)* | One `<officeId>.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`. |
+2
View File
@@ -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) => {
+7
View File
@@ -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,
};
+128
View File
@@ -0,0 +1,128 @@
/**
* Who is in the building, off disk.
*
* `TERA_PRESENCE_DIR` holds one `<officeId>.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<PresenceBody>;
}
export function createPresenceStore(dir: string): PresenceStore {
return {
async get(officeId: string): Promise<PresenceBody> {
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<Presence>;
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 } : {}),
};
}
+80
View File
@@ -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);
});
}
+3
View File
@@ -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(),
};
+285
View File
@@ -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<string, string>) {
const config = loadConfig({
TERA_OFFICES_DIR: offices,
TERA_PRESENCE_DIR: presence,
...env,
});
config.logLevel = "silent";
return buildApp(config);
}
function hs256(claims: Record<string, unknown>): 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<PresenceBody>();
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<PresenceBody>();
// 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<PresenceBody>();
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<PresenceBody>().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<PresenceBody>().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<PresenceBody>().people, []);
});
});
+24
View File
@@ -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<OfficeDoc | null>;
/**
* 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<PresenceBody | null>;
}
/**
@@ -323,6 +334,19 @@ export function createTeraClient(options: TeraApiOptions = {}): TeraClient {
},
office: (id) => get<OfficeDoc>(`/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<PresenceBody>(`/offices/${encodeURIComponent(officeId)}/presence`),
};
}
+87
View File
@@ -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<City, "id" | "center" | "bounds">): 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<string, number> = {
in: 0x6f9ec4,
focus: 0xf2b134,
meeting: 0xc4796f,
guest: 0x7fb886,
};
+125
View File
@@ -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<string, string>();
// 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
+89 -4
View File
@@ -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<HTMLElement>("#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. "),
+23 -1
View File
@@ -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