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
Check · Review · Land · Live done — Landed and live in production.
mason/parley:mason/19-no-private-view-is-ever-cached-private-n → main 1 commit fork 1 thread · 1 unresolved
AGENTS.md +3 −1
| @@ -71,7 +71,9 @@ To add a concept: | ||
| 71 | 71 | text. A request body is capped by the bytes actually read (`readBody`), |
| 72 | 72 | never by a Content-Length. `HEAD` answers like `GET`. A private thing |
| 73 | 73 | 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. | |
| 75 | 77 | - Speed: the first paint is server-rendered HTML; JavaScript is small, in |
| 76 | 78 | `static/`, linked by content (`asset()`) and cached for a year; no |
| 77 | 79 | 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: | ||
| 55 | 55 | "Sign in" is `/__forge/signin?to=<path>` and "Sign out" is |
| 56 | 56 | `/__forge/signout`; the forge answers both on the app's own host. |
| 57 | 57 | |
| 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 | ||
| 58 | 67 | ## Any interface |
| 59 | 68 | |
| 60 | 69 | 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> = { | ||
| 23 | 23 | : {}), |
| 24 | 24 | }; |
| 25 | 25 | |
| 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> = { | |
Retraction first: the comment I posted here minutes ago, about Approved. This is the small, exact change the issue asked for, and the preview shows why it matters. What I verified:
One follow-up, not a blocker and explicitly out of this issue's scope ("touch nothing else in The back-button cost is stated in the pull, the README and |
||
| 35 | "cache-control": "private, no-store", | |
| 36 | vary: "x-forge-identity, x-forge-signature, cookie", | |
| 37 | }; | |
| 38 | ||
| 26 | 39 | export const escape = (s: string): string => |
| 27 | 40 | s.replace( |
| 28 | 41 | /[&<>"']/g, |
| @@ -42,20 +55,39 @@ export const html = ( | ||
| 42 | 55 | headers: { |
| 43 | 56 | "content-type": "text/html; charset=utf-8", |
| 44 | 57 | ...SECURITY, |
| 58 | ...PRIVATE, | |
| 45 | 59 | ...headers, |
| 46 | 60 | }, |
| 47 | 61 | }); |
| 48 | 62 | |
| 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 => | |
| 50 | 68 | new Response(JSON.stringify(data), { |
| 51 | 69 | 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 | }, | |
| 53 | 76 | }); |
| 54 | 77 | |
| 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 => | |
| 56 | 83 | new Response(body, { |
| 57 | 84 | 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 | }, | |
| 59 | 91 | }); |
| 60 | 92 | |
| 61 | 93 | /** Back to the page, with a word for it to show once (`readNotice`). */ |
| @@ -65,6 +97,7 @@ export const back = (notice?: string): Response => | ||
| 65 | 97 | headers: { |
| 66 | 98 | location: "/", |
| 67 | 99 | ...SECURITY, |
| 100 | ...PRIVATE, | |
| 68 | 101 | ...(notice === undefined |
| 69 | 102 | ? {} |
| 70 | 103 | : { "set-cookie": cookie("notice", encodeURIComponent(notice), 30) }), |
| @@ -192,7 +225,10 @@ export function allow(key: string, now = Date.now()): boolean { | ||
| 192 | 225 | |
| 193 | 226 | // Files under ./static are served as they are. A page links each one by |
| 194 | 227 | // 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. | |
| 196 | 232 | const STATIC = join(import.meta.dir, "..", "static"); |
| 197 | 233 | const versions = new Map<string, string>(); |
| 198 | 234 | export function asset(name: string): string { |
src/server.test.ts +51 −1
| @@ -2,6 +2,7 @@ import { afterAll, expect, test } from "bun:test"; | ||
| 2 | 2 | import { mkdtempSync } from "node:fs"; |
| 3 | 3 | import { tmpdir } from "node:os"; |
| 4 | 4 | import { join } from "node:path"; |
| 5 | import { text } from "./http.ts"; | |
| 5 | 6 | import { serve } from "./server.ts"; |
| 6 | 7 | import { sign } from "./who.ts"; |
| 7 | 8 | |
| @@ -226,6 +227,49 @@ test("stands on the security floor", async () => { | ||
| 226 | 227 | ).toContain("default-src"); |
| 227 | 228 | }); |
| 228 | 229 | |
| 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 | ||
| 229 | 273 | test("a body is capped by the bytes read, whatever it claims", async () => { |
| 230 | 274 | const response = await api( |
| 231 | 275 | "/notes", |
| @@ -269,9 +313,15 @@ test("static files are cached for a year when linked by content", async () => { | ||
| 269 | 313 | const href = /href="(\/static\/app\.css\?v=[0-9a-f]{8})"/.exec(page)?.[1]; |
| 270 | 314 | expect(href).toBeDefined(); |
| 271 | 315 | 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 | ); | |
| 273 | 321 | expect( |
| 274 | 322 | (await fetch(base + "/static/app.css")).headers.get("cache-control"), |
| 275 | 323 | ).toBe("no-cache"); |
| 276 | 324 | 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")); | |
| 277 | 327 | }); |
Read every line of
src/protocol.tsandsrc/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 fromworld.ts/db.ts/live.ts/http.ts, strides and scales in one named block, named failures instead of throws on the decode path,parseSteercapping bytes before it parses and never handing aNaNdownstream. No secrets, no shell, no SQL, noeval, 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 bareMath.round(finite(value) * scale)andtoFieldByte(src/protocol.ts:123) with a bare128 + 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+12encodes 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 of1e9comes back as2560instead of the clamped8191.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 = 12decodes to exactlyFIELD_MAXandfield = -20to exactly-FIELD_MAX, and a position of1e9decodes to32767 / POSITION_SCALEand-1e9to-32768 / POSITION_SCALE— fourtoBeassertions, each of which fails the moment a clamp goes. Note when you write it that a saturated negative field byte decodes to exactly-FIELD_MAXbut the byte range is asymmetric around 128, so assert the endpoint value, notMath.abs(...) <= FIELD_MAXfor 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 onMOTE_STRIDE; if they are session-lifetime counters, #9 needs to know to fold them. Also:parseSteerappliesSTEER_MAX_BYTESonly 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
parseSteerrejection is asserted by name. Fix the clamp tests and this lands.