1
0

A plan view in the corner, a night you can actually see, and three kinds of visitor

The right half of the screen was empty sky. It holds the board now, drawn flat,
with the footprint of the camera's own frustum on it — the one part of a minimap
that earns its place, because it answers "where am I looking from" without
leaving the shot. Click it, drag it, scroll it. It is a 2D canvas rather than a
second WebGL context, cached per city and redrawn only when something moved.

Night was black. Not dark — black: at 3 a.m. the coastline, the hills and the
bay were one shape, and the frame read as a failed render rather than as
darkness. The sky already had a floor for exactly this reason and nothing did
the equivalent for the ground, so the ground has one now. The moon still has to
be worth computing, so the gap between a moonlit night and a moonless one is
preserved rather than filled in.

Three tiers, resolved once in the new src/access.ts: anonymous, signed in,
admin. Anonymous gets the map and a public office — the shell, the furniture,
the named viewpoints, nobody home — built without the private objects rather
than with them hidden, because scene.traverse makes hiding a leak with a bow on
it. The time scrubber and the debug readouts are admin only, and admin is
granted by TERA_ADMIN_SUBJECTS on the server and inferred nowhere else. An
unreachable API means member, never god: the promise is "clone it and it works",
not "clone it and you are an administrator of a deployment you did not
configure".

Three things this run found and fixed rather than shipped:

  - entryUrl came off the wire and went straight into an href with no scheme
    check, and a CSP of script-src 'self' 'unsafe-inline' does not stop a
    javascript: URL from navigating. One rejection point in access.ts now.
  - A 5xx from /health was the same null as "no API at all" and therefore the
    opposite conclusion. Eight seconds of tera-api restarting would have told
    every anonymous visitor they were a member. A 5xx is an answer; it fails
    closed.
  - decodeURIComponent in cookieToken was the one path in auth/index.ts that
    threw rather than returning ANONYMOUS, so one malformed cookie header from
    an unauthenticated caller turned /api/v1/session into a 500.

