@pylonsync/functions 0.3.298 → 0.3.300

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -56,6 +56,12 @@ export interface RenderRouteMessage {
56
56
  tenant_id: string | null;
57
57
  roles: string[];
58
58
  };
59
+ /**
60
+ * Whether the request carried a session cookie (presence, not identity) —
61
+ * surfaced as the identity-free `props.session.exists` (Phase 0 auth
62
+ * bucketing). Default false / absent for back-compat.
63
+ */
64
+ session_present?: boolean;
59
65
  /**
60
66
  * Initial HTTP status the response controller starts at (default 200).
61
67
  * The host sets this to 404 when dispatching a `not-found.tsx` render
@@ -151,6 +157,30 @@ export declare function makeResponseController(state: ResponseState, defaultRedi
151
157
  * Exported for direct unit testing of that property.
152
158
  */
153
159
  export declare function makeReadTrackingProxy(obj: Record<string, unknown> | undefined, onTouch: () => void): Record<string, unknown>;
160
+ /**
161
+ * Deep clone via JSON round-trip — drops functions / proxies / symbols, keeping
162
+ * only JSON-serializable data. Used to SNAPSHOT the bucket-tail props before any
163
+ * page code can mutate them, so a page can't smuggle identity through a nested
164
+ * field of params/searchParams into a shared cache entry. The deep copy is fully
165
+ * independent of the source: a later mutation of the original object can't reach
166
+ * the snapshot.
167
+ */
168
+ export declare function jsonClone<T>(v: T): T;
169
+ /**
170
+ * Revocable form of {@link makeReadTrackingProxy}. The render path wraps each
171
+ * per-request input (auth / headers / cookies / session) in one of these and
172
+ * REVOKES it once the render is done — so a page that stashed the props object
173
+ * (or `props.auth`) in module-level state and read it on a LATER render gets a
174
+ * hard throw instead of silently reading a prior request's identity WITHOUT
175
+ * tripping this render's read-tracking. Revocation is per-render (each render
176
+ * revokes only its own proxies), so it is sound even when the Bun runner
177
+ * multiplexes concurrent renders. Fail-closed: the stale read throws, the render
178
+ * fails, and nothing is cached.
179
+ */
180
+ export declare function makeRevocableReadTrackingProxy(obj: Record<string, unknown> | undefined, onTouch: () => void): {
181
+ proxy: Record<string, unknown>;
182
+ revoke: () => void;
183
+ };
154
184
  export declare function finalizeHeaders(state: ResponseState, extra?: Record<string, string>, internal?: Record<string, string>): Record<string, string>;
155
185
  /**
156
186
  * Phase 1 SSR handler. Resolves the component, renders it via
@@ -305,6 +335,13 @@ export declare function isSafeRedirect(url: string, opts: {
305
335
  * into a page's metadata. Explicit `metadata.icons.*` wins. */
306
336
  export declare function applyAutoIcons(component: string, metadata: SsrMetadata | undefined): SsrMetadata | undefined;
307
337
  export declare function applyAutoSocialImages(component: string, headers: Record<string, string> | undefined, metadata: SsrMetadata | undefined): SsrMetadata | undefined;
338
+ /**
339
+ * Dev-only tail chunk: the `__PYLON_DEV__` info blob (cache verdict, render
340
+ * mode/timing, route) + the HUD bootstrap. Embedded after the page tail so the
341
+ * marker + probe are in place before the deferred client entry boots the sync
342
+ * engine. `<` is escaped so the JSON can't break out of the script.
343
+ */
344
+ export declare function buildDevHudChunk(devInfo: Record<string, unknown>): string;
308
345
  /**
309
346
  * Build the hydration tail appended after React's stream EOFs: the
310
347
  * `__PYLON_DATA__` JSON blob (props + ssrData) + the per-route entry
@@ -334,6 +371,9 @@ export declare function buildHydrationTail(args: {
334
371
  message: string;
335
372
  digest?: string;
336
373
  };
374
+ bucketAuth?: {
375
+ signedIn: boolean;
376
+ };
337
377
  }): string;
338
378
  /**
339
379
  * A short, non-reversible correlation id for an error — surfaced to the
@@ -361,6 +401,47 @@ export declare function computeRevalidateSecs(mod: any): number | null;
361
401
  * cached). Fail-closed: every condition must hold.
362
402
  */
