/** * 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; /** * Read this deployment's **real** device route, rather than the simulator * bundled in the tab. * * `false` for an anonymous visitor, and that is not a restriction on what * they see — it is what makes the studio work for them at all. The route is * `members`-only and answers 401 to an anonymous GET, which is correct: the * mics and the camera in it are hardware in somebody's room. What was wrong * was the *client*, which asked anyway. `serverHasDevices` was the only gate * on the API strategy, so on the production box — where `/health` reports * `devices: "sim"` — an anonymous visitor took the API path, was refused, * rendered every instrument permanently at rest, and backed off exponentially * against a request it could never pass. The panel beside it promised a * locally simulated studio. * * So this is the second half of a decision that already had a first half. * `Feeds.devices` asks "has this deployment got a device source at all"; this * asks "may *this viewer* read it". Both have to be true before a request is * worth making, and when either is false the answer is the same and it is a * good one: run the fixed-step simulator in this tab, which is what * `adapter.ts`, `routes/devices.ts` and `devicePanel.ts` have all documented * as the anonymous experience since they were written. */ liveDevices: boolean; /** Debug overlays: frame time, draw calls, chapter poses, the solar readout. */ debug: boolean; } /** * Which of the 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; /** * Device state for the studios. * * `false` on a zero-config box and on every clone, which is the default and * is not a gap: `src/devices/adapter.ts` reads this and runs the bundled * simulator in the tab instead, so the studio is alive either way. What the * flag actually prevents is a poll against a box that will answer 404 to all * of it, forever, on every open tab. */ devices: boolean; /** * A satellite catalogue. Off on almost every box, including this repo's own * default — see `loadSatellites` in the server's `config.ts` for why a clone * does not start pulling CelesTrak the moment it boots. */ satellites: boolean; markers: boolean; /** * A wildfire projection, from `TERA_FIRES_SOURCE`. * * Off on a clone and off on this repo's own default, which is the important * half: a board with no fire feed behind it must draw nothing and say nothing * rather than poll `/fires` every ten minutes forever to be told the same * empty body. There is deliberately no `can.` twin — a wildfire is a public * agency record and an account cannot grant you one — and there is * deliberately no synthetic fallback anywhere beneath it. An invented * aeroplane is a plausible aeroplane; an invented fire is a claim that a named * place is burning, made to somebody who may live there. */ fires: boolean; /** * The two sky projections, from `TERA_RADAR_SOURCE` and `TERA_BIRDS_SOURCE`. * * **Optional, unlike every field above**, and that is the one thing to * understand about them: `sources.radar` and `sources.birds` are newer than * some servers this client will meet, and `undefined` has to mean the same as * `false` — do not ask. A required boolean here would have read a body from an * older box as a definite "no", which is the same answer by luck rather than * by construction, and would have made every existing `Feeds` literal in the * tests a compile error for no gain. * * Both are off on this repo's default and on every clone, and both stay * honest when they are: `promoteRadar(null)` and `promoteBirds(null)` write * "no feed is configured — that is a fact about this box, not about the sky", * which is the sentence a blank panel could not say. */ radar?: boolean; birds?: 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; /** * Every demotion this deployment made, in the server's own words. * * `/api/v1/health` has carried this since the config learned to demote rather * than to die (CONTRACT.md §5.1), and until now **nothing in `src/` read * it**: it was built, served, logged and then dropped on the floor by the one * consumer that could put it in front of a person. So an operator whose * `TERA_WEATHER_CONTACT` was missing saw a permanently clear sky, with the * sentence explaining exactly that sitting in a JSON body one fetch away. * * It rides along here because this module has already paid for the round * trip — `/health` is the first thing boot asks for — and a second identical * GET to read a different field of the same body is a request nobody needs to * make. Empty on a fully-configured box, and empty when nothing answered: * a deployment that does not exist has not demoted anything. * * The interface shows it to admin-tier viewers. It names environment * variables and internal source ids, which is diagnostic detail rather than a * secret — but it is also noise to everybody who cannot act on it. * * **Optional in the type and always present in practice**: every `Access` * `resolveAccess` returns carries one, empty when there is nothing to report. * The `?` is there only so that a hand-written pre-boot literal — the closed * default `main.ts` holds before `resolveAccess()` settles — does not have to * restate an empty array to keep compiling. Read it as `access.degraded ?? []` * and the two cases are the same case. */ degraded?: string[]; } /** * 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, // The one feed where an anonymous "no" is better than an anonymous "ask". // See the field. The refusal is real, it is correct, and the simulator on // the other side of it is a working studio rather than a consolation. liveDevices: 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 { const health = await getJson<{ auth?: { mode?: unknown; entryUrl?: unknown }; sources?: unknown; degraded?: 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); const degraded = degradedFrom(body.degraded); // 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, degraded); 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, degraded); return access(admin ? "god" : "member", subject, signInUrl, feeds, degraded); } function access( tier: Tier, subject: string | null, signInUrl: string | null, feeds: Feeds | null = null, degraded: string[] = [], ): Access { return { tier, subject, signInUrl, can: capabilitiesFor(tier), feeds, degraded }; } /** * `/health`'s `sources` block, read as one yes/no answer per feed. * * 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"), satellites: wired("satellites"), markers: wired("markers"), devices: wired("devices"), fires: wired("fires"), radar: wired("radar"), birds: wired("birds"), }; } /** * `/health`'s `degraded` block, read as sentences. * * Filtered rather than cast, and for a reason beyond tidiness: these strings go * into the interface, so a body carrying numbers, objects or `null` in that * array would put `[object Object]` in front of an operator who is already * looking at this list because something is wrong. Anything that is not a * string is not a sentence and is dropped. * * Bounded as well. The list is one line per demotion and a fully-configured box * has none, so a body with thousands in it is a server this client should not * be rendering unboundedly — the same disposition `presence/store.ts` takes to * a roster with fifty thousand rows in it. */ function degradedFrom(raw: unknown): string[] { if (!Array.isArray(raw)) return []; return raw.filter((line): line is string => typeof line === "string").slice(0, MAX_DEGRADED); } /** More demotions than any real configuration can produce. See `degradedFrom`. */ const MAX_DEGRADED = 32; /** * 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; } }