1
0

The sky is not something an account grants you

`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>
This commit is contained in:
2026-08-06 03:36:12 -07:00
parent e41c90fe8d
commit 1db868ad70
6 changed files with 146 additions and 19 deletions
+17 -2
View File
@@ -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
+39 -2
View File
@@ -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,
};
}
+9 -2
View File
@@ -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;
}
+29
View File
@@ -83,6 +83,35 @@ function asMember(app: ReturnType<typeof buildApp>) {
});
}
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);
+37 -3
View File
@@ -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",
};
}
+15 -10
View File
@@ -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;