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
+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);
});
}