1db868ad70
`liveData` was one flag meaning two unrelated things, and it was set to `tier !== "anon"`. That reasoning does not survive asking what the data actually is. Cloud cover over San Francisco is a reading from a government sensor. The aircraft are broadcasting their positions, unencrypted, to anyone within range who owns a forty-dollar receiver. Neither is withheld from anybody by anybody, so neither is a thing an account can grant access to — and putting them behind a sign-in cost the only moment that makes this project land: real fog rolling off the Pacific onto a city you recognise, at the real time of day, on a first visit. On an SSO-gated deployment it cost that moment for every visitor there currently is. So `liveData` splits. `liveEnvironment` is public and unconditional. `liveMarkers` is asked for by everyone and granted by the server, because the marker set is the one feed here that can carry something private — a company's pipeline, a person's job search — and whether it is public is a property of the deployment, not of a file in this repo. `TERA_MARKERS_ACCESS` is therefore a server switch and its default is `members`, which is the safe answer rather than the common one. The failure mode of getting this wrong is silent: nothing throws, nothing looks broken, the data is simply readable by the internet. An operator who wires real markers up gets the shut door without having chosen it and has to say `public` out loud — and saying it appends a line to `degraded[]`, so `/api/v1/health` reports that this box is publishing its map without anyone having to go and read the env file. Same reasoning as `TERA_ADMIN_SUBJECTS=*`. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
403 lines
19 KiB
TypeScript
403 lines
19 KiB
TypeScript
/**
|
|
* 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<Access> {
|
|
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<string, unknown>;
|
|
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<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;
|
|
/**
|
|
* 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;
|
|
}
|
|
}
|