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:
+89
-4
@@ -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. "),
|
||||
|
||||
Reference in New Issue
Block a user