@pylonsync/functions 0.3.297 → 0.3.299

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
@@ -142,7 +148,40 @@ export declare function makeResponseController(state: ResponseState, defaultRedi
142
148
  * into one `Set-Cookie` header each (newline is forbidden inside a
143
149
  * cookie, so it can't be turned into header injection).
144
150
  */
145
- export declare function finalizeHeaders(state: ResponseState, extra?: Record<string, string>): Record<string, string>;
151
+ /**
152
+ * Wrap a per-request object so ANY observation — property `get`, `in` (`has`),
153
+ * `Object.keys`/spread/`for…in` (`ownKeys`), or a descriptor probe — calls
154
+ * `onTouch`. Used to mark a render request-specific (vetoing shared caching) the
155
+ * instant it reads auth/headers/cookies. A bare `get` trap misses `in` and
156
+ * `Object.keys`, which would observe the data without tripping the veto.
157
+ * Exported for direct unit testing of that property.
158
+ */
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
+ };
184
+ export declare function finalizeHeaders(state: ResponseState, extra?: Record<string, string>, internal?: Record<string, string>): Record<string, string>;
146
185
  /**
147
186
  * Phase 1 SSR handler. Resolves the component, renders it via
148
187
  * react-dom/server.renderToReadableStream, pumps chunks back to the
@@ -325,6 +364,9 @@ export declare function buildHydrationTail(args: {
325
364
  message: string;
326
365
  digest?: string;
327
366
  };
367
+ bucketAuth?: {
368
+ signedIn: boolean;
369
+ };
328
370
  }): string;
329
371
  /**
330
372
  * A short, non-reversible correlation id for an error — surfaced to the
@@ -355,6 +397,48 @@ export declare function computeCacheVerdict(args: {
355
397
  revalidateSecs: number | null;
356
398
  forceDynamic: boolean;
357
399
  authTouched: boolean;
400
+ dynamicTouched: boolean;
401
+ sessionTouched: boolean;
402
+ cookieCount: number;
403
+ strictPolicies: boolean;
404
+ wantsStream: boolean;
405
+ status: number;
406
+ }): boolean;
407
+ /**
408
+ * PPR Phase 0 — the AUTH-BUCKET verdict: may this render be stored as a SHARED
409
+ * cache entry keyed only on session-cookie PRESENCE (one signed-in shell + one
410
+ * signed-out shell, both IDENTITY-FREE)? Pure, exported, leak-class tested.
411
+ *
412
+ * Like `computeCacheVerdict` EXCEPT `sessionTouched` is ALLOWED (reading
413
+ * `props.session.exists` to render a binary signed-in/out nav is the whole
414
+ * point — that bit is what the bucket keys on, and it's identical across all
415
+ * users in the same bucket). Everything else still vetoes:
416
+ *
417
+ * - `authTouched` — reading real `props.auth` (user_id/tenant_id/roles) makes
418
+ * the output identity-SPECIFIC, not bucket-uniform. This is the load-bearing
419
+ * gate: ANY identity-dependent output requires reading auth, so vetoing on
420
+ * `authTouched` proves a bucketable body depends on nothing but the binary
421
+ * session bit.
422
+ * - `dynamicTouched` — reading request headers/cookies makes it request-specific.
423
+ * - `strictPolicies` — in strict mode serverData reads are auth-FILTERED, so the
424
+ * body/ssrData would vary by identity even without the page reading auth. In
425
+ * the default (non-strict) mode serverData reads bypass the policy gate and
426
+ * return identity-independent rows, so the cached body is safe to share within
427
+ * the bucket. Strict mode therefore must NOT bucket.
428
+ * - cookieCount / forceDynamic / wantsStream / non-200 — same reasons as the
429
+ * anon verdict (a Set-Cookie, a streaming head, or an error/redirect can't be
430
+ * safely shared).
431
+ *
432
+ * Requires the explicit `bucketOptIn` (`export const cache = "auth-bucketed"`)
433
+ * AND a TTL via `export const revalidate = N` — fail-closed: no opt-in or no TTL
434
+ * → no bucket.
435
+ */
436
+ export declare function computeBucketVerdict(args: {
437
+ bucketOptIn: boolean;
438
+ revalidateSecs: number | null;
439
+ forceDynamic: boolean;
440
+ authTouched: boolean;
441
+ dynamicTouched: boolean;
358
442
  cookieCount: number;
359
443
  strictPolicies: boolean;
360
444
  wantsStream: boolean;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pylonsync/functions",
3
- "version": "0.3.297",
3
+ "version": "0.3.299",
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",
@@ -14,6 +14,11 @@ import {
14
14
  isSafeRedirect,
15
15
  asRouteControl,
16
16
  PylonRouteControl,
17
+ finalizeHeaders,
18
+ makeResponseController,
19
+ makeReadTrackingProxy,
20
+ makeRevocableReadTrackingProxy,
21
+ jsonClone,
17
22
  } from "./ssr-runtime";
18
23
 
19
24
  describe("resolveOrigin — Host-header allowlist (cache-poisoning fence)", () => {
@@ -53,6 +58,134 @@ describe("resolveOrigin — Host-header allowlist (cache-poisoning fence)", () =
53
58
  test("no public origin + untrusted host → empty (relative, never poisoned)", () => {
54
59
  expect(resolveOrigin({ host: "evil.com" })).toBe("");
55
60
  });
61
+
62
+ test("off-loopback never honors X-Forwarded-Proto (no downgrade poisoning)", () => {
63
+ // Even on a TRUSTED host, a client-supplied proto must not change the
64
+ // absolute origin — it feeds og:image/canonical and is cache-keyed only by
65
+ // host, so honoring `http` would downgrade the cached URL for everyone.
66
+ // Off-loopback is ALWAYS https.
67
+ expect(
68
+ resolveOrigin({ host: "www.notbehind.com", publicUrl, forwardedProto: "http" }),
69
+ ).toBe("https://www.notbehind.com");
70
+ expect(
71
+ resolveOrigin({
72
+ host: "www.notbehind.com",
73
+ publicUrl,
74
+ forwardedProto: "javascript:alert(1)",
75
+ }),
76
+ ).toBe("https://www.notbehind.com");
77
+ // Loopback (dev) may be http; an explicit https there is honored.
78
+ expect(resolveOrigin({ host: "localhost:4321", forwardedProto: "http" })).toBe(
79
+ "http://localhost:4321",
80
+ );
81
+ expect(resolveOrigin({ host: "localhost:4321", forwardedProto: "https" })).toBe(
82
+ "https://localhost:4321",
83
+ );
84
+ });
85
+ });
86
+
87
+ describe("reserved x-pylon-* header namespace (cache-proof forgery fence)", () => {
88
+ test("response.setHeader() rejects the reserved x-pylon-* namespace", () => {
89
+ const state = {
90
+ status: 200,
91
+ headers: {} as Record<string, string>,
92
+ cookies: [] as string[],
93
+ };
94
+ const res = makeResponseController(state);
95
+ // Forging the #277 cache proof from userland must throw, not silently set it.
96
+ expect(() => res.setHeader("x-pylon-cacheable", "300")).toThrow(/reserved/i);
97
+ expect(() => res.setHeader("X-Pylon-Anything", "1")).toThrow(/reserved/i);
98
+ // An ordinary header still works.
99
+ res.setHeader("x-custom", "ok");
100
+ expect(state.headers["x-custom"]).toBe("ok");
101
+ });
102
+
103
+ test("finalizeHeaders: x-pylon-* survives ONLY from the trusted internal channel", () => {
104
+ // The #277 proof must come from the 3rd `internal` arg. A page-set header
105
+ // (state.headers) OR a route-handler header (the 2nd `extra` arg, which
106
+ // ssr-form-runtime fills from user-returned headers) is stripped — so
107
+ // userland can't forge the host-side cache verdict through ANY path.
108
+ const state = {
109
+ status: 200,
110
+ headers: { "x-pylon-cacheable": "999", "x-keep": "yes" } as Record<string, string>,
111
+ cookies: [] as string[],
112
+ };
113
+ const out = finalizeHeaders(
114
+ state,
115
+ { "x-pylon-cacheable": "888", "x-extra": "e" }, // untrusted extra → stripped
116
+ { "x-pylon-cacheable": "60" }, // trusted internal → kept
117
+ );
118
+ expect(out["x-pylon-cacheable"]).toBe("60"); // only the trusted value
119
+ expect(out["x-keep"]).toBe("yes");
120
+ expect(out["x-extra"]).toBe("e"); // a non-reserved extra header still merges
121
+
122
+ // No internal proof → NO x-pylon-* survives, from page headers OR extra.
123
+ const out2 = finalizeHeaders(
124
+ { status: 200, headers: { "x-pylon-cacheable": "999" }, cookies: [] as string[] },
125
+ { "x-pylon-cacheable": "777" },
126
+ );
127
+ expect(out2["x-pylon-cacheable"]).toBeUndefined();
128
+ });
129
+
130
+ test("makeReadTrackingProxy trips on get / in / Object.keys / descriptor / spread", () => {
131
+ const probes: Array<(o: any) => unknown> = [
132
+ (o) => o.host,
133
+ (o) => "host" in o,
134
+ (o) => Object.keys(o),
135
+ (o) => Object.getOwnPropertyDescriptor(o, "host"),
136
+ (o) => ({ ...o }),
137
+ ];
138
+ for (const probe of probes) {
139
+ let touched = false;
140
+ const p = makeReadTrackingProxy({ host: "x" }, () => {
141
+ touched = true;
142
+ });
143
+ probe(p);
144
+ expect(touched).toBe(true); // a bare `get` trap would miss in/keys
145
+ }
146
+ // No observation → never touched.
147
+ let t = false;
148
+ makeReadTrackingProxy({ host: "x" }, () => {
149
+ t = true;
150
+ });
151
+ expect(t).toBe(false);
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
+ });
56
189
  });
57
190
 
58
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
@@ -253,6 +259,16 @@ export function makeResponseController(
253
259
  if (!TOKEN_RE.test(name)) {
254
260
  throw new Error(`pylon ssr: invalid header name ${JSON.stringify(name)}`);
255
261
  }
262
+ // `x-pylon-*` is a reserved internal namespace — the host trusts headers
263
+ // like the #277 `x-pylon-cacheable` cache proof as runtime-emitted. Letting
264
+ // userland set one would forge the cache verdict (e.g. mark a personalized
265
+ // render shareable). Reject it loudly.
266
+ if (name.toLowerCase().startsWith("x-pylon-")) {
267
+ throw new Error(
268
+ `pylon ssr: "x-pylon-*" is a reserved internal header namespace and ` +
269
+ `cannot be set via response.setHeader() (got ${JSON.stringify(name)})`,
270
+ );
271
+ }
256
272
  assertNoControlChars(value, "header value");
257
273
  state.headers[name.toLowerCase()] = value;
258
274
  },
@@ -299,11 +315,93 @@ export function makeResponseController(
299
315
  * into one `Set-Cookie` header each (newline is forbidden inside a
300
316
  * cookie, so it can't be turned into header injection).
301
317
  */
318
+ /**
319
+ * Wrap a per-request object so ANY observation — property `get`, `in` (`has`),
320
+ * `Object.keys`/spread/`for…in` (`ownKeys`), or a descriptor probe — calls
321
+ * `onTouch`. Used to mark a render request-specific (vetoing shared caching) the
322
+ * instant it reads auth/headers/cookies. A bare `get` trap misses `in` and
323
+ * `Object.keys`, which would observe the data without tripping the veto.
324
+ * Exported for direct unit testing of that property.
325
+ */
326
+ export function makeReadTrackingProxy(
327
+ obj: Record<string, unknown> | undefined,
328
+ onTouch: () => void,
329
+ ): 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>, {
361
+ get(t, p, r) {
362
+ onTouch();
363
+ return Reflect.get(t, p, r);
364
+ },
365
+ has(t, p) {
366
+ onTouch();
367
+ return Reflect.has(t, p);
368
+ },
369
+ ownKeys(t) {
370
+ onTouch();
371
+ return Reflect.ownKeys(t);
372
+ },
373
+ getOwnPropertyDescriptor(t, p) {
374
+ onTouch();
375
+ return Reflect.getOwnPropertyDescriptor(t, p);
376
+ },
377
+ });
378
+ }
379
+
302
380
  export function finalizeHeaders(
303
381
  state: ResponseState,
382
+ // UNTRUSTED user headers (page-set `state.headers` and, at the form/data-route
383
+ // call sites, a route handler's returned headers). `x-pylon-*` is stripped.
304
384
  extra?: Record<string, string>,
385
+ // TRUSTED runtime headers (e.g. the #277 `x-pylon-cacheable` proof). The ONLY
386
+ // legitimate source of `x-pylon-*`; merged last and never stripped.
387
+ internal?: Record<string, string>,
305
388
  ): Record<string, string> {
306
- const h: Record<string, string> = { ...state.headers, ...(extra ?? {}) };
389
+ // The host TRUSTS `x-pylon-*` headers (cache verdict, etc.). They must come
390
+ // ONLY from `internal` — strip them from BOTH page-set headers AND `extra` so
391
+ // userland can't forge the verdict through any path (setHeader is also
392
+ // rejected at the source, this is defense-in-depth + covers route-handler
393
+ // headers that flow through `extra`).
394
+ const h: Record<string, string> = {};
395
+ const mergeStripped = (src?: Record<string, string>) => {
396
+ if (!src) return;
397
+ for (const [k, v] of Object.entries(src)) {
398
+ if (k.toLowerCase().startsWith("x-pylon-")) continue;
399
+ h[k] = v; // later sources override earlier (page < extra)
400
+ }
401
+ };
402
+ mergeStripped(state.headers);
403
+ mergeStripped(extra);
404
+ if (internal) Object.assign(h, internal); // trusted, never stripped
307
405
  if (!h["content-type"]) h["content-type"] = "text/html; charset=utf-8";
308
406
  if (state.cookies.length > 0) {
309
407
  // Preserve a set-cookie value set via setHeader() (rare) and join it
@@ -811,9 +909,17 @@ export function resolveOrigin(opts: {
811
909
  for (const x of (opts.trustedHostsCsv || "").split(",")) add(x);
812
910
  const isLoopback = LOOPBACK_HOST.test(host);
813
911
  if (isLoopback || allow.has(host)) {
814
- // Only honor a forwarded proto for a TRUSTED host — else an attacker
815
- // could downgrade the cached URL to http://. Default https off-loopback.
816
- const proto = opts.forwardedProto || (isLoopback ? "http" : "https");
912
+ // Off-loopback (prod) we ALWAYS use https and never honor the request's
913
+ // X-Forwarded-Proto. The SSR cache is keyed only by host (not proto), so
914
+ // honoring a client-supplied `http` would poison the cached canonical/OG
915
+ // URL with a downgraded scheme for every subsequent visitor. Loopback
916
+ // (dev) may be plain http. (A genuinely non-https prod origin should set
917
+ // PYLON_PUBLIC_URL explicitly, which takes precedence above.)
918
+ const proto = isLoopback
919
+ ? opts.forwardedProto === "https"
920
+ ? "https"
921
+ : "http"
922
+ : "https";
817
923
  return `${proto}://${host}`;
818
924
  }
819
925
  }
@@ -1132,6 +1238,15 @@ export function buildHydrationTail(args: {
1132
1238
  manifestErr: string | null;
1133
1239
  kind?: "error" | "not-found";
1134
1240
  errorForClient?: { message: string; digest?: string };
1241
+ // PPR Phase 0 (auth-bucketed caching): when set, this render is being stored
1242
+ // as a SHARED cache entry keyed only on session-cookie PRESENCE, so its
1243
+ // hydration tail must carry an IDENTITY-FREE auth — the binary `{ signedIn }`
1244
+ // bit the bucket is keyed on, and NOTHING ELSE (no user_id / tenant_id /
1245
+ // roles / email). Two different signed-in users hitting the same bucketed
1246
+ // page MUST produce a byte-identical tail, or the shared cache replays one
1247
+ // user's identity to another (the #277 leak class, at the body level). The
1248
+ // raw `auth` is replaced AFTER the live-handle strip below.
1249
+ bucketAuth?: { signedIn: boolean };
1135
1250
  }): string {
1136
1251
  // Strip live, non-serializable handles (serverData / response / reset) + the
1137
1252
  // request headers/cookies (SECURITY: never expose the session cookie to
@@ -1146,7 +1261,28 @@ export function buildHydrationTail(args: {
1146
1261
  error: _err,
1147
1262
  ...restProps
1148
1263
  } = args.props ?? {};
1149
- const serializableProps: any = { ...restProps, headers: {}, cookies: {} };
1264
+ let serializableProps: any;
1265
+ if (args.bucketAuth) {
1266
+ // Bucketed render → SHARED entry. Serialize a strict ALLOWLIST of the
1267
+ // framework props that are provably bucket-uniform — never a spread of the
1268
+ // page's props object. A page can alias identity onto a custom key
1269
+ // (`props.leak = props.auth`) without tripping read-tracking; spreading
1270
+ // `restProps` would then serialize that identity into the shared tail. The
1271
+ // allowlisted fields are all path/route-derived (in the cache key) or the
1272
+ // collapsed binary auth bit. (params/searchParams are keyed by pathname; a
1273
+ // bucket request has no query, so searchParams is empty.)
1274
+ serializableProps = {
1275
+ url: restProps.url,
1276
+ params: restProps.params,
1277
+ searchParams: restProps.searchParams,
1278
+ auth: { signedIn: args.bucketAuth.signedIn },
1279
+ session: { exists: args.bucketAuth.signedIn },
1280
+ headers: {},
1281
+ cookies: {},
1282
+ };
1283
+ } else {
1284
+ serializableProps = { ...restProps, headers: {}, cookies: {} };
1285
+ }
1150
1286
  if (args.errorForClient) serializableProps.error = args.errorForClient;
1151
1287
  const hydrationPayload: any = {
1152
1288
  component: args.component,
@@ -1486,6 +1622,18 @@ export function computeCacheVerdict(args: {
1486
1622
  revalidateSecs: number | null;
1487
1623
  forceDynamic: boolean;
1488
1624
  authTouched: boolean;
1625
+ // True when the render read any OTHER per-request input that is not part of
1626
+ // the cache key — request `headers` (incl. non-bucketed ones) or `cookies`
1627
+ // (incl. via generateMetadata). Such a read makes the output request-specific,
1628
+ // so it must veto shared caching exactly like `authTouched`. (Keyed inputs —
1629
+ // pathname, and Host via the host bucket — are handled by the cache key and do
1630
+ // not set this.)
1631
+ dynamicTouched: boolean;
1632
+ // True when the render read `props.session.exists` (Phase 0). For the PLAIN
1633
+ // anonymous cache this is a veto — the output varies by signed-in bucket, and
1634
+ // the anon cache holds one entry for all. (The auth-BUCKET verdict, which keys
1635
+ // the cache on the bucket, permits it; this gate does not.)
1636
+ sessionTouched: boolean;
1489
1637
  cookieCount: number;
1490
1638
  strictPolicies: boolean;
1491
1639
  wantsStream: boolean;
@@ -1495,6 +1643,61 @@ export function computeCacheVerdict(args: {
1495
1643
  args.revalidateSecs != null &&
1496
1644
  !args.forceDynamic &&
1497
1645
  !args.authTouched &&
1646
+ !args.dynamicTouched &&
1647
+ !args.sessionTouched &&
1648
+ args.cookieCount === 0 &&
1649
+ !args.strictPolicies &&
1650
+ !args.wantsStream &&
1651
+ args.status === 200
1652
+ );
1653
+ }
1654
+
1655
+ /**
1656
+ * PPR Phase 0 — the AUTH-BUCKET verdict: may this render be stored as a SHARED
1657
+ * cache entry keyed only on session-cookie PRESENCE (one signed-in shell + one
1658
+ * signed-out shell, both IDENTITY-FREE)? Pure, exported, leak-class tested.
1659
+ *
1660
+ * Like `computeCacheVerdict` EXCEPT `sessionTouched` is ALLOWED (reading
1661
+ * `props.session.exists` to render a binary signed-in/out nav is the whole
1662
+ * point — that bit is what the bucket keys on, and it's identical across all
1663
+ * users in the same bucket). Everything else still vetoes:
1664
+ *
1665
+ * - `authTouched` — reading real `props.auth` (user_id/tenant_id/roles) makes
1666
+ * the output identity-SPECIFIC, not bucket-uniform. This is the load-bearing
1667
+ * gate: ANY identity-dependent output requires reading auth, so vetoing on
1668
+ * `authTouched` proves a bucketable body depends on nothing but the binary
1669
+ * session bit.
1670
+ * - `dynamicTouched` — reading request headers/cookies makes it request-specific.
1671
+ * - `strictPolicies` — in strict mode serverData reads are auth-FILTERED, so the
1672
+ * body/ssrData would vary by identity even without the page reading auth. In
1673
+ * the default (non-strict) mode serverData reads bypass the policy gate and
1674
+ * return identity-independent rows, so the cached body is safe to share within
1675
+ * the bucket. Strict mode therefore must NOT bucket.
1676
+ * - cookieCount / forceDynamic / wantsStream / non-200 — same reasons as the
1677
+ * anon verdict (a Set-Cookie, a streaming head, or an error/redirect can't be
1678
+ * safely shared).
1679
+ *
1680
+ * Requires the explicit `bucketOptIn` (`export const cache = "auth-bucketed"`)
1681
+ * AND a TTL via `export const revalidate = N` — fail-closed: no opt-in or no TTL
1682
+ * → no bucket.
1683
+ */
1684
+ export function computeBucketVerdict(args: {
1685
+ bucketOptIn: boolean;
1686
+ revalidateSecs: number | null;
1687
+ forceDynamic: boolean;
1688
+ authTouched: boolean;
1689
+ dynamicTouched: boolean;
1690
+ cookieCount: number;
1691
+ strictPolicies: boolean;
1692
+ wantsStream: boolean;
1693
+ status: number;
1694
+ }): boolean {
1695
+ return (
1696
+ args.bucketOptIn &&
1697
+ args.revalidateSecs != null &&
1698
+ !args.forceDynamic &&
1699
+ !args.authTouched &&
1700
+ !args.dynamicTouched &&
1498
1701
  args.cookieCount === 0 &&
1499
1702
  !args.strictPolicies &&
1500
1703
  !args.wantsStream &&
@@ -1713,6 +1916,11 @@ export async function handleRenderRoute(
1713
1916
  let React: any = null;
1714
1917
  let renderToReadableStream: any = null;
1715
1918
  let props: any = null;
1919
+ // Revoke fns for this render's per-request proxies (auth/headers/cookies/
1920
+ // session). Declared OUT here so the `finally` revokes them on EVERY exit path
1921
+ // (success, redirect/notFound/error boundary, throw) — neutralizing any
1922
+ // module-stashed reference to a prior request's identity.
1923
+ const proxyRevokers: Array<() => void> = [];
1716
1924
  // Accumulates the resolved results of every `serverData.*` read the page
1717
1925
  // made during render, keyed identically to the client shim. Serialized
1718
1926
  // into `__PYLON_DATA__.ssrData` so hydration replays the same values
@@ -1790,27 +1998,63 @@ export async function handleRenderRoute(
1790
1998
  );
1791
1999
 
1792
2000
  // #277 cache-safety proof. A render is shareable (CDN/disk cacheable) ONLY
1793
- // if its output is auth-INDEPENDENT — so wrap props.auth in a Proxy that
1794
- // flips `authTouched` the moment a page/layout reads it. Reading auth at
1795
- // all (even for an anonymous request) opts the render OUT of caching,
1796
- // because the output could differ by identity. The raw auth is restored
1797
- // before serialization (so the hydration blob carries real values, and so
1798
- // JSON.stringify doesn't trip the Proxy itself).
2001
+ // if its output is independent of per-request inputs — so wrap each in a
2002
+ // read-tracking Proxy that flips a flag the moment a page/layout/
2003
+ // generateMetadata observes it (get / `in` / Object.keys / probe). The
2004
+ // proxies are REVOKED in the `finally` (never restored to raw onto `props`),
2005
+ // so the live props object never aliases a prior request's identity. The tail
2006
+ // serializes from the raw `msg` values via `tailProps` below.
2007
+ const track = (
2008
+ obj: Record<string, unknown> | undefined,
2009
+ onTouch: () => void,
2010
+ ) => {
2011
+ const { proxy, revoke } = makeRevocableReadTrackingProxy(obj, onTouch);
2012
+ proxyRevokers.push(revoke);
2013
+ return proxy;
2014
+ };
2015
+
2016
+ // Reading auth at all (even for an anon request) opts the render OUT of
2017
+ // caching, because the output could differ by identity.
1799
2018
  let authTouched = false;
1800
- const authProxy = new Proxy(msg.auth as Record<string, unknown>, {
1801
- get(target, prop, receiver) {
2019
+ const authProxy = track(
2020
+ msg.auth as Record<string, unknown> | undefined,
2021
+ () => {
1802
2022
  authTouched = true;
1803
- return Reflect.get(target, prop, receiver);
1804
2023
  },
1805
- });
2024
+ );
2025
+
2026
+ // Same proof for the OTHER per-request inputs that aren't in the cache key:
2027
+ // reading `headers` (any header, incl. via generateMetadata) or `cookies`
2028
+ // makes the output request-specific. Only USER code reads props.headers/
2029
+ // cookies — the framework's metadata path reads `msg.headers` directly.
2030
+ let dynamicTouched = false;
2031
+ const touchProxy = (obj: Record<string, unknown> | undefined) =>
2032
+ track(obj, () => {
2033
+ dynamicTouched = true;
2034
+ });
2035
+
2036
+ // Phase 0 (auth bucketing): `props.session.exists` — the identity-FREE
2037
+ // presence bit, so a page can render a binary auth nav WITHOUT reading real
2038
+ // `auth`. Reading it sets `sessionTouched` (separate from authTouched): it
2039
+ // vetoes the plain anonymous cache (the output now varies by signed-in
2040
+ // bucket) but is PERMITTED by the bucket verdict (which keys the cache on the
2041
+ // bucket).
2042
+ let sessionTouched = false;
2043
+ const sessionProxy = track(
2044
+ { exists: msg.session_present === true },
2045
+ () => {
2046
+ sessionTouched = true;
2047
+ },
2048
+ );
1806
2049
 
1807
2050
  props = {
1808
2051
  url: msg.url,
1809
2052
  params: msg.params,
1810
2053
  searchParams: msg.search_params,
1811
- headers: msg.headers,
1812
- cookies: msg.cookies,
2054
+ headers: touchProxy(msg.headers as Record<string, unknown> | undefined),
2055
+ cookies: touchProxy(msg.cookies as Record<string, unknown> | undefined),
1813
2056
  auth: authProxy,
2057
+ session: sessionProxy,
1814
2058
  // Response controller — a page/layout calls response.setStatus /
1815
2059
  // setHeader / setCookie / redirect / notFound to shape the reply.
1816
2060
  response,
@@ -1818,6 +2062,23 @@ export async function handleRenderRoute(
1818
2062
  serverData,
1819
2063
  };
1820
2064
 
2065
+ // PPR Phase 0: an immutable, pre-render SNAPSHOT of the only props a bucketed
2066
+ // (SHARED) tail may serialize — url/params/searchParams, all path-derived and
2067
+ // in the cache key. Deep-cloned NOW, before any page/generateMetadata code
2068
+ // runs, because `props.params`/`searchParams` ARE the live `msg` objects: a
2069
+ // page could mutate a nested field to smuggle identity (e.g.
2070
+ // `props.searchParams.leak = props.auth`) WITHOUT tripping read-tracking, and
2071
+ // a shallow allowlist would copy that mutation by reference into the shared
2072
+ // tail. Captured only for opted-in pages (zero cost otherwise).
2073
+ const bucketOptIn = (mod as any).cache === "auth-bucketed";
2074
+ const bucketTailBase = bucketOptIn
2075
+ ? {
2076
+ url: msg.url,
2077
+ params: jsonClone(msg.params),
2078
+ searchParams: jsonClone(msg.search_params),
2079
+ }
2080
+ : null;
2081
+
1821
2082
  // SEO metadata: static `export const metadata` or dynamic
1822
2083
  // `export async function generateMetadata(props)`. Awaited before the
1823
2084
  // first byte, so keep it to cheap derivations (params → title); heavy
@@ -1974,18 +2235,60 @@ export async function handleRenderRoute(
1974
2235
  // `!Loading`) is the gate: a `streaming = true` page has `Loading` null but
1975
2236
  // `wantsStream` true, and must still be excluded. Fail-closed. (See
1976
2237
  // computeCacheVerdict — pure + unit-tested for the leak class.)
1977
- const cacheable = computeCacheVerdict({
2238
+ // PPR Phase 0: `bucketOptIn` (`export const cache = "auth-bucketed"`) was
2239
+ // resolved at props-construction time (it gates the immutable bucketTailBase
2240
+ // snapshot). A bucketed render and the plain anon cache are mutually exclusive
2241
+ // — `cacheable` excludes the opt-in so a bucket page never also emits the anon
2242
+ // proof.
2243
+ const cacheable =
2244
+ !bucketOptIn &&
2245
+ computeCacheVerdict({
2246
+ revalidateSecs,
2247
+ forceDynamic,
2248
+ authTouched,
2249
+ dynamicTouched,
2250
+ sessionTouched,
2251
+ cookieCount: responseState.cookies.length,
2252
+ strictPolicies,
2253
+ wantsStream,
2254
+ status: responseState.status,
2255
+ });
2256
+ // The bucket verdict permits `sessionTouched` (reading the binary signed-in
2257
+ // bit) but still vetoes any real-auth read. When true, this render is stored
2258
+ // as a shared, identity-free, session-presence-keyed entry — so its
2259
+ // hydration tail MUST be anonymized (bucketAuth below) and it advertises the
2260
+ // TRUSTED internal `x-pylon-bucket` proof for the host to key + store it.
2261
+ const bucketable = computeBucketVerdict({
2262
+ bucketOptIn,
1978
2263
  revalidateSecs,
1979
2264
  forceDynamic,
1980
2265
  authTouched,
2266
+ dynamicTouched,
1981
2267
  cookieCount: responseState.cookies.length,
1982
2268
  strictPolicies,
1983
2269
  wantsStream,
1984
2270
  status: responseState.status,
1985
2271
  });
1986
- // Restore the raw auth before any serialization below (the Proxy was only
1987
- // for the render-time auth-touch probe).
1988
- if (props) props.auth = msg.auth;
2272
+ // Serialization view of props built from the RAW `msg` values — the live
2273
+ // `props` (with its read-tracking proxies) is NEVER mutated back to raw, so
2274
+ // it can't become a vehicle that leaks a prior request's identity to a later
2275
+ // render (the proxies are revoked in `finally`). headers/cookies are stripped
2276
+ // inside buildHydrationTail; auth uses the raw value (so anon auth stays
2277
+ // falsy/undefined rather than the proxy's empty `{}`). For a BUCKETED render,
2278
+ // url/params/searchParams come from the pre-render `bucketTailBase` snapshot —
2279
+ // NOT the live (page-mutable) props — so a page can't smuggle identity through
2280
+ // a nested field into the shared tail. buildHydrationTail's bucket allowlist
2281
+ // then serializes only these fields + the collapsed { signedIn } bit.
2282
+ const tailProps = props
2283
+ ? {
2284
+ ...props,
2285
+ auth: msg.auth,
2286
+ headers: msg.headers,
2287
+ cookies: msg.cookies,
2288
+ session: { exists: msg.session_present === true },
2289
+ ...(bucketable && bucketTailBase ? bucketTailBase : {}),
2290
+ }
2291
+ : props;
1989
2292
  // #278: on a STREAMING render the head commits NOW, before suspended
1990
2293
  // subtrees run. Snapshot what's committed so we can detect (after EOF) a
1991
2294
  // late response.setStatus/setCookie/setHeader from a suspended subtree that
@@ -2005,7 +2308,16 @@ export async function handleRenderRoute(
2005
2308
  status: responseState.status,
2006
2309
  headers: finalizeHeaders(
2007
2310
  responseState,
2008
- cacheable ? { "x-pylon-cacheable": String(revalidateSecs) } : {},
2311
+ undefined,
2312
+ // The #277 anon proof and the Phase 0 bucket proof both ride the TRUSTED
2313
+ // `internal` channel (never stripped), so userland (page setHeader /
2314
+ // route-handler headers via `extra`) can't forge either. Mutually
2315
+ // exclusive (bucketOptIn splits the two verdicts).
2316
+ bucketable
2317
+ ? { "x-pylon-bucket": String(revalidateSecs) }
2318
+ : cacheable
2319
+ ? { "x-pylon-cacheable": String(revalidateSecs) }
2320
+ : undefined,
2009
2321
  ),
2010
2322
  });
2011
2323
 
@@ -2144,7 +2456,7 @@ export async function handleRenderRoute(
2144
2456
  const tail = buildHydrationTail({
2145
2457
  component: msg.component,
2146
2458
  layouts: msg.layouts ?? [],
2147
- props,
2459
+ props: tailProps,
2148
2460
  ssrData: ssrValueCache,
2149
2461
  manifestRoute: preloadManifestRoute,
2150
2462
  publicPrefix: preloadPublicPrefix,
@@ -2154,6 +2466,11 @@ export async function handleRenderRoute(
2154
2466
  ? "error"
2155
2467
  : "not-found"
2156
2468
  : undefined,
2469
+ // A bucketed render is stored shared → its tail must carry ONLY the
2470
+ // binary signed-in bit, never this request's real identity.
2471
+ bucketAuth: bucketable
2472
+ ? { signedIn: msg.session_present === true }
2473
+ : undefined,
2157
2474
  });
2158
2475
  sendChunk(tail);
2159
2476
  }
@@ -2246,5 +2563,17 @@ export async function handleRenderRoute(
2246
2563
  message:
2247
2564
  devMode && err?.stack ? String(err.stack) : err?.message ?? String(err),
2248
2565
  });
2566
+ } finally {
2567
+ // Revoke this render's per-request proxies. Any reference a page stashed in
2568
+ // module-level state (props or props.auth/headers/cookies/session) now throws
2569
+ // on access from a LATER render — fail-closed, so a prior request's identity
2570
+ // can never silently enter a cached body without tripping read-tracking.
2571
+ for (const revoke of proxyRevokers) {
2572
+ try {
2573
+ revoke();
2574
+ } catch {
2575
+ // best-effort
2576
+ }
2577
+ }
2249
2578
  }
2250
2579
  }
@@ -18,6 +18,7 @@ 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,
@@ -96,6 +97,8 @@ describe("computeCacheVerdict (the #277 leak-class gate)", () => {
96
97
  revalidateSecs: 60 as number | null,
97
98
  forceDynamic: false,
98
99
  authTouched: false,
100
+ dynamicTouched: false,
101
+ sessionTouched: false,
99
102
  cookieCount: 0,
100
103
  strictPolicies: false,
101
104
  wantsStream: false,
@@ -110,6 +113,8 @@ describe("computeCacheVerdict (the #277 leak-class gate)", () => {
110
113
  expect(computeCacheVerdict({ ...base, revalidateSecs: null })).toBe(false); // no opt-in
111
114
  expect(computeCacheVerdict({ ...base, forceDynamic: true })).toBe(false);
112
115
  expect(computeCacheVerdict({ ...base, authTouched: true })).toBe(false); // read auth
116
+ expect(computeCacheVerdict({ ...base, dynamicTouched: true })).toBe(false); // read headers/cookies
117
+ expect(computeCacheVerdict({ ...base, sessionTouched: true })).toBe(false); // read session.exists (anon cache)
113
118
  expect(computeCacheVerdict({ ...base, cookieCount: 1 })).toBe(false); // set a cookie
114
119
  expect(computeCacheVerdict({ ...base, strictPolicies: true })).toBe(false);
115
120
  expect(computeCacheVerdict({ ...base, wantsStream: true })).toBe(false); // STREAMING
@@ -133,6 +138,8 @@ describe("computeCacheVerdict (the #277 leak-class gate)", () => {
133
138
  revalidateSecs,
134
139
  forceDynamic,
135
140
  authTouched,
141
+ dynamicTouched: false,
142
+ sessionTouched: false,
136
143
  cookieCount,
137
144
  strictPolicies,
138
145
  wantsStream,
@@ -148,6 +155,73 @@ describe("computeCacheVerdict (the #277 leak-class gate)", () => {
148
155
  });
149
156
  });
150
157
 
158
+ describe("computeBucketVerdict (PPR Phase 0 — session-presence shared cache)", () => {
159
+ const base = {
160
+ bucketOptIn: true,
161
+ revalidateSecs: 60 as number | null,
162
+ forceDynamic: false,
163
+ authTouched: false,
164
+ dynamicTouched: false,
165
+ cookieCount: 0,
166
+ strictPolicies: false,
167
+ wantsStream: false,
168
+ status: 200,
169
+ };
170
+
171
+ test("a clean opted-in bucketed 200 is bucketable", () => {
172
+ expect(computeBucketVerdict(base)).toBe(true);
173
+ });
174
+
175
+ test("sessionTouched is NOT a veto here (the whole point of a bucket)", () => {
176
+ // computeBucketVerdict has no sessionTouched field — reading session.exists
177
+ // is allowed. This test documents the contract by construction: the same
178
+ // base (which a bucket render reaches with sessionTouched=true) buckets.
179
+ expect(computeBucketVerdict(base)).toBe(true);
180
+ });
181
+
182
+ test("no opt-in → never buckets, even when otherwise clean", () => {
183
+ expect(computeBucketVerdict({ ...base, bucketOptIn: false })).toBe(false);
184
+ });
185
+
186
+ test("every identity/safety veto flips it to non-bucketable (fail-closed)", () => {
187
+ expect(computeBucketVerdict({ ...base, revalidateSecs: null })).toBe(false); // no TTL
188
+ expect(computeBucketVerdict({ ...base, forceDynamic: true })).toBe(false);
189
+ expect(computeBucketVerdict({ ...base, authTouched: true })).toBe(false); // read real auth
190
+ expect(computeBucketVerdict({ ...base, dynamicTouched: true })).toBe(false); // headers/cookies
191
+ expect(computeBucketVerdict({ ...base, cookieCount: 1 })).toBe(false); // set a cookie
192
+ expect(computeBucketVerdict({ ...base, strictPolicies: true })).toBe(false); // auth-filtered reads
193
+ expect(computeBucketVerdict({ ...base, wantsStream: true })).toBe(false); // streaming head
194
+ expect(computeBucketVerdict({ ...base, status: 404 })).toBe(false);
195
+ expect(computeBucketVerdict({ ...base, status: 307 })).toBe(false);
196
+ });
197
+
198
+ test("LOAD-BEARING: authTouched ALWAYS vetoes regardless of opt-in", () => {
199
+ // The proof that a bucketable body is identity-free: any output that depends
200
+ // on identity must read props.auth, which sets authTouched. So authTouched
201
+ // ⟹ !bucketable over the full cross-product.
202
+ const bools = [false, true];
203
+ for (const bucketOptIn of bools)
204
+ for (const dynamicTouched of bools)
205
+ for (const strictPolicies of bools)
206
+ for (const wantsStream of bools)
207
+ for (const cookieCount of [0, 1])
208
+ for (const status of [200, 404]) {
209
+ const v = computeBucketVerdict({
210
+ bucketOptIn,
211
+ revalidateSecs: 60,
212
+ forceDynamic: false,
213
+ authTouched: true,
214
+ dynamicTouched,
215
+ cookieCount,
216
+ strictPolicies,
217
+ wantsStream,
218
+ status,
219
+ });
220
+ expect(v).toBe(false);
221
+ }
222
+ });
223
+ });
224
+
151
225
  describe("diffCommittedResponse (#278 late-response.* drop detector)", () => {
152
226
  const snap = (over: any = {}) => ({
153
227
  status: 200,
@@ -300,3 +374,144 @@ describe("hydration tail ordering (#278: data blob before entry script)", () =>
300
374
  expect(tail).toContain('"body":"hi"');
301
375
  });
302
376
  });
377
+
378
+ // ---------------------------------------------------------------------------
379
+ // PPR Phase 0 — bucketed hydration tail must be IDENTITY-FREE
380
+ // ---------------------------------------------------------------------------
381
+
382
+ // Pull the __PYLON_DATA__ payload back out of a tail. JSON.parse natively
383
+ // decodes the unicode escapes (< etc.) buildHydrationTail applies.
384
+ function parsePylonData(tail: string): any {
385
+ const m = tail.match(
386
+ /<script id="__PYLON_DATA__" type="application\/json">([\s\S]*?)<\/script>/,
387
+ );
388
+ if (!m) throw new Error("no __PYLON_DATA__ blob in tail");
389
+ return JSON.parse(m[1]);
390
+ }
391
+
392
+ describe("bucketed hydration tail (PPR Phase 0 leak class)", () => {
393
+ // Two DIFFERENT signed-in identities. A non-bucketed tail serializes each
394
+ // verbatim; a bucketed tail MUST collapse both to `{ signedIn: true }`.
395
+ const alice = {
396
+ user_id: "user_alice",
397
+ tenant_id: "tenant_1",
398
+ roles: ["admin"],
399
+ email: "alice@x.com",
400
+ };
401
+ const bob = {
402
+ user_id: "user_bob",
403
+ tenant_id: "tenant_2",
404
+ roles: ["member"],
405
+ email: "bob@y.com",
406
+ };
407
+ const baseArgs = (auth: any) => ({
408
+ component: "app/page",
409
+ layouts: [],
410
+ props: {
411
+ url: "/",
412
+ params: {},
413
+ searchParams: {},
414
+ auth,
415
+ session: { exists: true },
416
+ },
417
+ ssrData: {},
418
+ manifestRoute: { file: "index.js", imports: [], css: [] },
419
+ publicPrefix: "/_pylon/build/",
420
+ manifestErr: null,
421
+ });
422
+
423
+ test("non-bucketed tail keeps real auth (unchanged legacy behavior)", () => {
424
+ const tail = buildHydrationTail(baseArgs(alice));
425
+ expect(parsePylonData(tail).props.auth).toEqual(alice);
426
+ });
427
+
428
+ test("bucketed signed-in tail carries ONLY { signedIn: true }", () => {
429
+ const tail = buildHydrationTail({
430
+ ...baseArgs(alice),
431
+ bucketAuth: { signedIn: true },
432
+ });
433
+ const data = parsePylonData(tail);
434
+ expect(data.props.auth).toEqual({ signedIn: true });
435
+ expect(data.props.session).toEqual({ exists: true });
436
+ // No identity escapes — not in props, not anywhere in the rendered tail.
437
+ expect(tail).not.toContain("user_alice");
438
+ expect(tail).not.toContain("tenant_1");
439
+ expect(tail).not.toContain("alice@x.com");
440
+ expect(tail).not.toContain("admin");
441
+ });
442
+
443
+ test("bucketed signed-out tail carries ONLY { signedIn: false }", () => {
444
+ const tail = buildHydrationTail({
445
+ ...baseArgs(null),
446
+ props: {
447
+ url: "/",
448
+ params: {},
449
+ searchParams: {},
450
+ auth: null,
451
+ session: { exists: false },
452
+ },
453
+ bucketAuth: { signedIn: false },
454
+ });
455
+ const data = parsePylonData(tail);
456
+ expect(data.props.auth).toEqual({ signedIn: false });
457
+ expect(data.props.session).toEqual({ exists: false });
458
+ });
459
+
460
+ test("LEAK INVARIANT: two identities → byte-identical bucketed tail", () => {
461
+ // The load-bearing property. If these two tails differed by a single byte,
462
+ // a shared cache entry would replay alice's identity to bob (or vice-versa).
463
+ const aTail = buildHydrationTail({
464
+ ...baseArgs(alice),
465
+ bucketAuth: { signedIn: true },
466
+ });
467
+ const bTail = buildHydrationTail({
468
+ ...baseArgs(bob),
469
+ bucketAuth: { signedIn: true },
470
+ });
471
+ expect(aTail).toBe(bTail);
472
+ });
473
+
474
+ test("bucketed tail DROPS page-added props (alias-identity fence, codex #4)", () => {
475
+ // A page can alias identity onto a custom key without tripping read-tracking
476
+ // (`props.leak = props.auth` — props is a plain object, no proxy trap). A
477
+ // bucketed (SHARED) tail must therefore serialize a strict ALLOWLIST of
478
+ // framework props, never a spread — else `leak` would carry one user's
479
+ // identity into every signed-in visitor's cached page.
480
+ const tail = buildHydrationTail({
481
+ ...baseArgs(alice),
482
+ props: {
483
+ url: "/",
484
+ params: { id: "x" },
485
+ searchParams: {},
486
+ auth: alice,
487
+ session: { exists: true },
488
+ // Page-aliased identity + a copied cookie bag.
489
+ leak: { uid: alice.user_id },
490
+ sneaky: alice.email,
491
+ },
492
+ bucketAuth: { signedIn: true },
493
+ });
494
+ const data = parsePylonData(tail);
495
+ expect(data.props.auth).toEqual({ signedIn: true });
496
+ // Allowlisted framework fields survive…
497
+ expect(data.props.url).toBe("/");
498
+ expect(data.props.params).toEqual({ id: "x" });
499
+ // …but the page-added keys are GONE, and no identity is anywhere in the tail.
500
+ expect(data.props.leak).toBeUndefined();
501
+ expect(data.props.sneaky).toBeUndefined();
502
+ expect(tail).not.toContain("user_alice");
503
+ expect(tail).not.toContain("alice@x.com");
504
+ });
505
+
506
+ test("NON-bucket tail still preserves page-added props (no behavior change)", () => {
507
+ // The allowlist applies ONLY to bucketed renders. A normal (non-shared)
508
+ // render keeps serializing all props so hydration matches the server tree.
509
+ const tail = buildHydrationTail({
510
+ ...baseArgs(alice),
511
+ props: { url: "/", params: {}, searchParams: {}, auth: alice, custom: 42 },
512
+ });
513
+ const data = parsePylonData(tail);
514
+ expect(data.props.custom).toBe(42);
515
+ expect(data.props.auth).toEqual(alice);
516
+ });
517
+ });