/** * 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; /** * The real sky — observed weather and observed aircraft — rather than the * synthetic one. * * **Public, including to a visitor who has not signed in.** It was briefly * `tier !== "anon"`, on the reasoning that live feeds are what an account * buys you. That reasoning does not survive contact with what the data * actually is: the cloud cover over San Francisco is a public observation * from a government sensor, and the aircraft are broadcasting their positions * unencrypted to anyone with a forty-dollar receiver. Neither is a thing an * account can grant you access to, because neither is withheld from anyone. * * What it cost was the only moment that makes this project land — fog rolling * off the Pacific onto a city you recognise, at the real time of day, on a * first visit. Gating that behind a sign-in traded the whole first impression * for a rule with nothing behind it. */ liveEnvironment: boolean; /** * Markers: whatever this deployment has decided its map is *about*. * * Separate from `liveEnvironment` because it is the one feed that can carry * something private. The sky is the same for everybody; a marker set is a * company's pipeline, or a person's job search, and whether it is public is a * property of the deployment rather than of this file. The **server** decides * — `TERA_MARKERS_ACCESS`, which defaults to `members` so that a self-hoster * who wires real data up gets the safe answer without having chosen it — and * this flag only reports what the server already said. Setting it true here * against a server set to `members` earns a 401 and nothing else. */ liveMarkers: boolean; /** Debug overlays: frame time, draw calls, chapter poses, the solar readout. */ debug: boolean; } /** * Which of the three feeds this deployment has actually wired. * * A capability says what a *visitor* may have; this says what the *server* has, * and the app needs both before it opens a socket. `can.liveData` is true for * every member of every deployment, including the overwhelming majority that * have `weather: "none"` — so gating on the capability alone starts a * ten-minute weather poll against a box that will answer 404 to all of it, * forever, on every tab that is open. * * Each field is `true` when `/health` named a source other than `"none"`, which * is deliberately coarser than the string. The app does not care whether the * weather comes from NWS or met.no; it cares whether asking is pointless. * `flights: "sim"` counts as wired, because the server's synchronised plan is * worth fetching even though it is not observed — `TrafficSource.live()` is the * thing that knows the difference, and it says `false` for it. */ export interface Feeds { weather: boolean; flights: boolean; markers: 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; /** * What `/health` said is wired, or `null` when nothing answered — which is * the zero-config case, and means every feed is the bundled sample. * * It rides along here rather than being fetched again by whoever wants it * because this module has already paid for the round trip: `/health` is the * first thing boot asks for, and a second identical GET a moment later to * read a different field of the same body is a request nobody needs to make. */ feeds: Feeds | null; } /** * 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", liveEnvironment: true, // Asked for by everyone; granted by the server or not. See the field's own // note — the client requesting a marker set it may not have is a 401, which // is the correct place for that decision to be enforced and the only place // it can be enforced at all. liveMarkers: true, 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 { const health = await getJson<{ auth?: { mode?: unknown; entryUrl?: unknown }; sources?: 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); const feeds = feedsFrom(body.sources); // 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, feeds); 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, feeds); return access(admin ? "god" : "member", subject, signInUrl, feeds); } function access( tier: Tier, subject: string | null, signInUrl: string | null, feeds: Feeds | null = null, ): Access { return { tier, subject, signInUrl, can: capabilitiesFor(tier), feeds }; } /** * `/health`'s `sources` block, read as three yes/no answers. * * Defensively, like `admin` above and for the same reason: this field is newer * than some servers this client will meet, and a missing one has to fall the * safe way. Here "safe" is `false` — no feed, no request — because the bundled * sample set is a working map and a poll against a server that never heard of * the route is not. */ function feedsFrom(raw: unknown): Feeds { const sources = (typeof raw === "object" && raw !== null ? raw : {}) as Record; const wired = (key: string) => typeof sources[key] === "string" && sources[key] !== "none"; return { weather: wired("weather"), flights: wired("flights"), markers: wired("markers") }; } /** * 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 = /** 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 ``, 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(fetcher: typeof fetch, path: string): Promise> { 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; /** * Before `new URL`, because `new URL` is what hides this one. * * A protocol-relative `//evil.example/login` inherits the page's scheme, so * `url.protocol` comes back `https:` and the check below waves it through — * the comment above listed it among the rejected set and it was not among the * rejected set. It is not the `javascript:` case and it is not script * execution; it is a value an operator pasted, or an API answered with, being * turned into a link off this origin that says "Sign in" on it. A host that * wants to be honoured can write its scheme. */ if (/^\s*\/\//.test(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; } }