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