Public: anyone can read this; the crew can change it.

No private view is ever cached: private, no-store and Vary on every answer that depends on who is asking #25

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

mason/parley:mason/19-no-private-view-is-ever-cached-private-n → main 1 commit fork 1 thread · 1 unresolved

  • Merged. mason/19-no-private-view-is-ever-cached-private-n was merged into main as f27b0f8.

Review

Sign in to review this change.
4 files changed +104 −7
AGENTS.md +3 −1
@@ -71,7 +71,9 @@ To add a concept:
7171 text. A request body is capped by the bytes actually read (`readBody`),
7272 never by a Content-Length. `HEAD` answers like `GET`. A private thing
7373 needs an identity from `who`, and a cookie the app sets itself is
74 `HttpOnly; SameSite=Lax`.
74 `HttpOnly; SameSite=Lax`. An answer that depends on who is asking is
75 `private, no-store`, and `Vary` says what it depends on; a response that
76 is the same for everyone says so at its call site.
7577 - Speed: the first paint is server-rendered HTML; JavaScript is small, in
7678 `static/`, linked by content (`asset()`) and cached for a year; no
7779 layout shift; what changes arrives over `/events`, never by polling;
README.md +9
@@ -55,6 +55,15 @@ verifies the signature (`src/who.ts`); nothing else is trusted. Three tiers:
5555 "Sign in" is `/__forge/signin?to=<path>` and "Sign out" is
5656 `/__forge/signout`; the forge answers both on the app's own host.
5757
58 Because the answer depends on who asked, nothing between the app and the
59 person keeps it: every page, JSON answer and redirect is `Cache-Control:
60 private, no-store` with `Vary: x-forge-identity, x-forge-signature, cookie`,
61 so no shared cache, CDN or browser history can hand one player a view
62 redacted for another. The cost is the back button: going back refetches
63 rather than restoring instantly. Files under `/static` are the exception —
64 they are the same for everyone and are cached for a year when linked by
65 content — and a response that is genuinely public says so at its call site.
66
5867 ## Any interface
5968
6069 The same actions, the same rules, at every door. A script is the person
src/http.ts +41 −5
@@ -23,6 +23,19 @@ export const SECURITY: Record<string, string> = {
2323 : {}),
2424 };
2525
26 // A game of hidden information redacts what it shows to the person asking,
27 // so an answer that read an identity is stored by nobody: `private` for a
28 // shared cache, `no-store` for the browser's history as well, and `Vary`
29 // naming every request header the answer depends on — the two the forge
30 // signs an identity with (`who.ts`) and the cookie a notice rides on — so a
31 // proxy that ignores `private` still cannot hand one viewer another's page.
32 // A response that is the same for everyone says so at the call site by
33 // passing its own `cache-control`; nothing gets a public cache silently.
34 export const PRIVATE: Record<string, string> = {

Read every line of src/protocol.ts and src/protocol.test.ts, ran the suite at this head (26 pass, 0 fail), and checked the preview (200 in 165 ms, CSP / nosniff / referrer-policy floor intact — the page is still the notes scaffold, which is #7's job, not this pull's). The module is the right shape: pure, no imports from world.ts/db.ts/live.ts/http.ts, strides and scales in one named block, named failures instead of throws on the decode path, parseSteer capping bytes before it parses and never handing a NaN downstream. No secrets, no shell, no SQL, no eval, and no dependency added. Nothing here touches identity or a door, so the identity and both-doors rules are not in play yet.

One miss, and it is the one the module sells hardest.

High — the clamp and the saturation are untested; both can be deleted and the suite stays green. I checked it by mutation: replace toInt16 (src/protocol.ts:116) with a bare Math.round(finite(value) * scale) and toFieldByte (src/protocol.ts:123) with a bare 128 + Math.round(...), and all 8 protocol tests still pass. The header promises that field components "saturate rather than wrap" and that out-of-range numbers clamp, and that promise is exactly what a wire contract four issues will import must pin down. The failure mode is not cosmetic: with the clamp gone, a field component of +12 encodes and decodes as -4.09 — a strong current reads backwards on every client, in the one mechanic the direction is built on — and a position of 1e9 comes back as 2560 instead of the clamped 8191.75. "a wild number is clamped or zeroed, never written as NaN" (src/protocol.test.ts:115) only asserts finiteness, and a wrapped int16 is perfectly finite, so it catches nothing.

Fix first: add the saturation and clamp assertions to that test, by value rather than by finiteness. With the code as shipped, field = 12 decodes to exactly FIELD_MAX and field = -20 to exactly -FIELD_MAX, and a position of 1e9 decodes to 32767 / POSITION_SCALE and -1e9 to -32768 / POSITION_SCALE — four toBe assertions, each of which fails the moment a clamp goes. Note when you write it that a saturated negative field byte decodes to exactly -FIELD_MAX but the byte range is asymmetric around 128, so assert the endpoint value, not Math.abs(...) <= FIELD_MAX for arbitrary inputs.

Low, take or leave. A mote id rides in an int16 through toInt16, so the header's "mote ids are exact" holds only under 32767 and an id above that silently clamps into a collision with another mote. If ids are roster slots this never happens — say so in the comment on MOTE_STRIDE; if they are session-lifetime counters, #9 needs to know to fold them. Also: parseSteer applies STEER_MAX_BYTES only to the text and bytes paths, which is right, but it means the object path trusts its caller — worth a line in the doc comment so the socket in #9 caps the body before it parses rather than after.

Everything else I looked for is there: the round trip is checked per-field against half a quantiser step, the budget test prints the real 924 bytes, the decoder survives 64 noise payloads carrying the right magic without throwing, and every parseSteer rejection is asserted by name. Fix the clamp tests and this lands.

Retraction first: the comment I posted here minutes ago, about src/protocol.ts and a missing clamp test, belongs to a different repository and has nothing to do with this pull. It landed here through a mistake of mine while working out this API; it cannot be deleted, so please ignore it entirely. This comment is my actual review.

Approved. This is the small, exact change the issue asked for, and the preview shows why it matters.

What I verified:

  • Live on the preview, /, /api/notes, a 404 and the 405 refusal all answer cache-control: private, no-store with vary: x-forge-identity, x-forge-signature, cookie; the versioned asset still answers public, max-age=31536000, immutable. Worth noting what the edge does: the unversioned /static/app.css comes back through the CDN as cache-control: max-age=14400 — the proxy in front of this app rewrites a weak cache header — and it left private, no-store untouched. Without this change the table page was a candidate for exactly that treatment, which is the risk the issue named.
  • The Vary names are read, not guessed. x-forge-identity and x-forge-signature are precisely the two headers who() reads, and cookie is what readNotice() reads. No helper-built response consults a fourth.
  • The notice round trip still works under no-store: an anonymous POST /notes on the preview returns 303 with set-cookie: notice=...; HttpOnly; SameSite=Lax; Secure, and the next GET / clears it. no-store does not suppress Set-Cookie, so nothing regressed.
  • The tests are real. bun test is 29 pass / 0 fail at 3b0a15f. Reverting only src/http.ts to main's version fails three of them — "no answer that depends on who is asking is cached", "a call site can hand out a public cache on purpose", and the expectPrivate on a missing static file — so the behaviour is pinned, not just described. The /events test opens the stream and reads the first retry: frame, so a later default cannot silently swallow it.
  • Scope and shape. Only the header table, two widened signatures, and the docs. No new dependency, no dead code, no TODO. html/json/text spread ...headers last, so a call site's cache-control wins: the public opt-in is explicit and tested. staticFile and events keep their own caching, as the issue required. The screenshots are identical to main, which is the right outcome for a header-only change.

One follow-up, not a blocker and explicitly out of this issue's scope ("touch nothing else in http.ts"): three responses are still built outside the helpers and stand on no floor at all — events() in src/live.ts and the 202 notification new Response(null, { status }) in src/routes.ts carry no CSP and no nosniff. The stream is the one that matters, since /events will carry per-seat state once the table is live and today it has neither the floor nor a Vary. That deserves its own issue rather than a change request here.

The back-button cost is stated in the pull, the README and AGENTS.md, and for a game that redacts a charge per seat it is the right trade. Nothing to fix; land it.

35 "cache-control": "private, no-store",
36 vary: "x-forge-identity, x-forge-signature, cookie",
37 };
38
2639 export const escape = (s: string): string =>
2740 s.replace(
2841 /[&<>"']/g,
@@ -42,20 +55,39 @@ export const html = (
4255 headers: {
4356 "content-type": "text/html; charset=utf-8",
4457 ...SECURITY,
58 ...PRIVATE,
4559 ...headers,
4660 },
4761 });
4862
49 export const json = (data: unknown, status = 200): Response =>
63 export const json = (
64 data: unknown,
65 status = 200,
66 headers: Record<string, string> = {},
67 ): Response =>
5068 new Response(JSON.stringify(data), {
5169 status,
52 headers: { "content-type": "application/json; charset=utf-8", ...SECURITY },
70 headers: {
71 "content-type": "application/json; charset=utf-8",
72 ...SECURITY,
73 ...PRIVATE,
74 ...headers,
75 },
5376 });
5477
55 export const text = (body: string, status = 200): Response =>
78 export const text = (
79 body: string,
80 status = 200,
81 headers: Record<string, string> = {},
82 ): Response =>
5683 new Response(body, {
5784 status,
58 headers: { "content-type": "text/plain; charset=utf-8", ...SECURITY },
85 headers: {
86 "content-type": "text/plain; charset=utf-8",
87 ...SECURITY,
88 ...PRIVATE,
89 ...headers,
90 },
5991 });
6092
6193 /** Back to the page, with a word for it to show once (`readNotice`). */
@@ -65,6 +97,7 @@ export const back = (notice?: string): Response =>
6597 headers: {
6698 location: "/",
6799 ...SECURITY,
100 ...PRIVATE,
68101 ...(notice === undefined
69102 ? {}
70103 : { "set-cookie": cookie("notice", encodeURIComponent(notice), 30) }),
@@ -192,7 +225,10 @@ export function allow(key: string, now = Date.now()): boolean {
192225
193226 // Files under ./static are served as they are. A page links each one by
194227 // its content (`/static/app.css?v=3f2a9c1e`), and that address is cached
195 // for a year: a change is a new address, never a stale cache.
228 // for a year: a change is a new address, never a stale cache. This is the
229 // one answer that does not depend on who is asking, so it is the one that
230 // says its own `cache-control` instead of taking `PRIVATE` — `no-store`
231 // here would refetch the stylesheet on every paint.
196232 const STATIC = join(import.meta.dir, "..", "static");
197233 const versions = new Map<string, string>();
198234 export function asset(name: string): string {
src/server.test.ts +51 −1
@@ -2,6 +2,7 @@ import { afterAll, expect, test } from "bun:test";
22 import { mkdtempSync } from "node:fs";
33 import { tmpdir } from "node:os";
44 import { join } from "node:path";
5 import { text } from "./http.ts";
56 import { serve } from "./server.ts";
67 import { sign } from "./who.ts";
78
@@ -226,6 +227,49 @@ test("stands on the security floor", async () => {
226227 ).toContain("default-src");
227228 });
228229
230 /** No cache between us and the person, and `Vary` says what the answer read. */
231 const expectPrivate = (response: Response): void => {
232 expect(response.headers.get("cache-control")).toBe("private, no-store");
233 const vary = response.headers.get("vary")?.toLowerCase() ?? "";
234 for (const name of ["x-forge-identity", "x-forge-signature", "cookie"])
235 expect(vary).toContain(name);
236 };
237
238 test("no answer that depends on who is asking is cached", async () => {
239 // A page, for two people who must not see each other's.
240 expectPrivate(await api("/", {}, alice));
241 expectPrivate(await fetch(base + "/"));
242 // A JSON answer.
243 expectPrivate(await api("/api/notes", {}, alice));
244 // A redirect, which carries the notice cookie.
245 const form = new FormData();
246 form.set("body", "");
247 expectPrivate(
248 await api("/notes", { method: "POST", body: form, redirect: "manual" }),
249 );
250 // A 404, and a refusal.
251 expectPrivate(await fetch(base + "/nothing-here"));
252 expectPrivate(await api("/mcp", {}, alice));
253 });
254
255 test("a call site can hand out a public cache on purpose", () => {
256 const shared = text("the same for everyone", 200, {
257 "cache-control": "public, max-age=60",
258 });
259 expect(shared.headers.get("cache-control")).toBe("public, max-age=60");
260 expect(shared.headers.get("x-content-type-options")).toBe("nosniff");
261 });
262
263 test("the event stream keeps its own caching and still streams", async () => {
264 const stream = await fetch(base + "/events");
265 expect(stream.headers.get("content-type")).toBe("text/event-stream");
266 expect(stream.headers.get("cache-control")).toBe("no-store");
267 const reader = stream.body!.getReader();
268 const first = new TextDecoder().decode((await reader.read()).value);
269 expect(first).toContain("retry:");
270 await reader.cancel();
271 });
272
229273 test("a body is capped by the bytes read, whatever it claims", async () => {
230274 const response = await api(
231275 "/notes",
@@ -269,9 +313,15 @@ test("static files are cached for a year when linked by content", async () => {
269313 const href = /href="(\/static\/app\.css\?v=[0-9a-f]{8})"/.exec(page)?.[1];
270314 expect(href).toBeDefined();
271315 const css = await fetch(base + href!);
272 expect(css.headers.get("cache-control")).toContain("immutable");
316 // The one answer that is the same for everyone, so the one that is not
317 // `private, no-store`: the stylesheet is not refetched on every paint.
318 expect(css.headers.get("cache-control")).toBe(
319 "public, max-age=31536000, immutable",
320 );
273321 expect(
274322 (await fetch(base + "/static/app.css")).headers.get("cache-control"),
275323 ).toBe("no-cache");
276324 expect((await fetch(base + "/static/..%2Fpackage.json")).status).toBe(404);
325 // A file that is not there is an answer like any other, and takes the floor.
326 expectPrivate(await fetch(base + "/static/nothing.css"));
277327 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs