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:
@@ -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, []);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user