Also: keyboard shortcuts, focus rings, a boot state instead of a blank 2.3
seconds, a collapsible panel under 900px, and no horizontal overflow at 375,
768, 1440 or 2560.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-05 22:53:30 -07:00
parent 47faec9f9d
commit 5bc7258753
24 changed files with 3982 additions and 211 deletions
+301
View File
@@ -0,0 +1,301 @@
/**
* What this visitor may do — resolved once at boot, read everywhere after.
*
* There are three kinds of person in front of this map. Someone who has not
* signed in (**anon**) gets the public city and a public office: the shell, the
* furniture, the named viewpoints, nobody home. Someone signed in (**member**)
* gets the live feeds and the people in the room. An administrator (**god**)
* gets those plus the instruments — the time scrubber and the debug readouts —
* which are development tools that happen to be shipped.
*
* This module exists so `main.ts` never has to think about auth again. Before
* it, the app carried two loose booleans (`canEnterOffice`, `signInUrl`) and the
* rule that produced them was inline in the boot path; every new capability
* meant another boolean and another chance to get the rule subtly wrong. One
* function, one value, one place to read the reasoning.
*
* ## These capabilities are UI, not security
*
* Say it plainly, because the shape of this file invites the opposite reading:
* **nothing here is a boundary.** It is a set of decisions about what to draw.
* Anyone can open the console and set `can.liveData` to true.
*
* The two halves of that are genuinely different and it matters which is which:
*
* - `timeControl` and `debug` are *purely* client-side. Scrubbing the clock
* changes a `Date` that is fed to `observe()` in this browser and moves a sun
* this browser is drawing. There is no server to enforce anything against, so
* hiding the control here **is** the whole enforcement, and that is fine and
* honest: the worst a determined visitor achieves is a sunset at 2 p.m. on
* their own screen. Nothing leaks.
*
* - `liveData` and `officeDepth` are **not** enforced here even slightly. The
* API returns nothing — no markers, and a 404 rather than a 403 for a private
* office pack (CONTRACT.md §6) — to a caller it does not recognise. That
* refusal is the security. What this module does is stop the app from asking
* for something it will not get and from rendering an empty room as though it
* were an empty office. It is a convenience laid on top of a server-side rule,
* never a substitute for one. If you are ever tempted to move an access check
* *out* of the API and into here because it is easier, that is the moment this
* file has been misread.
*
* ## Why an unreachable API means `member` and not `god`
*
* A clean clone with no server is the repo's flagship case (CONTRACT.md §0) and
* it has to be a good experience, so it gets `member`: live-shaped UI over the
* bundled sample data, the whole office, no sign-in prompt for a door that does
* not exist. It deliberately does **not** get `god`. The self-host promise is
* "clone it and it works", not "clone it and you are an administrator of a
* deployment you did not configure" — and the difference stops mattering only
* until someone puts a static build in front of an API they do not control, at
* which point a client that awards itself godmode whenever it cannot reach the
* server has turned a network failure into a privilege escalation.
*
* Godmode comes from an explicit server-side grant. Always. Absence of an answer
* is not an answer.
*/
import { authFetch } from "./session.ts";
/** Where the API lives, per CONTRACT.md §5. Same-origin, behind the site's own proxy. */
const BASE = "/api/v1";
/**
* How long either probe may take before it counts as no answer.
*
* Boot awaits this, so an unbounded wait is not "eventually correct", it is a
* map that never appears. A black-holed port — a firewall dropping packets
* rather than refusing the connection — hangs `fetch` indefinitely, and that is
* exactly the deployment mistake most likely to be made by the person this
* timeout protects.
*/
const TIMEOUT_MS = 4000;
export type Tier = "anon" | "member" | "god";
export interface Capabilities {
/** Step into the office at all. True for everyone; `officeDepth` is what differs. */
enterOffice: boolean;
/** "public" = shell, furniture and named views, nobody home. "full" = presence and occupants. */
officeDepth: "public" | "full";
/** Scrub the clock and the date. God only — see the note about why this is honest. */
timeControl: boolean;
/** Live markers and live flights rather than the fabricated sample set. */
liveData: boolean;
/** Debug overlays: frame time, draw calls, chapter poses, the solar readout. */
debug: boolean;
}
export interface Access {
tier: Tier;
subject: string | null;
/** Where to send someone who is not signed in. `null` means this deployment has no door. */
signInUrl: string | null;
can: Capabilities;
}
/**
* The table. One place, so "what does a member actually get?" is answered by
* reading five lines rather than by grepping for `tier ===` across the app.
*
* `enterOffice` is true for all three on purpose. An earlier cut of this made
* the office a members-only destination and the anonymous view of the site was
* a map with a greyed-out button on it — the single most interesting thing this
* project does, visible only as something you cannot have. The public office is
* the same room with the occupancy layer off, and it costs nothing to show,
* because the floorplan is a data file in this bundle and not a secret.
*/
export function capabilitiesFor(tier: Tier): Capabilities {
return {
enterOffice: true,
officeDepth: tier === "anon" ? "public" : "full",
timeControl: tier === "god",
liveData: tier !== "anon",
debug: tier === "god",
};
}
/**
* Ask the deployment what it is, then ask it who you are.
*
* **The rule is the auth mode, not the presence of a login form.** This is a
* bug that has already been fixed once in this repo and the way it was written
* is worth keeping in front of anyone editing this function. The old line was:
*
* canEnterOffice = s.authenticated || !s.passwordLogin;
*
* which reads as "if this box cannot sign anyone in, it must be open". True for
* `auth: none`. Dangerously false for `sso` and `jwt`, where `POST
* /api/v1/session` is 404 precisely *because* credentials are issued somewhere
* else — so on an SSO deployment that line handed every anonymous visitor the
* private view while the config still said the deployment was private.
*
* So the mode comes from `/api/v1/health`, which already reports it, and only
* `none` means open. Everything else is a private deployment and has to be told
* affirmatively who you are.
*
* The two failure paths land in deliberately different places, and the asymmetry
* is the entire point:
*
* - **No answer from `/health`** — no API, no deployment-level auth to honour,
* the self-host default. `member`, no sign-in link.
* - **`/health` answered and named a mode, then `/session` failed** — this is a
* configured private deployment having a bad minute. Fail *closed*: `anon`.
* An API that has already told you it has auth is not an API you may assume is
* open.
*/
export async function resolveAccess(fetcher: typeof fetch = authFetch): Promise<Access> {
const health = await getJson<{
auth?: { mode?: unknown; entryUrl?: unknown };
}>(fetcher, "/health");
// Something is mounted at `/api/v1` and it is unwell. That is not the same
// fact as "there is no API", and collapsing the two is how a deployment that
// says it is private comes up open: `tera-api` restarts, Caddy answers 502 for
// the eight seconds it takes, and every anonymous visitor in that window would
// otherwise be told they are a member — badge, full-depth office, and a
// markers request the server is about to refuse anyway. A 5xx is an answer,
// so it is treated like a failed `/session`: closed, and no sign-in link,
// because we do not yet know which door this deployment uses.
if (health.kind === "broken") return access("anon", null, null);
// Nothing answered. Clone-and-run: full experience, no door, no godmode.
if (health.kind === "gone") return access("member", null, null);
const body = health.body;
const mode = typeof body.auth?.mode === "string" ? body.auth.mode : "none";
const entryUrl = entryHref(body.auth?.entryUrl);
// A box with auth switched off is a self-host that chose to stay open. Same
// deal as no API at all, and for the same reason it is `member` and not `god`.
if (mode === "none") return access("member", null, null);
const fetched = await getJson<{
authenticated?: unknown;
subject?: unknown;
passwordLogin?: unknown;
admin?: unknown;
}>(fetcher, "/session");
// Every way `/session` can fail is the same way here — this deployment has
// already said it has auth, so anything short of an affirmative answer is
// `anon`. The three-way split above exists for `/health`, where the question
// is whether there is an API at all; by this line that question is settled.
const session = fetched.kind === "ok" ? fetched.body : null;
const authenticated = session !== null && session.authenticated === true;
const passwordLogin = session !== null && session.passwordLogin === true;
/**
* Read defensively, because `admin` is newer than some servers this client
* will meet. A deployment that has not been updated omits the field, `typeof`
* says `undefined`, and its signed-in users are members — which is the only
* safe direction for a missing field to fall. Never infer godmode from
* silence; see the module header.
*/
const admin = session !== null && typeof session.admin === "boolean" ? session.admin : false;
const subject = session !== null && typeof session.subject === "string" ? session.subject : null;
/**
* The door, in order of how likely it is to actually work.
*
* `entryUrl` is the identity provider naming itself, so it wins. Otherwise the
* local form, but *only* on a server that said it can process one: `login.html`
* ships in this bundle and so is never a 404, which makes the failure mode
* worse rather than better — a page that renders, takes an email and a
* password, and posts them to an endpoint that answers 404 because this
* deployment issues credentials elsewhere. An inert state that says "sign in
* required" is more honest than a form that cannot succeed.
*
* A `/session` that did not answer counts as no local form for the same
* reason. `GET /session` is public and always answers on a healthy box; if it
* did not, the login POST is not going to fare better.
*/
const signInUrl = entryUrl ?? (passwordLogin ? "/login.html" : null);
if (!authenticated) return access("anon", null, signInUrl);
return access(admin ? "god" : "member", subject, signInUrl);
}
function access(tier: Tier, subject: string | null, signInUrl: string | null): Access {
return { tier, subject, signInUrl, can: capabilitiesFor(tier) };
}
/**
* The three answers a request to `/api/v1` can carry, which is one more than
* this used to have.
*
* `null` for everything was the right shape while the only question was "is
* there an API". It stopped being the right shape once the answer decided
* whether an anonymous visitor is a member: a 502 while `tera-api` restarts and
* a bare static host with no API behind it are the same `null` and the opposite
* conclusion. So there are three, and no more than three — a 404 and a DNS
* failure still land together, because no caller branches on the difference.
*/
type Fetched<T> =
/** 2xx, JSON, parsed. */
| { kind: "ok"; body: T }
/** Nothing is mounted here: transport failure, 404, or a static host's HTML shell. */
| { kind: "gone" }
/** Something is mounted here and it is failing: 5xx. */
| { kind: "broken" };
/**
* One GET, sorted into one of the three.
*
* Still deliberately coarse, in the spirit of `adapters/http.ts`: a timeout, a
* CORS refusal and a DNS failure are all `gone`, and a taxonomy of failures
* nobody reads is a taxonomy nobody maintains. The one distinction that earns
* its keep is 5xx, because it is the only status that means "the thing exists".
*
* The content-type check is not pedantry. A static host serving this bundle
* answers an unknown path with `index.html` and a 200, so without it `/health`
* "succeeds", `res.json()` throws on a `<!doctype html>`, and the throw happens
* to land in the right place — which is a correct outcome arrived at by
* accident. Checking makes it a decision.
*/
async function getJson<T>(fetcher: typeof fetch, path: string): Promise<Fetched<T>> {
try {
const res = await fetcher(`${BASE}${path}`, {
signal: AbortSignal.timeout(TIMEOUT_MS),
headers: { accept: "application/json" },
});
if (res.status >= 500) return { kind: "broken" };
if (!res.ok) return { kind: "gone" };
if (!(res.headers.get("content-type") ?? "").includes("json")) return { kind: "gone" };
return { kind: "ok", body: (await res.json()) as T };
} catch {
// Includes a body that claimed JSON and was not. A malformed answer from a
// live server is closer to a broken server than to an absent one, but it is
// indistinguishable here from a socket that died mid-read, and `gone` is
// what the zero-config case needs. The status check above is the line that
// actually catches a sick API.
return { kind: "gone" };
}
}
/**
* `entryUrl` as something safe to put in an `href`.
*
* It arrives from `/api/v1/health`, which is to say from whatever this browser
* is pointed at, and it lands in `a.href` in two places in `main.ts`. A CSP of
* `script-src 'self' 'unsafe-inline'` — which is what `deploy/STATIC.md`
* recommends and what the Lumbridge vhost serves — does **not** block a
* `javascript:` URL from navigating, so an operator who pastes an untrusted
* `TERA_AUTH_ENTRY_URL`, or an API that has been taken over, gets script
* execution in the origin where the sso bearer token lives.
*
* Rejecting it once here beats validating at each sink, and the accepted set is
* deliberately narrow: an absolute `http`/`https` URL, or a path on this origin.
* Anything else — `javascript:`, `data:`, `blob:`, a protocol-relative `//host`
* that silently leaves the origin — is not a sign-in page, and the honest
* outcome for a deployment whose door is unusable is no door at all.
*/
function entryHref(raw: unknown): string | null {
if (typeof raw !== "string" || raw === "") return null;
try {
const url = new URL(raw, window.location.origin);
if (url.protocol !== "https:" && url.protocol !== "http:") return null;
return url.href;
} catch {
return null;
}
}