363
403
  export declare function computeCacheVerdict(args: {
404
+ revalidateSecs: number | null;
405
+ forceDynamic: boolean;
406
+ authTouched: boolean;
407
+ dynamicTouched: boolean;
408
+ sessionTouched: boolean;
409
+ cookieCount: number;
410
+ strictPolicies: boolean;
411
+ wantsStream: boolean;
412
+ status: number;
413
+ }): boolean;
414
+ /**
415
+ * PPR Phase 0 — the AUTH-BUCKET verdict: may this render be stored as a SHARED
416
+ * cache entry keyed only on session-cookie PRESENCE (one signed-in shell + one
417
+ * signed-out shell, both IDENTITY-FREE)? Pure, exported, leak-class tested.
418
+ *
419
+ * Like `computeCacheVerdict` EXCEPT `sessionTouched` is ALLOWED (reading
420
+ * `props.session.exists` to render a binary signed-in/out nav is the whole
421
+ * point — that bit is what the bucket keys on, and it's identical across all
422
+ * users in the same bucket). Everything else still vetoes:
423
+ *
424
+ * - `authTouched` — reading real `props.auth` (user_id/tenant_id/roles) makes
425
+ * the output identity-SPECIFIC, not bucket-uniform. This is the load-bearing
426
+ * gate: ANY identity-dependent output requires reading auth, so vetoing on
427
+ * `authTouched` proves a bucketable body depends on nothing but the binary
428
+ * session bit.
429
+ * - `dynamicTouched` — reading request headers/cookies makes it request-specific.
430
+ * - `strictPolicies` — in strict mode serverData reads are auth-FILTERED, so the
431
+ * body/ssrData would vary by identity even without the page reading auth. In
432
+ * the default (non-strict) mode serverData reads bypass the policy gate and
433
+ * return identity-independent rows, so the cached body is safe to share within
434
+ * the bucket. Strict mode therefore must NOT bucket.
435
+ * - cookieCount / forceDynamic / wantsStream / non-200 — same reasons as the
436
+ * anon verdict (a Set-Cookie, a streaming head, or an error/redirect can't be
437
+ * safely shared).
438
+ *
439
+ * Requires the explicit `bucketOptIn` (`export const cache = "auth-bucketed"`)
440
+ * AND a TTL via `export const revalidate = N` — fail-closed: no opt-in or no TTL
441
+ * → no bucket.
442
+ */
443
+ export declare function computeBucketVerdict(args: {
444
+ bucketOptIn: boolean;
364
445
  revalidateSecs: number | null;
365
446
  forceDynamic: boolean;
366
447
  authTouched: boolean;
@@ -370,6 +451,31 @@ export declare function computeCacheVerdict(args: {
370
451
  wantsStream: boolean;
371
452
  status: number;
372
453
  }): boolean;
454
+ /**
455
+ * Dev HUD: explain the cache verdict for a finished render in human terms — the
456
+ * verdict, its TTL, and (when dynamic) the SINGLE reason it isn't cached. Pure so
457
+ * it's unit-tested and matches `computeCacheVerdict`/`computeBucketVerdict`
458
+ * exactly. The "why isn't my page caching?" answer the framework otherwise throws
459
+ * away.
460
+ */
461
+ export declare function describeCacheVerdict(v: {
462
+ bucketOptIn: boolean;
463
+ cacheable: boolean;
464
+ bucketable: boolean;
465
+ revalidateSecs: number | null;
466
+ authTouched: boolean;
467
+ dynamicTouched: boolean;
468
+ sessionTouched: boolean;
469
+ cookieCount: number;
470
+ strictPolicies: boolean;
471
+ wantsStream: boolean;
472
+ forceDynamic: boolean;
473
+ status: number;
474
+ }): {
475
+ verdict: "bucketed" | "cacheable" | "dynamic";
476
+ secs: number | null;
477
+ reason: string;
478
+ };
373
479
  /**
374
480
  * #278: diff the response head committed at `response_start` against the final
375
481
  * state after EOF, to catch a late response.* mutation from a suspended subtree
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pylonsync/functions",
3
- "version": "0.3.298",
3
+ "version": "0.3.300",
4
4
  "description": "TypeScript function runtime for pylon — defines server-side queries, mutations, and actions.",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -17,6 +17,8 @@ import {
17
17
  finalizeHeaders,
18
18
  makeResponseController,
19
19
  makeReadTrackingProxy,
20
+ makeRevocableReadTrackingProxy,
21
+ jsonClone,
20
22
  } from "./ssr-runtime";
21
23
 
22
24
  describe("resolveOrigin — Host-header allowlist (cache-poisoning fence)", () => {
@@ -148,6 +150,42 @@ describe("reserved x-pylon-* header namespace (cache-proof forgery fence)", () =
148
150
  });
149
151
  expect(t).toBe(false);
150
152
  });
153
+
154
+ test("revocable proxy throws after revoke (stale module-stashed props fence)", () => {
155
+ // P0 (codex 2026-06-28): a page that stashes `props` (or `props.auth`) in
156
+ // module-level state and reads it on a LATER render must not silently read a
157
+ // prior request's identity without tripping THIS render's read-tracking. The
158
+ // render path revokes each per-request proxy when the render ends, so any
159
+ // retained reference throws on access — fail-closed.
160
+ const { proxy, revoke } = makeRevocableReadTrackingProxy(
161
+ { user_id: "alice" },
162
+ () => {},
163
+ );
164
+ expect((proxy as any).user_id).toBe("alice"); // live during the render
165
+ revoke();
166
+ // A stashed reference, read on a later render:
167
+ expect(() => (proxy as any).user_id).toThrow();
168
+ expect(() => "user_id" in proxy).toThrow();
169
+ expect(() => Object.keys(proxy)).toThrow();
170
+ });
171
+
172
+ test("jsonClone snapshot is independent of later source mutation (bucket params fence)", () => {
173
+ // The bucket-tail snapshot (bucketTailBase) is jsonClone'd at render START.
174
+ // The defense rests on the clone being decoupled from the live params object:
175
+ // a page mutating a NESTED field afterwards (props.searchParams.leak =
176
+ // props.auth) can't reach the already-captured snapshot.
177
+ const source: any = { id: "a", nested: { keep: 1 } };
178
+ const snap = jsonClone(source);
179
+ // Simulate the page smuggling identity in after the snapshot was taken.
180
+ source.leak = { user_id: "alice" };
181
+ source.nested.keep = 999;
182
+ expect(snap).toEqual({ id: "a", nested: { keep: 1 } });
183
+ expect((snap as any).leak).toBeUndefined();
184
+ // And it strips non-JSON values (a proxy aliased in would serialize as its
185
+ // target via JSON, but a function/symbol is dropped entirely).
186
+ const stripped = jsonClone({ ok: "v", fn: () => 1, sym: Symbol("x") } as any);
187
+ expect(stripped).toEqual({ ok: "v" });
188
+ });
151
189
  });
152
190
 
153
191
  // Pull the JSON out of the `__PYLON_DATA__` <script> a hydration tail emits.
@@ -75,6 +75,12 @@ export interface RenderRouteMessage {
75
75
  tenant_id: string | null;
76
76
  roles: string[];
77
77
  };
78
+ /**
79
+ * Whether the request carried a session cookie (presence, not identity) —
80
+ * surfaced as the identity-free `props.session.exists` (Phase 0 auth
81
+ * bucketing). Default false / absent for back-compat.
82
+ */
83
+ session_present?: boolean;
78
84
  /**
79
85
  * Initial HTTP status the response controller starts at (default 200).
80
86
  * The host sets this to 404 when dispatching a `not-found.tsx` render
@@ -321,7 +327,37 @@ export function makeReadTrackingProxy(
321
327
  obj: Record<string, unknown> | undefined,
322
328
  onTouch: () => void,
323
329
  ): Record<string, unknown> {
324
- return new Proxy((obj ?? {}) as Record<string, unknown>, {
330
+ return makeRevocableReadTrackingProxy(obj, onTouch).proxy;
331
+ }
332
+
333
+ /**
334
+ * Deep clone via JSON round-trip — drops functions / proxies / symbols, keeping
335
+ * only JSON-serializable data. Used to SNAPSHOT the bucket-tail props before any
336
+ * page code can mutate them, so a page can't smuggle identity through a nested
337
+ * field of params/searchParams into a shared cache entry. The deep copy is fully
338
+ * independent of the source: a later mutation of the original object can't reach
339
+ * the snapshot.
340
+ */
341
+ export function jsonClone<T>(v: T): T {
342
+ return v == null ? v : (JSON.parse(JSON.stringify(v)) as T);
343
+ }
344
+
345
+ /**
346
+ * Revocable form of {@link makeReadTrackingProxy}. The render path wraps each
347
+ * per-request input (auth / headers / cookies / session) in one of these and
348
+ * REVOKES it once the render is done — so a page that stashed the props object
349
+ * (or `props.auth`) in module-level state and read it on a LATER render gets a
350
+ * hard throw instead of silently reading a prior request's identity WITHOUT
351
+ * tripping this render's read-tracking. Revocation is per-render (each render
352
+ * revokes only its own proxies), so it is sound even when the Bun runner
353
+ * multiplexes concurrent renders. Fail-closed: the stale read throws, the render
354
+ * fails, and nothing is cached.
355
+ */
356
+ export function makeRevocableReadTrackingProxy(
357
+ obj: Record<string, unknown> | undefined,
358
+ onTouch: () => void,
359
+ ): { proxy: Record<string, unknown>; revoke: () => void } {
360
+ return Proxy.revocable((obj ?? {}) as Record<string, unknown>, {
325
361
  get(t, p, r) {
326
362
  onTouch();
327
363
  return Reflect.get(t, p, r);
@@ -1122,6 +1158,155 @@ const DEV_LIVE_RELOAD_SNIPPET =
1122
1158
  "if(b!==null&&e.data!==b){s.close();location.reload();return;}b=e.data;});" +
1123
1159
  "}catch(_){}})();</script>";
1124
1160
 
1161
+ /**
1162
+ * The dev HUD (a floating bottom-left overlay, dev-only): surfaces the
1163
+ * framework decisions a dev otherwise can't see — the cache verdict + WHY a page
1164
+ * isn't cached, render mode + timing, sync connection + offline-outbox depth, and
1165
+ * client error count. Written as a plain function and embedded via `.toString()`
1166
+ * (Bun strips the TS annotations), so it ships as self-contained browser JS that
1167
+ * closes over nothing. Browser globals go through `g` so it typechecks without a
1168
+ * DOM lib. Mirrors Next's dev indicator, tuned to Pylon's hidden state.
1169
+ */
1170
+ function pylonDevHud() {
1171
+ const g: any = globalThis;
1172
+ const d: any = g.document;
1173
+ if (!d || g.__pylonHudMounted) return;
1174
+ g.__pylonHudMounted = true;
1175
+
1176
+ let info: any = {};
1177
+ try {
1178
+ const el = d.getElementById("__PYLON_DEV__");
1179
+ if (el) info = JSON.parse(el.textContent || "{}");
1180
+ } catch (_e) {}
1181
+ // Marker the sync engine checks before publishing its dev status probe.
1182
+ g.__PYLON_DEV__ = info;
1183
+
1184
+ const errs: string[] = [];
1185
+ const onErr = (m: any) => {
1186
+ errs.push(String(m));
1187
+ if (errs.length > 50) errs.shift();
1188
+ };
1189
+ if (g.addEventListener) {
1190
+ g.addEventListener("error", (e: any) => onErr((e && (e.message || e.error)) || e));
1191
+ g.addEventListener("unhandledrejection", (e: any) => onErr((e && e.reason) || e));
1192
+ }
1193
+
1194
+ const C = { ok: "#3fb950", warn: "#d29922", bad: "#f85149", dim: "#6e7681" };
1195
+ const make = (tag: string, css: string, text?: string) => {
1196
+ const n = d.createElement(tag);
1197
+ n.style.cssText = css;
1198
+ if (text != null) n.textContent = text;
1199
+ return n;
1200
+ };
1201
+ const dot = (color: string) =>
1202
+ make("span", "display:inline-block;width:7px;height:7px;border-radius:50%;flex:0 0 auto;background:" + color);
1203
+
1204
+ const box = make(
1205
+ "div",
1206
+ "position:fixed;left:12px;bottom:12px;z-index:2147483646;font:11px/1.5 ui-monospace,SFMono-Regular,Menlo,monospace",
1207
+ );
1208
+ const panel = make(
1209
+ "div",
1210
+ "background:#0d1117;border:1px solid #30363d;border-radius:8px;padding:9px 11px;margin-bottom:6px;min-width:240px;max-width:360px;box-shadow:0 8px 28px rgba(0,0,0,.45)",
1211
+ );
1212
+ const pill = make(
1213
+ "button",
1214
+ "display:flex;align-items:center;gap:6px;background:#161b22;border:1px solid #30363d;border-radius:999px;padding:5px 11px;color:#e6edf3;cursor:pointer;font:inherit;box-shadow:0 2px 10px rgba(0,0,0,.35)",
1215
+ );
1216
+
1217
+ const rowEl = (label: string) => {
1218
+ const r = make("div", "display:flex;align-items:flex-start;gap:8px;margin:3px 0");
1219
+ r.appendChild(make("span", "color:#8b949e;flex:0 0 60px", label));
1220
+ const v = make("span", "color:#e6edf3;word-break:break-word;flex:1");
1221
+ r.appendChild(v);
1222
+ panel.appendChild(r);
1223
+ return v;
1224
+ };
1225
+
1226
+ const cache = info.cache || {};
1227
+ const cacheLabel =
1228
+ cache.verdict === "dynamic"
1229
+ ? "dynamic"
1230
+ : cache.verdict + (cache.secs ? " · " + cache.secs + "s" : "");
1231
+ rowEl("route").textContent = info.route || "—";
1232
+ rowEl("page").textContent = info.component || "—";
1233
+ const cacheV = rowEl("cache");
1234
+ cacheV.textContent = cacheLabel + (cache.reason ? " · " + cache.reason : "");
1235
+ cacheV.style.color = cache.verdict === "dynamic" ? C.warn : C.ok;
1236
+ rowEl("render").textContent =
1237
+ (info.renderMode || "ssr") + (info.renderMs != null ? " · " + info.renderMs + "ms" : "");
1238
+ const syncV = rowEl("sync");
1239
+ const errV = rowEl("errors");
1240
+
1241
+ pill.appendChild(dot(cache.verdict === "dynamic" ? C.warn : C.ok));
1242
+ pill.appendChild(make("span", "font-weight:600;color:#e6edf3", "pylon"));
1243
+ const pillSyncDot = dot(C.dim);
1244
+ pill.appendChild(pillSyncDot);
1245
+
1246
+ let open = false;
1247
+ try {
1248
+ open = g.localStorage && g.localStorage.getItem("pylon.hud.open") === "1";
1249
+ } catch (_e) {}
1250
+ panel.style.display = open ? "block" : "none";
1251
+ pill.onclick = () => {
1252
+ open = !open;
1253
+ panel.style.display = open ? "block" : "none";
1254
+ try {
1255
+ g.localStorage && g.localStorage.setItem("pylon.hud.open", open ? "1" : "0");
1256
+ } catch (_e) {}
1257
+ };
1258
+
1259
+ const refresh = () => {
1260
+ const s = g.__pylonDevSync;
1261
+ if (s) {
1262
+ let st = "?";
1263
+ let pend = 0;
1264
+ let rows = 0;
1265
+ try {
1266
+ st = s.status();
1267
+ pend = s.pending();
1268
+ rows = s.rows();
1269
+ } catch (_e) {}
1270
+ syncV.textContent = st + " · " + pend + " pending · " + rows + " rows";
1271
+ const col = st === "connected" ? C.ok : st === "offline" ? C.bad : C.warn;
1272
+ syncV.style.color = col;
1273
+ pillSyncDot.style.background = col;
1274
+ } else {
1275
+ const online = g.navigator ? g.navigator.onLine : true;
1276
+ syncV.textContent = "no sync engine · " + (online ? "online" : "offline");
1277
+ syncV.style.color = C.dim;
1278
+ pillSyncDot.style.background = online ? C.dim : C.bad;
1279
+ }
1280
+ errV.textContent = errs.length === 0 ? "0" : errs.length + " · " + errs[errs.length - 1];
1281
+ errV.style.color = errs.length ? C.bad : C.dim;
1282
+ };
1283
+
1284
+ box.appendChild(panel);
1285
+ box.appendChild(pill);
1286
+ const mount = () => (d.body || d.documentElement).appendChild(box);
1287
+ if (d.body) mount();
1288
+ else if (g.addEventListener) g.addEventListener("DOMContentLoaded", mount);
1289
+ refresh();
1290
+ if (g.setInterval) g.setInterval(refresh, 1000);
1291
+ }
1292
+
1293
+ /**
1294
+ * Dev-only tail chunk: the `__PYLON_DEV__` info blob (cache verdict, render
1295
+ * mode/timing, route) + the HUD bootstrap. Embedded after the page tail so the
1296
+ * marker + probe are in place before the deferred client entry boots the sync
1297
+ * engine. `<` is escaped so the JSON can't break out of the script.
1298
+ */
1299
+ export function buildDevHudChunk(devInfo: Record<string, unknown>): string {
1300
+ const json = JSON.stringify(devInfo)
1301
+ .replace(/</g, "\\u003c")
1302
+ .replace(/\u2028/g, "\\u2028")
1303
+ .replace(/\u2029/g, "\\u2029");
1304
+ return (
1305
+ `<script id="__PYLON_DEV__" type="application/json">${json}</script>` +
1306
+ `<script>(${pylonDevHud.toString()})();</script>`
1307
+ );
1308
+ }
1309
+
1125
1310
  /**
1126
1311
  * Build the <head> blob for a boundary render: the union of every route's
1127
1312
  * stylesheet links from the client build manifest. Boundary modules aren't
@@ -1202,6 +1387,15 @@ export function buildHydrationTail(args: {
1202
1387
  manifestErr: string | null;
1203
1388
  kind?: "error" | "not-found";
1204
1389
  errorForClient?: { message: string; digest?: string };
1390
+ // PPR Phase 0 (auth-bucketed caching): when set, this render is being stored
1391
+ // as a SHARED cache entry keyed only on session-cookie PRESENCE, so its
1392
+ // hydration tail must carry an IDENTITY-FREE auth — the binary `{ signedIn }`
1393
+ // bit the bucket is keyed on, and NOTHING ELSE (no user_id / tenant_id /
1394
+ // roles / email). Two different signed-in users hitting the same bucketed
1395
+ // page MUST produce a byte-identical tail, or the shared cache replays one
1396
+ // user's identity to another (the #277 leak class, at the body level). The
1397
+ // raw `auth` is replaced AFTER the live-handle strip below.
1398
+ bucketAuth?: { signedIn: boolean };
1205
1399
  }): string {
1206
1400
  // Strip live, non-serializable handles (serverData / response / reset) + the
1207
1401
  // request headers/cookies (SECURITY: never expose the session cookie to
@@ -1216,7 +1410,28 @@ export function buildHydrationTail(args: {
1216
1410
  error: _err,
1217
1411
  ...restProps
1218
1412
  } = args.props ?? {};
1219
- const serializableProps: any = { ...restProps, headers: {}, cookies: {} };
1413
+ let serializableProps: any;
1414
+ if (args.bucketAuth) {
1415
+ // Bucketed render → SHARED entry. Serialize a strict ALLOWLIST of the
1416
+ // framework props that are provably bucket-uniform — never a spread of the
1417
+ // page's props object. A page can alias identity onto a custom key
1418
+ // (`props.leak = props.auth`) without tripping read-tracking; spreading
1419
+ // `restProps` would then serialize that identity into the shared tail. The
1420
+ // allowlisted fields are all path/route-derived (in the cache key) or the
1421
+ // collapsed binary auth bit. (params/searchParams are keyed by pathname; a
1422
+ // bucket request has no query, so searchParams is empty.)
1423
+ serializableProps = {
1424
+ url: restProps.url,
1425
+ params: restProps.params,
1426
+ searchParams: restProps.searchParams,
1427
+ auth: { signedIn: args.bucketAuth.signedIn },
1428
+ session: { exists: args.bucketAuth.signedIn },
1429
+ headers: {},
1430
+ cookies: {},
1431
+ };
1432
+ } else {
1433
+ serializableProps = { ...restProps, headers: {}, cookies: {} };
1434
+ }
1220
1435
  if (args.errorForClient) serializableProps.error = args.errorForClient;
1221
1436
  const hydrationPayload: any = {
1222
1437
  component: args.component,
@@ -1563,6 +1778,11 @@ export function computeCacheVerdict(args: {
1563
1778
  // pathname, and Host via the host bucket — are handled by the cache key and do
1564
1779
  // not set this.)
1565
1780
  dynamicTouched: boolean;
1781
+ // True when the render read `props.session.exists` (Phase 0). For the PLAIN
1782
+ // anonymous cache this is a veto — the output varies by signed-in bucket, and
1783
+ // the anon cache holds one entry for all. (The auth-BUCKET verdict, which keys
1784
+ // the cache on the bucket, permits it; this gate does not.)
1785
+ sessionTouched: boolean;
1566
1786
  cookieCount: number;
1567
1787
  strictPolicies: boolean;
1568
1788
  wantsStream: boolean;
@@ -1573,6 +1793,7 @@ export function computeCacheVerdict(args: {
1573
1793
  !args.forceDynamic &&
1574
1794
  !args.authTouched &&
1575
1795
  !args.dynamicTouched &&
1796
+ !args.sessionTouched &&
1576
1797
  args.cookieCount === 0 &&
1577
1798
  !args.strictPolicies &&
1578
1799
  !args.wantsStream &&
@@ -1580,6 +1801,122 @@ export function computeCacheVerdict(args: {
1580
1801
  );
1581
1802
  }
1582
1803
 
1804
+ /**
1805
+ * PPR Phase 0 — the AUTH-BUCKET verdict: may this render be stored as a SHARED
1806
+ * cache entry keyed only on session-cookie PRESENCE (one signed-in shell + one
1807
+ * signed-out shell, both IDENTITY-FREE)? Pure, exported, leak-class tested.
1808
+ *
1809
+ * Like `computeCacheVerdict` EXCEPT `sessionTouched` is ALLOWED (reading
1810
+ * `props.session.exists` to render a binary signed-in/out nav is the whole
1811
+ * point — that bit is what the bucket keys on, and it's identical across all
1812
+ * users in the same bucket). Everything else still vetoes:
1813
+ *
1814
+ * - `authTouched` — reading real `props.auth` (user_id/tenant_id/roles) makes
1815
+ * the output identity-SPECIFIC, not bucket-uniform. This is the load-bearing
1816
+ * gate: ANY identity-dependent output requires reading auth, so vetoing on
1817
+ * `authTouched` proves a bucketable body depends on nothing but the binary
1818
+ * session bit.
1819
+ * - `dynamicTouched` — reading request headers/cookies makes it request-specific.
1820
+ * - `strictPolicies` — in strict mode serverData reads are auth-FILTERED, so the
1821
+ * body/ssrData would vary by identity even without the page reading auth. In
1822
+ * the default (non-strict) mode serverData reads bypass the policy gate and
1823
+ * return identity-independent rows, so the cached body is safe to share within
1824
+ * the bucket. Strict mode therefore must NOT bucket.
1825
+ * - cookieCount / forceDynamic / wantsStream / non-200 — same reasons as the
1826
+ * anon verdict (a Set-Cookie, a streaming head, or an error/redirect can't be
1827
+ * safely shared).
1828
+ *
1829
+ * Requires the explicit `bucketOptIn` (`export const cache = "auth-bucketed"`)
1830
+ * AND a TTL via `export const revalidate = N` — fail-closed: no opt-in or no TTL
1831
+ * → no bucket.
1832
+ */
1833
+ export function computeBucketVerdict(args: {
1834
+ bucketOptIn: boolean;
1835
+ revalidateSecs: number | null;
1836
+ forceDynamic: boolean;
1837
+ authTouched: boolean;
1838
+ dynamicTouched: boolean;
1839
+ cookieCount: number;
1840
+ strictPolicies: boolean;
1841
+ wantsStream: boolean;
1842
+ status: number;
1843
+ }): boolean {
1844
+ return (
1845
+ args.bucketOptIn &&
1846
+ args.revalidateSecs != null &&
1847
+ !args.forceDynamic &&
1848
+ !args.authTouched &&
1849
+ !args.dynamicTouched &&
1850
+ args.cookieCount === 0 &&
1851
+ !args.strictPolicies &&
1852
+ !args.wantsStream &&
1853
+ args.status === 200
1854
+ );
1855
+ }
1856
+
1857
+ /**
1858
+ * Dev HUD: explain the cache verdict for a finished render in human terms — the
1859
+ * verdict, its TTL, and (when dynamic) the SINGLE reason it isn't cached. Pure so
1860
+ * it's unit-tested and matches `computeCacheVerdict`/`computeBucketVerdict`
1861
+ * exactly. The "why isn't my page caching?" answer the framework otherwise throws
1862
+ * away.
1863
+ */
1864
+ export function describeCacheVerdict(v: {
1865
+ bucketOptIn: boolean;
1866
+ cacheable: boolean;
1867
+ bucketable: boolean;
1868
+ revalidateSecs: number | null;
1869
+ authTouched: boolean;
1870
+ dynamicTouched: boolean;
1871
+ sessionTouched: boolean;
1872
+ cookieCount: number;
1873
+ strictPolicies: boolean;
1874
+ wantsStream: boolean;
1875
+ forceDynamic: boolean;
1876
+ status: number;
1877
+ }): { verdict: "bucketed" | "cacheable" | "dynamic"; secs: number | null; reason: string } {
1878
+ if (v.bucketable) {
1879
+ return {
1880
+ verdict: "bucketed",
1881
+ secs: v.revalidateSecs,
1882
+ reason: "shared cache, keyed on session presence",
1883
+ };
1884
+ }
1885
+ if (v.cacheable) {
1886
+ return {
1887
+ verdict: "cacheable",
1888
+ secs: v.revalidateSecs,
1889
+ reason: "anonymous shared cache",
1890
+ };
1891
+ }
1892
+ // Dynamic — surface the single most useful reason (mirrors the verdict order).
1893
+ let reason: string;
1894
+ if (!v.bucketOptIn && v.revalidateSecs == null) {
1895
+ reason = "not opted in — add `export const revalidate = N` (or `cache = \"auth-bucketed\"`)";
1896
+ } else if (v.status !== 200) {
1897
+ reason = `status ${v.status} (only 200 is cacheable)`;
1898
+ } else if (v.forceDynamic) {
1899
+ reason = "dynamic = \"force-dynamic\"";
1900
+ } else if (v.wantsStream) {
1901
+ reason = "streaming render (loading.tsx / streaming = true)";
1902
+ } else if (v.cookieCount > 0) {
1903
+ reason = "the render set a cookie";
1904
+ } else if (v.strictPolicies) {
1905
+ reason = "PYLON_STRICT_FN_POLICIES (serverData reads are auth-filtered)";
1906
+ } else if (v.authTouched) {
1907
+ reason = "read props.auth (identity-specific)";
1908
+ } else if (v.dynamicTouched) {
1909
+ reason = "read request headers/cookies";
1910
+ } else if (v.sessionTouched) {
1911
+ reason = v.bucketOptIn
1912
+ ? "read props.session"
1913
+ : "read props.session — add `export const cache = \"auth-bucketed\"` to bucket-cache it";
1914
+ } else {
1915
+ reason = "not cacheable";
1916
+ }
1917
+ return { verdict: "dynamic", secs: null, reason };
1918
+ }
1919
+
1583
1920
  /**
1584
1921
  * #278: diff the response head committed at `response_start` against the final
1585
1922
  * state after EOF, to catch a late response.* mutation from a suspended subtree
@@ -1777,6 +2114,10 @@ export async function handleRenderRoute(
1777
2114
  : null;
1778
2115
  if (dataKind) return handleDataRoute(msg, dataKind, send);
1779
2116
 
2117
+ // Dev HUD: wall-clock for the whole render, surfaced in the dev overlay's
2118
+ // render row. `performance.now()` is monotonic; harmless in prod (unused).
2119
+ const renderStart = isDevMode() ? performance.now() : 0;
2120
+
1780
2121
  // Declared OUTSIDE the try so the catch can read page-set status/
1781
2122
  // cookies when turning a redirect()/notFound() throw into a response.
1782
2123
  const responseState: ResponseState = {
@@ -1791,6 +2132,11 @@ export async function handleRenderRoute(
1791
2132
  let React: any = null;
1792
2133
  let renderToReadableStream: any = null;
1793
2134
  let props: any = null;
2135
+ // Revoke fns for this render's per-request proxies (auth/headers/cookies/
2136
+ // session). Declared OUT here so the `finally` revokes them on EVERY exit path
2137
+ // (success, redirect/notFound/error boundary, throw) — neutralizing any
2138
+ // module-stashed reference to a prior request's identity.
2139
+ const proxyRevokers: Array<() => void> = [];
1794
2140
  // Accumulates the resolved results of every `serverData.*` read the page
1795
2141
  // made during render, keyed identically to the client shim. Serialized
1796
2142
  // into `__PYLON_DATA__.ssrData` so hydration replays the same values
@@ -1869,14 +2215,24 @@ export async function handleRenderRoute(
1869
2215
 
1870
2216
  // #277 cache-safety proof. A render is shareable (CDN/disk cacheable) ONLY
1871
2217
  // if its output is independent of per-request inputs — so wrap each in a
1872
- // read-tracking Proxy (makeReadTrackingProxy) that flips a flag the moment a
1873
- // page/layout/generateMetadata observes it (get / `in` / Object.keys / probe).
1874
- // The raw objects are restored before serialization.
2218
+ // read-tracking Proxy that flips a flag the moment a page/layout/
2219
+ // generateMetadata observes it (get / `in` / Object.keys / probe). The
2220
+ // proxies are REVOKED in the `finally` (never restored to raw onto `props`),
2221
+ // so the live props object never aliases a prior request's identity. The tail
2222
+ // serializes from the raw `msg` values via `tailProps` below.
2223
+ const track = (
2224
+ obj: Record<string, unknown> | undefined,
2225
+ onTouch: () => void,
2226
+ ) => {
2227
+ const { proxy, revoke } = makeRevocableReadTrackingProxy(obj, onTouch);
2228
+ proxyRevokers.push(revoke);
2229
+ return proxy;
2230
+ };
1875
2231
 
1876
2232
  // Reading auth at all (even for an anon request) opts the render OUT of
1877
2233
  // caching, because the output could differ by identity.
1878
2234
  let authTouched = false;
1879
- const authProxy = makeReadTrackingProxy(
2235
+ const authProxy = track(
1880
2236
  msg.auth as Record<string, unknown> | undefined,
1881
2237
  () => {
1882
2238
  authTouched = true;
@@ -1889,10 +2245,24 @@ export async function handleRenderRoute(
1889
2245
  // cookies — the framework's metadata path reads `msg.headers` directly.
1890
2246
  let dynamicTouched = false;
1891
2247
  const touchProxy = (obj: Record<string, unknown> | undefined) =>
1892
- makeReadTrackingProxy(obj, () => {
2248
+ track(obj, () => {
1893
2249
  dynamicTouched = true;
1894
2250
  });
1895
2251
 
2252
+ // Phase 0 (auth bucketing): `props.session.exists` — the identity-FREE
2253
+ // presence bit, so a page can render a binary auth nav WITHOUT reading real
2254
+ // `auth`. Reading it sets `sessionTouched` (separate from authTouched): it
2255
+ // vetoes the plain anonymous cache (the output now varies by signed-in
2256
+ // bucket) but is PERMITTED by the bucket verdict (which keys the cache on the
2257
+ // bucket).
2258
+ let sessionTouched = false;
2259
+ const sessionProxy = track(
2260
+ { exists: msg.session_present === true },
2261
+ () => {
2262
+ sessionTouched = true;
2263
+ },
2264
+ );
2265
+
1896
2266
  props = {
1897
2267
  url: msg.url,
1898
2268
  params: msg.params,
@@ -1900,6 +2270,7 @@ export async function handleRenderRoute(
1900
2270
  headers: touchProxy(msg.headers as Record<string, unknown> | undefined),
1901
2271
  cookies: touchProxy(msg.cookies as Record<string, unknown> | undefined),
1902
2272
  auth: authProxy,
2273
+ session: sessionProxy,
1903
2274
  // Response controller — a page/layout calls response.setStatus /
1904
2275
  // setHeader / setCookie / redirect / notFound to shape the reply.
1905
2276
  response,
@@ -1907,6 +2278,23 @@ export async function handleRenderRoute(
1907
2278
  serverData,
1908
2279
  };
1909
2280
 
2281
+ // PPR Phase 0: an immutable, pre-render SNAPSHOT of the only props a bucketed
2282
+ // (SHARED) tail may serialize — url/params/searchParams, all path-derived and
2283
+ // in the cache key. Deep-cloned NOW, before any page/generateMetadata code
2284
+ // runs, because `props.params`/`searchParams` ARE the live `msg` objects: a
2285
+ // page could mutate a nested field to smuggle identity (e.g.
2286
+ // `props.searchParams.leak = props.auth`) WITHOUT tripping read-tracking, and
2287
+ // a shallow allowlist would copy that mutation by reference into the shared
2288
+ // tail. Captured only for opted-in pages (zero cost otherwise).
2289
+ const bucketOptIn = (mod as any).cache === "auth-bucketed";
2290
+ const bucketTailBase = bucketOptIn
2291
+ ? {
2292
+ url: msg.url,
2293
+ params: jsonClone(msg.params),
2294
+ searchParams: jsonClone(msg.search_params),
2295
+ }
2296
+ : null;
2297
+
1910
2298
  // SEO metadata: static `export const metadata` or dynamic
1911
2299
  // `export async function generateMetadata(props)`. Awaited before the
1912
2300
  // first byte, so keep it to cheap derivations (params → title); heavy
@@ -2063,7 +2451,31 @@ export async function handleRenderRoute(
2063
2451
  // `!Loading`) is the gate: a `streaming = true` page has `Loading` null but
2064
2452
  // `wantsStream` true, and must still be excluded. Fail-closed. (See
2065
2453
  // computeCacheVerdict — pure + unit-tested for the leak class.)
2066
- const cacheable = computeCacheVerdict({
2454
+ // PPR Phase 0: `bucketOptIn` (`export const cache = "auth-bucketed"`) was
2455
+ // resolved at props-construction time (it gates the immutable bucketTailBase
2456
+ // snapshot). A bucketed render and the plain anon cache are mutually exclusive
2457
+ // — `cacheable` excludes the opt-in so a bucket page never also emits the anon
2458
+ // proof.
2459
+ const cacheable =
2460
+ !bucketOptIn &&
2461
+ computeCacheVerdict({
2462
+ revalidateSecs,
2463
+ forceDynamic,
2464
+ authTouched,
2465
+ dynamicTouched,
2466
+ sessionTouched,
2467
+ cookieCount: responseState.cookies.length,
2468
+ strictPolicies,
2469
+ wantsStream,
2470
+ status: responseState.status,
2471
+ });
2472
+ // The bucket verdict permits `sessionTouched` (reading the binary signed-in
2473
+ // bit) but still vetoes any real-auth read. When true, this render is stored
2474
+ // as a shared, identity-free, session-presence-keyed entry — so its
2475
+ // hydration tail MUST be anonymized (bucketAuth below) and it advertises the
2476
+ // TRUSTED internal `x-pylon-bucket` proof for the host to key + store it.
2477
+ const bucketable = computeBucketVerdict({
2478
+ bucketOptIn,
2067
2479
  revalidateSecs,
2068
2480
  forceDynamic,
2069
2481
  authTouched,
@@ -2073,13 +2485,26 @@ export async function handleRenderRoute(
2073
2485
  wantsStream,
2074
2486
  status: responseState.status,
2075
2487
  });
2076
- // Restore the raw auth/headers/cookies before any serialization below (the
2077
- // Proxies were only for the render-time touch probe).
2078
- if (props) {
2079
- props.auth = msg.auth;
2080
- props.headers = msg.headers;
2081
- props.cookies = msg.cookies;
2082
- }
2488
+ // Serialization view of props built from the RAW `msg` values — the live
2489
+ // `props` (with its read-tracking proxies) is NEVER mutated back to raw, so
2490
+ // it can't become a vehicle that leaks a prior request's identity to a later
2491
+ // render (the proxies are revoked in `finally`). headers/cookies are stripped
2492
+ // inside buildHydrationTail; auth uses the raw value (so anon auth stays
2493
+ // falsy/undefined rather than the proxy's empty `{}`). For a BUCKETED render,
2494
+ // url/params/searchParams come from the pre-render `bucketTailBase` snapshot —
2495
+ // NOT the live (page-mutable) props — so a page can't smuggle identity through
2496
+ // a nested field into the shared tail. buildHydrationTail's bucket allowlist
2497
+ // then serializes only these fields + the collapsed { signedIn } bit.
2498
+ const tailProps = props
2499
+ ? {
2500
+ ...props,
2501
+ auth: msg.auth,
2502
+ headers: msg.headers,
2503
+ cookies: msg.cookies,
2504
+ session: { exists: msg.session_present === true },
2505
+ ...(bucketable && bucketTailBase ? bucketTailBase : {}),
2506
+ }
2507
+ : props;
2083
2508
  // #278: on a STREAMING render the head commits NOW, before suspended
2084
2509
  // subtrees run. Snapshot what's committed so we can detect (after EOF) a
2085
2510
  // late response.setStatus/setCookie/setHeader from a suspended subtree that
@@ -2100,10 +2525,15 @@ export async function handleRenderRoute(
2100
2525
  headers: finalizeHeaders(
2101
2526
  responseState,
2102
2527
  undefined,
2103
- // The #277 proof rides the TRUSTED `internal` channel (never stripped),
2104
- // so userland (page setHeader / route-handler headers via `extra`) can't
2105
- // forge it.
2106
- cacheable ? { "x-pylon-cacheable": String(revalidateSecs) } : undefined,
2528
+ // The #277 anon proof and the Phase 0 bucket proof both ride the TRUSTED
2529
+ // `internal` channel (never stripped), so userland (page setHeader /
2530
+ // route-handler headers via `extra`) can't forge either. Mutually
2531
+ // exclusive (bucketOptIn splits the two verdicts).
2532
+ bucketable
2533
+ ? { "x-pylon-bucket": String(revalidateSecs) }
2534
+ : cacheable
2535
+ ? { "x-pylon-cacheable": String(revalidateSecs) }
2536
+ : undefined,
2107
2537
  ),
2108
2538
  });
2109
2539
 
@@ -2242,7 +2672,7 @@ export async function handleRenderRoute(
2242
2672
  const tail = buildHydrationTail({
2243
2673
  component: msg.component,
2244
2674
  layouts: msg.layouts ?? [],
2245
- props,
2675
+ props: tailProps,
2246
2676
  ssrData: ssrValueCache,
2247
2677
  manifestRoute: preloadManifestRoute,
2248
2678
  publicPrefix: preloadPublicPrefix,
@@ -2252,10 +2682,44 @@ export async function handleRenderRoute(
2252
2682
  ? "error"
2253
2683
  : "not-found"
2254
2684
  : undefined,
2685
+ // A bucketed render is stored shared → its tail must carry ONLY the
2686
+ // binary signed-in bit, never this request's real identity.
2687
+ bucketAuth: bucketable
2688
+ ? { signedIn: msg.session_present === true }
2689
+ : undefined,
2255
2690
  });
2256
2691
  sendChunk(tail);
2257
2692
  }
2258
2693
 
2694
+ // Dev HUD (dev only): append the cache-verdict + render-timing blob + the
2695
+ // floating overlay. After the page tail so its marker/probe are in place
2696
+ // before the deferred client entry boots the sync engine.
2697
+ if (isDevMode()) {
2698
+ const verdict = describeCacheVerdict({
2699
+ bucketOptIn,
2700
+ cacheable,
2701
+ bucketable,
2702
+ revalidateSecs,
2703
+ authTouched,
2704
+ dynamicTouched,
2705
+ sessionTouched,
2706
+ cookieCount: responseState.cookies.length,
2707
+ strictPolicies,
2708
+ wantsStream,
2709
+ forceDynamic,
2710
+ status: responseState.status,
2711
+ });
2712
+ sendChunk(
2713
+ buildDevHudChunk({
2714
+ route: msg.url,
2715
+ component: msg.component,
2716
+ renderMode: wantsStream ? "ssr-streaming" : "ssr-buffered",
2717
+ renderMs: Math.round((performance.now() - renderStart) * 10) / 10,
2718
+ cache: verdict,
2719
+ }),
2720
+ );
2721
+ }
2722
+
2259
2723
  send({ type: "render_done", call_id: msg.call_id });
2260
2724
  } catch (err: any) {
2261
2725
  // A page/layout called response.redirect()/response.notFound(), or
@@ -2344,5 +2808,17 @@ export async function handleRenderRoute(
2344
2808
  message:
2345
2809
  devMode && err?.stack ? String(err.stack) : err?.message ?? String(err),
2346
2810
  });
2811
+ } finally {
2812
+ // Revoke this render's per-request proxies. Any reference a page stashed in
2813
+ // module-level state (props or props.auth/headers/cookies/session) now throws
2814
+ // on access from a LATER render — fail-closed, so a prior request's identity
2815
+ // can never silently enter a cached body without tripping read-tracking.
2816
+ for (const revoke of proxyRevokers) {
2817
+ try {
2818
+ revoke();
2819
+ } catch {
2820
+ // best-effort
2821
+ }
2822
+ }
2347
2823
  }
2348
2824
  }
@@ -18,9 +18,11 @@ import React, { Suspense, use } from "react";
18
18
  import { renderToReadableStream } from "react-dom/server.browser";
19
19
  import {
20
20
  buildHydrationTail,
21
+ computeBucketVerdict,
21
22
  computeCacheVerdict,
22
23
  computeRevalidateSecs,
23
24
  computeWantsStream,
25
+ describeCacheVerdict,
24
26
  diffCommittedResponse,
25
27
  isDevMode,
26
28
  } from "./ssr-runtime";
@@ -97,6 +99,7 @@ describe("computeCacheVerdict (the #277 leak-class gate)", () => {
97
99
  forceDynamic: false,
98
100
  authTouched: false,
99
101
  dynamicTouched: false,
102
+ sessionTouched: false,
100
103
  cookieCount: 0,
101
104
  strictPolicies: false,
102
105
  wantsStream: false,
@@ -112,6 +115,7 @@ describe("computeCacheVerdict (the #277 leak-class gate)", () => {
112
115
  expect(computeCacheVerdict({ ...base, forceDynamic: true })).toBe(false);
113
116
  expect(computeCacheVerdict({ ...base, authTouched: true })).toBe(false); // read auth
114
117
  expect(computeCacheVerdict({ ...base, dynamicTouched: true })).toBe(false); // read headers/cookies
118
+ expect(computeCacheVerdict({ ...base, sessionTouched: true })).toBe(false); // read session.exists (anon cache)
115
119
  expect(computeCacheVerdict({ ...base, cookieCount: 1 })).toBe(false); // set a cookie
116
120
  expect(computeCacheVerdict({ ...base, strictPolicies: true })).toBe(false);
117
121
  expect(computeCacheVerdict({ ...base, wantsStream: true })).toBe(false); // STREAMING
@@ -136,6 +140,7 @@ describe("computeCacheVerdict (the #277 leak-class gate)", () => {
136
140
  forceDynamic,
137
141
  authTouched,
138
142
  dynamicTouched: false,
143
+ sessionTouched: false,
139
144
  cookieCount,
140
145
  strictPolicies,
141
146
  wantsStream,
@@ -151,6 +156,128 @@ describe("computeCacheVerdict (the #277 leak-class gate)", () => {
151
156
  });
152
157
  });
153
158
 
159
+ describe("computeBucketVerdict (PPR Phase 0 — session-presence shared cache)", () => {
160
+ const base = {
161
+ bucketOptIn: true,
162
+ revalidateSecs: 60 as number | null,
163
+ forceDynamic: false,
164
+ authTouched: false,
165
+ dynamicTouched: false,
166
+ cookieCount: 0,
167
+ strictPolicies: false,
168
+ wantsStream: false,
169
+ status: 200,
170
+ };
171
+
172
+ test("a clean opted-in bucketed 200 is bucketable", () => {
173
+ expect(computeBucketVerdict(base)).toBe(true);
174
+ });
175
+
176
+ test("sessionTouched is NOT a veto here (the whole point of a bucket)", () => {
177
+ // computeBucketVerdict has no sessionTouched field — reading session.exists
178
+ // is allowed. This test documents the contract by construction: the same
179
+ // base (which a bucket render reaches with sessionTouched=true) buckets.
180
+ expect(computeBucketVerdict(base)).toBe(true);
181
+ });
182
+
183
+ test("no opt-in → never buckets, even when otherwise clean", () => {
184
+ expect(computeBucketVerdict({ ...base, bucketOptIn: false })).toBe(false);
185
+ });
186
+
187
+ test("every identity/safety veto flips it to non-bucketable (fail-closed)", () => {
188
+ expect(computeBucketVerdict({ ...base, revalidateSecs: null })).toBe(false); // no TTL
189
+ expect(computeBucketVerdict({ ...base, forceDynamic: true })).toBe(false);
190
+ expect(computeBucketVerdict({ ...base, authTouched: true })).toBe(false); // read real auth
191
+ expect(computeBucketVerdict({ ...base, dynamicTouched: true })).toBe(false); // headers/cookies
192
+ expect(computeBucketVerdict({ ...base, cookieCount: 1 })).toBe(false); // set a cookie
193
+ expect(computeBucketVerdict({ ...base, strictPolicies: true })).toBe(false); // auth-filtered reads
194
+ expect(computeBucketVerdict({ ...base, wantsStream: true })).toBe(false); // streaming head
195
+ expect(computeBucketVerdict({ ...base, status: 404 })).toBe(false);
196
+ expect(computeBucketVerdict({ ...base, status: 307 })).toBe(false);
197
+ });
198
+
199
+ test("LOAD-BEARING: authTouched ALWAYS vetoes regardless of opt-in", () => {
200
+ // The proof that a bucketable body is identity-free: any output that depends
201
+ // on identity must read props.auth, which sets authTouched. So authTouched
202
+ // ⟹ !bucketable over the full cross-product.
203
+ const bools = [false, true];
204
+ for (const bucketOptIn of bools)
205
+ for (const dynamicTouched of bools)
206
+ for (const strictPolicies of bools)
207
+ for (const wantsStream of bools)
208
+ for (const cookieCount of [0, 1])
209
+ for (const status of [200, 404]) {
210
+ const v = computeBucketVerdict({
211
+ bucketOptIn,
212
+ revalidateSecs: 60,
213
+ forceDynamic: false,
214
+ authTouched: true,
215
+ dynamicTouched,
216
+ cookieCount,
217
+ strictPolicies,
218
+ wantsStream,
219
+ status,
220
+ });
221
+ expect(v).toBe(false);
222
+ }
223
+ });
224
+ });
225
+
226
+ describe("describeCacheVerdict (dev HUD — why isn't my page cached?)", () => {
227
+ const base = {
228
+ bucketOptIn: false,
229
+ cacheable: false,
230
+ bucketable: false,
231
+ revalidateSecs: null as number | null,
232
+ authTouched: false,
233
+ dynamicTouched: false,
234
+ sessionTouched: false,
235
+ cookieCount: 0,
236
+ strictPolicies: false,
237
+ wantsStream: false,
238
+ forceDynamic: false,
239
+ status: 200,
240
+ };
241
+
242
+ test("cacheable / bucketed verdicts carry the TTL", () => {
243
+ expect(describeCacheVerdict({ ...base, cacheable: true, revalidateSecs: 60 })).toEqual({
244
+ verdict: "cacheable",
245
+ secs: 60,
246
+ reason: "anonymous shared cache",
247
+ });
248
+ const b = describeCacheVerdict({
249
+ ...base,
250
+ bucketOptIn: true,
251
+ bucketable: true,
252
+ revalidateSecs: 30,
253
+ });
254
+ expect(b.verdict).toBe("bucketed");
255
+ expect(b.secs).toBe(30);
256
+ });
257
+
258
+ test("dynamic verdict reports the SINGLE actionable reason", () => {
259
+ const why = (over: any) =>
260
+ describeCacheVerdict({ ...base, ...over }).reason;
261
+ // Not opted in at all.
262
+ expect(why({})).toContain("not opted in");
263
+ // Opted in (revalidate set) but each veto wins in priority order.
264
+ const opted = { revalidateSecs: 60 };
265
+ expect(why({ ...opted, status: 404 })).toContain("status 404");
266
+ expect(why({ ...opted, forceDynamic: true })).toContain("force-dynamic");
267
+ expect(why({ ...opted, wantsStream: true })).toContain("streaming");
268
+ expect(why({ ...opted, cookieCount: 1 })).toContain("set a cookie");
269
+ expect(why({ ...opted, strictPolicies: true })).toContain("STRICT");
270
+ expect(why({ ...opted, authTouched: true })).toContain("props.auth");
271
+ expect(why({ ...opted, dynamicTouched: true })).toContain("headers/cookies");
272
+ // Reading session on a non-bucket page → the actionable hint to opt into buckets.
273
+ expect(why({ ...opted, sessionTouched: true })).toContain("auth-bucketed");
274
+ // …but on a bucket-opted page that didn't bucket for another reason, no hint.
275
+ expect(why({ bucketOptIn: true, revalidateSecs: 60, sessionTouched: true })).not.toContain(
276
+ "auth-bucketed",
277
+ );
278
+ });
279
+ });
280
+
154
281
  describe("diffCommittedResponse (#278 late-response.* drop detector)", () => {
155
282
  const snap = (over: any = {}) => ({
156
283
  status: 200,
@@ -303,3 +430,144 @@ describe("hydration tail ordering (#278: data blob before entry script)", () =>
303
430
  expect(tail).toContain('"body":"hi"');
304
431
  });
305
432
  });
433
+
434
+ // ---------------------------------------------------------------------------
435
+ // PPR Phase 0 — bucketed hydration tail must be IDENTITY-FREE
436
+ // ---------------------------------------------------------------------------
437
+
438
+ // Pull the __PYLON_DATA__ payload back out of a tail. JSON.parse natively
439
+ // decodes the unicode escapes (< etc.) buildHydrationTail applies.
440
+ function parsePylonData(tail: string): any {
441
+ const m = tail.match(
442
+ /<script id="__PYLON_DATA__" type="application\/json">([\s\S]*?)<\/script>/,
443
+ );
444
+ if (!m) throw new Error("no __PYLON_DATA__ blob in tail");
445
+ return JSON.parse(m[1]);
446
+ }
447
+
448
+ describe("bucketed hydration tail (PPR Phase 0 leak class)", () => {
449
+ // Two DIFFERENT signed-in identities. A non-bucketed tail serializes each
450
+ // verbatim; a bucketed tail MUST collapse both to `{ signedIn: true }`.
451
+ const alice = {
452
+ user_id: "user_alice",
453
+ tenant_id: "tenant_1",
454
+ roles: ["admin"],
455
+ email: "alice@x.com",
456
+ };
457
+ const bob = {
458
+ user_id: "user_bob",
459
+ tenant_id: "tenant_2",
460
+ roles: ["member"],
461
+ email: "bob@y.com",
462
+ };
463
+ const baseArgs = (auth: any) => ({
464
+ component: "app/page",
465
+ layouts: [],
466
+ props: {
467
+ url: "/",
468
+ params: {},
469
+ searchParams: {},
470
+ auth,
471
+ session: { exists: true },
472
+ },
473
+ ssrData: {},
474
+ manifestRoute: { file: "index.js", imports: [], css: [] },
475
+ publicPrefix: "/_pylon/build/",
476
+ manifestErr: null,
477
+ });
478
+
479
+ test("non-bucketed tail keeps real auth (unchanged legacy behavior)", () => {
480
+ const tail = buildHydrationTail(baseArgs(alice));
481
+ expect(parsePylonData(tail).props.auth).toEqual(alice);
482
+ });
483
+
484
+ test("bucketed signed-in tail carries ONLY { signedIn: true }", () => {
485
+ const tail = buildHydrationTail({
486
+ ...baseArgs(alice),
487
+ bucketAuth: { signedIn: true },
488
+ });
489
+ const data = parsePylonData(tail);
490
+ expect(data.props.auth).toEqual({ signedIn: true });
491
+ expect(data.props.session).toEqual({ exists: true });
492
+ // No identity escapes — not in props, not anywhere in the rendered tail.
493
+ expect(tail).not.toContain("user_alice");
494
+ expect(tail).not.toContain("tenant_1");
495
+ expect(tail).not.toContain("alice@x.com");
496
+ expect(tail).not.toContain("admin");
497
+ });
498
+
499
+ test("bucketed signed-out tail carries ONLY { signedIn: false }", () => {
500
+ const tail = buildHydrationTail({
501
+ ...baseArgs(null),
502
+ props: {
503
+ url: "/",
504
+ params: {},
505
+ searchParams: {},
506
+ auth: null,
507
+ session: { exists: false },
508
+ },
509
+ bucketAuth: { signedIn: false },
510
+ });
511
+ const data = parsePylonData(tail);
512
+ expect(data.props.auth).toEqual({ signedIn: false });
513
+ expect(data.props.session).toEqual({ exists: false });
514
+ });
515
+
516
+ test("LEAK INVARIANT: two identities → byte-identical bucketed tail", () => {
517
+ // The load-bearing property. If these two tails differed by a single byte,
518
+ // a shared cache entry would replay alice's identity to bob (or vice-versa).
519
+ const aTail = buildHydrationTail({
520
+ ...baseArgs(alice),
521
+ bucketAuth: { signedIn: true },
522
+ });
523
+ const bTail = buildHydrationTail({
524
+ ...baseArgs(bob),
525
+ bucketAuth: { signedIn: true },
526
+ });
527
+ expect(aTail).toBe(bTail);
528
+ });
529
+
530
+ test("bucketed tail DROPS page-added props (alias-identity fence, codex #4)", () => {
531
+ // A page can alias identity onto a custom key without tripping read-tracking
532
+ // (`props.leak = props.auth` — props is a plain object, no proxy trap). A
533
+ // bucketed (SHARED) tail must therefore serialize a strict ALLOWLIST of
534
+ // framework props, never a spread — else `leak` would carry one user's
535
+ // identity into every signed-in visitor's cached page.
536
+ const tail = buildHydrationTail({
537
+ ...baseArgs(alice),
538
+ props: {
539
+ url: "/",
540
+ params: { id: "x" },
541
+ searchParams: {},
542
+ auth: alice,
543
+ session: { exists: true },
544
+ // Page-aliased identity + a copied cookie bag.
545
+ leak: { uid: alice.user_id },
546
+ sneaky: alice.email,
547
+ },
548
+ bucketAuth: { signedIn: true },
549
+ });
550
+ const data = parsePylonData(tail);
551
+ expect(data.props.auth).toEqual({ signedIn: true });
552
+ // Allowlisted framework fields survive…
553
+ expect(data.props.url).toBe("/");
554
+ expect(data.props.params).toEqual({ id: "x" });
555
+ // …but the page-added keys are GONE, and no identity is anywhere in the tail.
556
+ expect(data.props.leak).toBeUndefined();
557
+ expect(data.props.sneaky).toBeUndefined();
558
+ expect(tail).not.toContain("user_alice");
559
+ expect(tail).not.toContain("alice@x.com");
560
+ });
561
+
562
+ test("NON-bucket tail still preserves page-added props (no behavior change)", () => {
563
+ // The allowlist applies ONLY to bucketed renders. A normal (non-shared)
564
+ // render keeps serializing all props so hydration matches the server tree.
565
+ const tail = buildHydrationTail({
566
+ ...baseArgs(alice),
567
+ props: { url: "/", params: {}, searchParams: {}, auth: alice, custom: 42 },
568
+ });
569
+ const data = parsePylonData(tail);
570
+ expect(data.props.custom).toBe(42);
571
+ expect(data.props.auth).toEqual(alice);
572
+ });
573
+ });