@pylonsync/functions 0.3.298 → 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
@@ -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
@@ -334,6 +364,9 @@ export declare function buildHydrationTail(args: {
334
364
  message: string;
335
365
  digest?: string;
336
366
  };
367
+ bucketAuth?: {
368
+ signedIn: boolean;
369
+ };
337
370
  }): string;
338
371
  /**
339
372
  * A short, non-reversible correlation id for an error — surfaced to the
@@ -361,6 +394,47 @@ export declare function computeRevalidateSecs(mod: any): number | null;
361
394
  * cached). Fail-closed: every condition must hold.
362
395
  */
363
396
  export declare function computeCacheVerdict(args: {
397
+ revalidateSecs: number | null;
398
+ forceDynamic: boolean;
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;
364
438
  revalidateSecs: number | null;
365
439
  forceDynamic: boolean;
366
440
  authTouched: boolean;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pylonsync/functions",
3
- "version": "0.3.298",
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",
@@ -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);
@@ -1202,6 +1238,15 @@ export function buildHydrationTail(args: {
1202
1238
  manifestErr: string | null;
1203
1239
  kind?: "error" | "not-found";
1204
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 };
1205
1250
  }): string {
1206
1251
  // Strip live, non-serializable handles (serverData / response / reset) + the
1207
1252
  // request headers/cookies (SECURITY: never expose the session cookie to
@@ -1216,7 +1261,28 @@ export function buildHydrationTail(args: {
1216
1261
  error: _err,
1217
1262
  ...restProps
1218
1263
  } = args.props ?? {};
1219
- 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
+ }
1220
1286
  if (args.errorForClient) serializableProps.error = args.errorForClient;
1221
1287
  const hydrationPayload: any = {
1222
1288
  component: args.component,
@@ -1563,12 +1629,71 @@ export function computeCacheVerdict(args: {
1563
1629
  // pathname, and Host via the host bucket — are handled by the cache key and do
1564
1630
  // not set this.)
1565
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;
1637
+ cookieCount: number;
1638
+ strictPolicies: boolean;
1639
+ wantsStream: boolean;
1640
+ status: number;
1641
+ }): boolean {
1642
+ return (
1643
+ args.revalidateSecs != null &&
1644
+ !args.forceDynamic &&
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;
1566
1690
  cookieCount: number;
1567
1691
  strictPolicies: boolean;
1568
1692
  wantsStream: boolean;
1569
1693
  status: number;
1570
1694
  }): boolean {
1571
1695
  return (
1696
+ args.bucketOptIn &&
1572
1697
  args.revalidateSecs != null &&
1573
1698
  !args.forceDynamic &&
1574
1699
  !args.authTouched &&
@@ -1791,6 +1916,11 @@ export async function handleRenderRoute(
1791
1916
  let React: any = null;
1792
1917
  let renderToReadableStream: any = null;
1793
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> = [];
1794
1924
  // Accumulates the resolved results of every `serverData.*` read the page
1795
1925
  // made during render, keyed identically to the client shim. Serialized
1796
1926
  // into `__PYLON_DATA__.ssrData` so hydration replays the same values
@@ -1869,14 +1999,24 @@ export async function handleRenderRoute(
1869
1999
 
1870
2000
  // #277 cache-safety proof. A render is shareable (CDN/disk cacheable) ONLY
1871
2001
  // 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.
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
+ };
1875
2015
 
1876
2016
  // Reading auth at all (even for an anon request) opts the render OUT of
1877
2017
  // caching, because the output could differ by identity.
1878
2018
  let authTouched = false;
1879
- const authProxy = makeReadTrackingProxy(
2019
+ const authProxy = track(
1880
2020
  msg.auth as Record<string, unknown> | undefined,
1881
2021
  () => {
1882
2022
  authTouched = true;
@@ -1889,10 +2029,24 @@ export async function handleRenderRoute(
1889
2029
  // cookies — the framework's metadata path reads `msg.headers` directly.
1890
2030
  let dynamicTouched = false;
1891
2031
  const touchProxy = (obj: Record<string, unknown> | undefined) =>
1892
- makeReadTrackingProxy(obj, () => {
2032
+ track(obj, () => {
1893
2033
  dynamicTouched = true;
1894
2034
  });
1895
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
+ );
2049
+
1896
2050
  props = {
1897
2051
  url: msg.url,
1898
2052
  params: msg.params,
@@ -1900,6 +2054,7 @@ export async function handleRenderRoute(
1900
2054
  headers: touchProxy(msg.headers as Record<string, unknown> | undefined),
1901
2055
  cookies: touchProxy(msg.cookies as Record<string, unknown> | undefined),
1902
2056
  auth: authProxy,
2057
+ session: sessionProxy,
1903
2058
  // Response controller — a page/layout calls response.setStatus /
1904
2059
  // setHeader / setCookie / redirect / notFound to shape the reply.
1905
2060
  response,
@@ -1907,6 +2062,23 @@ export async function handleRenderRoute(
1907
2062
  serverData,
1908
2063
  };
1909
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
+
1910
2082
  // SEO metadata: static `export const metadata` or dynamic
1911
2083
  // `export async function generateMetadata(props)`. Awaited before the
1912
2084
  // first byte, so keep it to cheap derivations (params → title); heavy
@@ -2063,7 +2235,31 @@ export async function handleRenderRoute(
2063
2235
  // `!Loading`) is the gate: a `streaming = true` page has `Loading` null but
2064
2236
  // `wantsStream` true, and must still be excluded. Fail-closed. (See
2065
2237
  // computeCacheVerdict — pure + unit-tested for the leak class.)
2066
- 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,
2067
2263
  revalidateSecs,
2068
2264
  forceDynamic,
2069
2265
  authTouched,
@@ -2073,13 +2269,26 @@ export async function handleRenderRoute(
2073
2269
  wantsStream,
2074
2270
  status: responseState.status,
2075
2271
  });
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
- }
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;
2083
2292
  // #278: on a STREAMING render the head commits NOW, before suspended
2084
2293
  // subtrees run. Snapshot what's committed so we can detect (after EOF) a
2085
2294
  // late response.setStatus/setCookie/setHeader from a suspended subtree that
@@ -2100,10 +2309,15 @@ export async function handleRenderRoute(
2100
2309
  headers: finalizeHeaders(
2101
2310
  responseState,
2102
2311
  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,
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,
2107
2321
  ),
2108
2322
  });
2109
2323
 
@@ -2242,7 +2456,7 @@ export async function handleRenderRoute(
2242
2456
  const tail = buildHydrationTail({
2243
2457
  component: msg.component,
2244
2458
  layouts: msg.layouts ?? [],
2245
- props,
2459
+ props: tailProps,
2246
2460
  ssrData: ssrValueCache,
2247
2461
  manifestRoute: preloadManifestRoute,
2248
2462
  publicPrefix: preloadPublicPrefix,
@@ -2252,6 +2466,11 @@ export async function handleRenderRoute(
2252
2466
  ? "error"
2253
2467
  : "not-found"
2254
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,
2255
2474
  });
2256
2475
  sendChunk(tail);
2257
2476
  }
@@ -2344,5 +2563,17 @@ export async function handleRenderRoute(
2344
2563
  message:
2345
2564
  devMode && err?.stack ? String(err.stack) : err?.message ?? String(err),
2346
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
+ }
2347
2578
  }
2348
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,
@@ -97,6 +98,7 @@ describe("computeCacheVerdict (the #277 leak-class gate)", () => {
97
98
  forceDynamic: false,
98
99
  authTouched: false,
99
100
  dynamicTouched: false,
101
+ sessionTouched: false,
100
102
  cookieCount: 0,
101
103
  strictPolicies: false,
102
104
  wantsStream: false,
@@ -112,6 +114,7 @@ describe("computeCacheVerdict (the #277 leak-class gate)", () => {
112
114
  expect(computeCacheVerdict({ ...base, forceDynamic: true })).toBe(false);
113
115
  expect(computeCacheVerdict({ ...base, authTouched: true })).toBe(false); // read auth
114
116
  expect(computeCacheVerdict({ ...base, dynamicTouched: true })).toBe(false); // read headers/cookies
117
+ expect(computeCacheVerdict({ ...base, sessionTouched: true })).toBe(false); // read session.exists (anon cache)
115
118
  expect(computeCacheVerdict({ ...base, cookieCount: 1 })).toBe(false); // set a cookie
116
119
  expect(computeCacheVerdict({ ...base, strictPolicies: true })).toBe(false);
117
120
  expect(computeCacheVerdict({ ...base, wantsStream: true })).toBe(false); // STREAMING
@@ -136,6 +139,7 @@ describe("computeCacheVerdict (the #277 leak-class gate)", () => {
136
139
  forceDynamic,
137
140
  authTouched,
138
141
  dynamicTouched: false,
142
+ sessionTouched: false,
139
143
  cookieCount,
140
144
  strictPolicies,
141
145
  wantsStream,
@@ -151,6 +155,73 @@ describe("computeCacheVerdict (the #277 leak-class gate)", () => {
151
155
  });
152
156
  });
153
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
+
154
225
  describe("diffCommittedResponse (#278 late-response.* drop detector)", () => {
155
226
  const snap = (over: any = {}) => ({
156
227
  status: 200,
@@ -303,3 +374,144 @@ describe("hydration tail ordering (#278: data blob before entry script)", () =>
303
374
  expect(tail).toContain('"body":"hi"');
304
375
  });
305
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
+ });