diff --git a/README.md b/README.md index e0e3a76..4360ac5 100644 --- a/README.md +++ b/README.md @@ -33,9 +33,24 @@ Three tiers, resolved once at boot by `src/access.ts`: | | anonymous | signed in | admin | |---|---|---|---| | the map, the plan view, the named chapters | ✅ | ✅ | ✅ | +| observed weather and live aircraft | ✅ | ✅ | ✅ | | the office | public depth — shell, furniture, viewpoints, nobody home | full depth, with presence | full depth | -| live markers and live traffic | — | ✅ | ✅ | -| the godmode panel (`G`) — clock, weather override, counters | — | — | ✅ | +| the marker feed | per `TERA_MARKERS_ACCESS` | ✅ | ✅ | +| the godmode panel (`G`) — date, season, weather override, counters, pose editor | — | — | ✅ | + +The sky is public on purpose. Cloud cover over San Francisco is a government +sensor reading, and the aircraft are broadcasting their positions unencrypted to +anyone with a receiver; neither is something an account can grant you access to. +Gating them 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. + +The **markers** are the one feed that can carry something private, so the server +decides. `TERA_MARKERS_ACCESS` is `members` by default and an operator has to +say `public` out loud, which `/api/v1/health` then announces in `degraded[]`. +The default is the safe answer rather than the common one, because the failure +mode is silent: nothing errors, nothing looks broken, the data is just readable +by the internet. **These are drawing decisions, not a security boundary**, and `src/access.ts` says so at length. Live data and office presence are withheld by the *API*, from diff --git a/server/src/config.ts b/server/src/config.ts index a84f769..4471c2f 100644 --- a/server/src/config.ts +++ b/server/src/config.ts @@ -58,6 +58,20 @@ export interface MarkersConfig { */ provenanceAllowlist: string[]; ttlSeconds: number; + /** + * Who may read the feed. `TERA_MARKERS_ACCESS`, `members` by default. + * + * The default is the safe answer rather than the common one, deliberately. + * A marker set is the one feed here that can carry something private — a + * company's pipeline, a person's job search — and the failure mode of getting + * it wrong is silent: nothing errors, nothing looks broken, the data is just + * readable by the internet. So an operator who wires real data up gets + * `members` without having chosen it, and has to say `public` out loud to + * publish it. The weather and the aircraft need no such switch, because a + * government sensor reading and an unencrypted ADS-B broadcast are not + * withheld from anyone by anybody. + */ + access: "members" | "public"; } /** @@ -313,11 +327,33 @@ function loadMarkers(env: Env, authMode: AuthMode, degraded: string[]): MarkersC // can sign in has built something no browser will ever see, and one sentence // now is cheaper than an afternoon. `routes/markers.ts` has the reasoning for // why the feed is members-only with no public escape hatch. - if (source === "file" && authMode === "none") { + const askedAccess = str(env, "TERA_MARKERS_ACCESS", "members"); + const access = askedAccess === "public" ? "public" : "members"; + if (askedAccess !== "members" && askedAccess !== "public") { + degraded.push( + `TERA_MARKERS_ACCESS="${askedAccess}" is not one of members, public; ` + + "keeping the feed members-only.", + ); + } + if (source === "file" && access === "public") { + // Not a demotion — a deliberate choice, announced. It goes in `degraded[]` + // because that array is what `/api/v1/health` publishes, and an operator + // reading their own health endpoint should be able to see that this box is + // serving its marker set to the world without going and reading the env + // file. The same reasoning as `TERA_ADMIN_SUBJECTS=*`. + degraded.push( + "TERA_MARKERS_ACCESS=public: the marker feed is served to anonymous " + + "callers. Correct for a public map; wrong for anything private.", + ); + } + // Members-only and nobody can ever be a member is the combination that answers + // 401 to the entire internet, which looks like an outage rather than a policy. + if (source === "file" && access === "members" && authMode === "none") { degraded.push( "TERA_MARKERS_SOURCE=file needs an authentication mode: the marker feed is " + "refused to anonymous callers, and with TERA_AUTH_MODE=none nobody is ever " + - "anything else, so it will answer 401 to everybody. Set TERA_AUTH_MODE.", + "anything else, so it will answer 401 to everybody. Set TERA_AUTH_MODE, or " + + "TERA_MARKERS_ACCESS=public if the map is meant to be open.", ); } @@ -327,6 +363,7 @@ function loadMarkers(env: Env, authMode: AuthMode, degraded: string[]): MarkersC file, provenanceAllowlist: allowlist.length > 0 ? allowlist : DEFAULT_PROVENANCE_ALLOWLIST, ttlSeconds: num(env, "TERA_MARKERS_TTL", 300, degraded), + access, }; } diff --git a/server/src/routes/markers.ts b/server/src/routes/markers.ts index 489101d..52bafd0 100644 --- a/server/src/routes/markers.ts +++ b/server/src/routes/markers.ts @@ -54,9 +54,16 @@ const UNAUTHORIZED: ErrorBody = { export function registerMarkers(app: FastifyInstance, services: Services): void { app.get("/api/v1/markers", async (req, reply) => { - if (services.config.markers.source === "none") { + const { markers } = services.config; + // Two ways to be public and they are not the same fact. `source === "none"` + // is "there is nothing here to protect" — the body is the bundled sample + // set. `access === "public"` is an operator saying this deployment's real + // marker set is meant to be read by anyone. Both end up here; only the + // second one is a decision, and `config.ts` announces it in `degraded[]` + // so it cannot be made silently. + if (markers.source === "none" || markers.access === "public") { const body = await services.markers.current(); - publicCache(req, reply, services.config.markers.ttlSeconds); + publicCache(req, reply, markers.ttlSeconds); return body; } diff --git a/server/src/test/markers.test.ts b/server/src/test/markers.test.ts index d5bfc78..019210c 100644 --- a/server/src/test/markers.test.ts +++ b/server/src/test/markers.test.ts @@ -83,6 +83,35 @@ function asMember(app: ReturnType) { }); } +describe("TERA_MARKERS_ACCESS", () => { + it("defaults to members, so wiring real data up is safe by omission", async () => { + const config = loadConfig({ TERA_MARKERS_FILE: file, ...feedEnv }); + assert.equal(config.markers.access, "members"); + }); + + it("serves anonymous callers when an operator says public out loud", async () => { + const app = appWith({ ...feedEnv, TERA_MARKERS_ACCESS: "public" }); + after(() => app.close()); + + const res = await app.inject({ method: "GET", url: "/api/v1/markers" }); + assert.equal(res.statusCode, 200); + assert.ok(res.body.includes("Ferry")); + // Public means publicly cacheable. The members-only body deliberately is not. + assert.match(res.headers["cache-control"] as string, /max-age/); + }); + + it("announces a public feed in degraded, so health shows it without reading the env", async () => { + const config = loadConfig({ TERA_MARKERS_FILE: file, ...feedEnv, TERA_MARKERS_ACCESS: "public" }); + assert.ok(config.degraded.some((line) => line.includes("TERA_MARKERS_ACCESS=public"))); + }); + + it("keeps the feed shut on a value it does not understand", async () => { + const config = loadConfig({ TERA_MARKERS_FILE: file, ...feedEnv, TERA_MARKERS_ACCESS: "yes" }); + assert.equal(config.markers.access, "members"); + assert.ok(config.degraded.some((line) => line.includes("is not one of members, public"))); + }); +}); + describe("a configured marker feed", () => { it("refuses an anonymous caller", async () => { const app = appWith(feedEnv); diff --git a/src/access.ts b/src/access.ts index 31ea94b..7e85a05 100644 --- a/src/access.ts +++ b/src/access.ts @@ -80,8 +80,37 @@ export interface Capabilities { 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; + /** + * 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; } @@ -143,7 +172,12 @@ export function capabilitiesFor(tier: Tier): Capabilities { enterOffice: true, officeDepth: tier === "anon" ? "public" : "full", timeControl: tier === "god", - liveData: tier !== "anon", + 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", }; } diff --git a/src/main.ts b/src/main.ts index b2528de..d546818 100644 --- a/src/main.ts +++ b/src/main.ts @@ -289,13 +289,18 @@ async function mountCity(id: string) { * The sky and the traffic are per-city and are chosen here, before the build, * because `flights` is fixed at scene construction. * - * Two gates, and both are needed. `can.liveData` is the tier — an anonymous - * visitor must not be firing requests the server is going to refuse — and - * `feeds` is the deployment, which is what stops a member on the ordinary - * box, where every source is `none`, from polling two endpoints forever for - * a 404. The old code gated the flights on `liveData`, the markers flag, - * which is a different feed entirely: a deployment with a real ADS-B receiver - * and no marker file flew the simulator. + * Still two gates, but only one of them is about the visitor now. + * + * `can.liveEnvironment` is true for everybody — the reasoning is in + * `access.ts`, and it comes down to the sky not being a thing an account can + * grant you. What remains load-bearing is `feeds`, the *deployment*: it is + * what stops the ordinary box, where every source is `none`, from polling two + * endpoints forever for a 404 on every tab that is open. + * + * The other half of the old comment is still worth keeping, because it was a + * real bug: the flights used to be gated on the *markers* flag, which is a + * different feed entirely, so a deployment with a real ADS-B receiver and no + * marker file flew the simulator. */ const region = regionOf(entry.city); // The hand-authored corridors for *this* city. `SAMPLE_ROUTES` was passed @@ -303,7 +308,7 @@ async function mountCity(id: string) { // entire sky projected ~590 km off the world and rendered as nothing at all. const routes = sampleRoutesFor(entry.city); const traffic = - access.can.liveData && access.feeds?.flights ? tera.flights(region, routes) : null; + access.can.liveEnvironment && access.feeds?.flights ? tera.flights(region, routes) : null; cityFlights = traffic; const handle = await createScene(stage, { @@ -342,7 +347,7 @@ async function mountCity(id: string) { * replaces it when it lands. */ weatherWatch = - access.can.liveData && access.feeds?.weather + access.can.liveEnvironment && access.feeds?.weather ? tera.watchWeather(entry.city.center, () => { updateSun(); renderSource(); @@ -1434,7 +1439,7 @@ async function boot() { // whether this visitor may ask, `feeds` says whether there is anything to // ask. A box with `markers: "none"` serves the public empty body to everyone, // so the request buys a round trip and lands on the same sample set. - if (access.can.liveData && access.feeds?.markers) { + if (access.can.liveMarkers && access.feeds?.markers) { try { const feed = await tera.markers(); markers = feed.value;