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