@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.
- package/dist/ssr-runtime.d.ts +74 -0
- package/package.json +1 -1
- package/src/ssr-runtime.test.ts +38 -0
- package/src/ssr-runtime.ts +251 -20
- package/src/ssr-streaming.test.ts +212 -0
package/dist/ssr-runtime.d.ts
CHANGED
|
@@ -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
package/src/ssr-runtime.test.ts
CHANGED
|
@@ -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.
|
package/src/ssr-runtime.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
|
1873
|
-
//
|
|
1874
|
-
//
|
|
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 =
|
|
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
|
-
|
|
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
|
|
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
|
-
//
|
|
2077
|
-
//
|
|
2078
|
-
|
|
2079
|
-
|
|
2080
|
-
|
|
2081
|
-
|
|
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
|
|
2104
|
-
// so userland (page setHeader /
|
|
2105
|
-
// forge
|
|
2106
|
-
|
|
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
|
+
});
|