@pylonsync/functions 0.3.271 → 0.3.273
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/package.json +1 -1
- package/src/index.ts +3 -0
- package/src/member.test.ts +96 -0
- package/src/member.ts +47 -0
- package/src/runtime.ts +41 -4
- package/src/ssr-client-boundary.ts +251 -0
- package/src/ssr-client-bundler.test.ts +1 -1
- package/src/ssr-client-bundler.ts +32 -194
- package/src/ssr-runtime-boundary.test.ts +225 -0
- package/src/types.ts +59 -0
package/package.json
CHANGED
package/src/index.ts
CHANGED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
// Unit tests for `ctx.requireMember` (the org membership / role gate). The
|
|
2
|
+
// motivating bug class: actions + mutations bypass entity read policies, so a
|
|
3
|
+
// function that trusts an attacker-supplied orgId is an IDOR unless it
|
|
4
|
+
// re-checks membership. These assert the gate rejects non-members + wrong
|
|
5
|
+
// roles, and reads the right entity/fields.
|
|
6
|
+
import { describe, expect, test } from "bun:test";
|
|
7
|
+
import { makeRequireMember } from "./member";
|
|
8
|
+
|
|
9
|
+
// A spy `read` that records its (entity, filter) and returns canned rows.
|
|
10
|
+
function spyRead(rows: any[]) {
|
|
11
|
+
const calls: Array<{ entity: string; filter: Record<string, unknown> }> = [];
|
|
12
|
+
const read = async (entity: string, filter: Record<string, unknown>) => {
|
|
13
|
+
calls.push({ entity, filter });
|
|
14
|
+
return rows;
|
|
15
|
+
};
|
|
16
|
+
return { read, calls };
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
async function codeOf(fn: () => Promise<unknown>): Promise<string | undefined> {
|
|
20
|
+
try {
|
|
21
|
+
await fn();
|
|
22
|
+
return undefined;
|
|
23
|
+
} catch (e: any) {
|
|
24
|
+
return e?.code;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
describe("ctx.requireMember", () => {
|
|
29
|
+
test("UNAUTHENTICATED when there is no signed-in user", async () => {
|
|
30
|
+
const { read, calls } = spyRead([{ role: "owner" }]);
|
|
31
|
+
const requireMember = makeRequireMember(null, read);
|
|
32
|
+
expect(await codeOf(() => requireMember("org_1"))).toBe("UNAUTHENTICATED");
|
|
33
|
+
// Must short-circuit BEFORE touching the database.
|
|
34
|
+
expect(calls).toEqual([]);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test("MISSING_ORG when orgId is empty", async () => {
|
|
38
|
+
const { read } = spyRead([{ role: "owner" }]);
|
|
39
|
+
const requireMember = makeRequireMember("u1", read);
|
|
40
|
+
expect(await codeOf(() => requireMember(""))).toBe("MISSING_ORG");
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test("FORBIDDEN when the caller is not a member (no row)", async () => {
|
|
44
|
+
const { read } = spyRead([]);
|
|
45
|
+
const requireMember = makeRequireMember("u1", read);
|
|
46
|
+
expect(await codeOf(() => requireMember("org_1"))).toBe("FORBIDDEN");
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test("returns the membership row for any member when no role is required", async () => {
|
|
50
|
+
const row = { id: "m1", role: "member", userId: "u1", orgId: "org_1" };
|
|
51
|
+
const { read, calls } = spyRead([row]);
|
|
52
|
+
const requireMember = makeRequireMember("u1", read);
|
|
53
|
+
expect(await requireMember("org_1")).toEqual(row);
|
|
54
|
+
// Reads the conventional OrgMember entity keyed on (orgId, userId).
|
|
55
|
+
expect(calls).toEqual([
|
|
56
|
+
{ entity: "OrgMember", filter: { orgId: "org_1", userId: "u1" } },
|
|
57
|
+
]);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test("role gate: FORBIDDEN when the member's role isn't allowed", async () => {
|
|
61
|
+
const { read } = spyRead([{ role: "member" }]);
|
|
62
|
+
const requireMember = makeRequireMember("u1", read);
|
|
63
|
+
expect(
|
|
64
|
+
await codeOf(() => requireMember("org_1", { role: ["owner", "admin"] })),
|
|
65
|
+
).toBe("FORBIDDEN");
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test("role gate: passes when the member's role IS allowed (string or array)", async () => {
|
|
69
|
+
const owner = { id: "m1", role: "owner" };
|
|
70
|
+
expect(
|
|
71
|
+
await makeRequireMember("u1", spyRead([owner]).read)("org_1", {
|
|
72
|
+
role: "owner",
|
|
73
|
+
}),
|
|
74
|
+
).toEqual(owner);
|
|
75
|
+
expect(
|
|
76
|
+
await makeRequireMember("u1", spyRead([owner]).read)("org_1", {
|
|
77
|
+
role: ["owner", "admin"],
|
|
78
|
+
}),
|
|
79
|
+
).toEqual(owner);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test("honors a custom membership entity + field names", async () => {
|
|
83
|
+
const { read, calls } = spyRead([{ tier: "admin" }]);
|
|
84
|
+
const requireMember = makeRequireMember("u1", read);
|
|
85
|
+
await requireMember("team_9", {
|
|
86
|
+
entity: "TeamMember",
|
|
87
|
+
orgField: "teamId",
|
|
88
|
+
userField: "memberId",
|
|
89
|
+
roleField: "tier",
|
|
90
|
+
role: "admin",
|
|
91
|
+
});
|
|
92
|
+
expect(calls).toEqual([
|
|
93
|
+
{ entity: "TeamMember", filter: { teamId: "team_9", memberId: "u1" } },
|
|
94
|
+
]);
|
|
95
|
+
});
|
|
96
|
+
});
|
package/src/member.ts
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
// `ctx.requireMember` — the org membership / role authorization gate.
|
|
2
|
+
//
|
|
3
|
+
// Pure + dependency-injected so it's unit-testable and works across all three
|
|
4
|
+
// ctx types: query/mutation read membership via `ctx.db.query`, actions (which
|
|
5
|
+
// have no `ctx.db`) via `ctx.runQuery` to the built-in `__pylonMemberLookup`.
|
|
6
|
+
// runtime.ts wires the appropriate `read` per ctx; member.test.ts drives this
|
|
7
|
+
// directly.
|
|
8
|
+
|
|
9
|
+
import type { MemberRow, RequireMember } from "./types";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Build `ctx.requireMember(orgId, opts)` bound to a membership `read`. Errors
|
|
13
|
+
* carry `.code` (UNAUTHENTICATED / MISSING_ORG / FORBIDDEN) the same way
|
|
14
|
+
* `ctx.error` does — self-contained so it also works on the query ctx, which
|
|
15
|
+
* has no `ctx.error`.
|
|
16
|
+
*/
|
|
17
|
+
export function makeRequireMember(
|
|
18
|
+
userId: string | null | undefined,
|
|
19
|
+
read: (entity: string, filter: Record<string, unknown>) => Promise<any[]>,
|
|
20
|
+
): RequireMember {
|
|
21
|
+
const fail = (code: string, message: string): never => {
|
|
22
|
+
const e = new Error(message);
|
|
23
|
+
(e as any).code = code;
|
|
24
|
+
throw e;
|
|
25
|
+
};
|
|
26
|
+
return async (orgId, opts) => {
|
|
27
|
+
if (!userId) fail("UNAUTHENTICATED", "log in first");
|
|
28
|
+
if (!orgId) fail("MISSING_ORG", "orgId is required");
|
|
29
|
+
const entity = opts?.entity ?? "OrgMember";
|
|
30
|
+
const orgField = opts?.orgField ?? "orgId";
|
|
31
|
+
const userField = opts?.userField ?? "userId";
|
|
32
|
+
const roleField = opts?.roleField ?? "role";
|
|
33
|
+
const rows = await read(entity, {
|
|
34
|
+
[orgField]: orgId,
|
|
35
|
+
[userField]: userId as string,
|
|
36
|
+
});
|
|
37
|
+
const row = rows && rows[0];
|
|
38
|
+
if (!row) fail("FORBIDDEN", "you are not a member of this organization");
|
|
39
|
+
if (opts?.role !== undefined) {
|
|
40
|
+
const allowed = Array.isArray(opts.role) ? opts.role : [opts.role];
|
|
41
|
+
if (!allowed.includes((row as any)[roleField])) {
|
|
42
|
+
fail("FORBIDDEN", `requires one of: ${allowed.join(", ")}`);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return row as MemberRow;
|
|
46
|
+
};
|
|
47
|
+
}
|
package/src/runtime.ts
CHANGED
|
@@ -31,6 +31,7 @@ import type {
|
|
|
31
31
|
FnDefinition,
|
|
32
32
|
AuthInfo,
|
|
33
33
|
} from "./types";
|
|
34
|
+
import { makeRequireMember } from "./member";
|
|
34
35
|
import { validateArgs } from "./validators";
|
|
35
36
|
import { readdirSync } from "fs";
|
|
36
37
|
import { join, basename } from "path";
|
|
@@ -741,6 +742,15 @@ function buildActionCtx(
|
|
|
741
742
|
(err as any).code = code;
|
|
742
743
|
return err;
|
|
743
744
|
},
|
|
745
|
+
// Actions have no ctx.db; read membership via the built-in internal query.
|
|
746
|
+
requireMember: makeRequireMember(auth.userId, (entity, filter) =>
|
|
747
|
+
rpc(callId, {
|
|
748
|
+
type: "run_fn",
|
|
749
|
+
fn_name: "__pylonMemberLookup",
|
|
750
|
+
fn_type: "query",
|
|
751
|
+
args: { entity, filter },
|
|
752
|
+
}) as Promise<any[]>,
|
|
753
|
+
),
|
|
744
754
|
request: normalizedRequest,
|
|
745
755
|
};
|
|
746
756
|
}
|
|
@@ -751,6 +761,19 @@ function buildActionCtx(
|
|
|
751
761
|
|
|
752
762
|
const registry = new Map<string, FnDefinition>();
|
|
753
763
|
|
|
764
|
+
// Built-in internal query backing `ctx.requireMember` on ACTION ctx — actions
|
|
765
|
+
// have no `ctx.db`, so they read membership via `runQuery` to this. Registered
|
|
766
|
+
// before app fns load (so the host sees it in the registration list and an
|
|
767
|
+
// action's runQuery dispatches back here); app fns load by filename basename so
|
|
768
|
+
// they can't collide with this reserved name. The read is policy-gated like any
|
|
769
|
+
// ctx.db read, which is fine: a member can always read their own membership row.
|
|
770
|
+
registry.set("__pylonMemberLookup", {
|
|
771
|
+
type: "query",
|
|
772
|
+
internal: true,
|
|
773
|
+
handler: async (ctx: any, args: any) =>
|
|
774
|
+
ctx.db.query(args.entity, { ...(args.filter ?? {}), $limit: 1 }),
|
|
775
|
+
} as FnDefinition);
|
|
776
|
+
|
|
754
777
|
// ---------------------------------------------------------------------------
|
|
755
778
|
// Handler
|
|
756
779
|
// ---------------------------------------------------------------------------
|
|
@@ -845,15 +868,25 @@ async function handleCall(msg: CallMessage): Promise<void> {
|
|
|
845
868
|
|
|
846
869
|
let ctx: QueryCtx | MutationCtx | ActionCtx;
|
|
847
870
|
switch (def.type) {
|
|
848
|
-
case "query":
|
|
871
|
+
case "query": {
|
|
849
872
|
// ctx.llm is intentionally absent on queries — reactive
|
|
850
873
|
// re-runs would re-bill the LLM call on every dep change.
|
|
851
874
|
// Move LLM calls into actions / mutations.
|
|
852
|
-
|
|
875
|
+
const reader = buildDbReader(msg.call_id);
|
|
876
|
+
ctx = {
|
|
877
|
+
db: reader,
|
|
878
|
+
auth,
|
|
879
|
+
env,
|
|
880
|
+
requireMember: makeRequireMember(auth.userId, (entity, filter) =>
|
|
881
|
+
reader.query(entity, { ...filter, $limit: 1 }),
|
|
882
|
+
),
|
|
883
|
+
};
|
|
853
884
|
break;
|
|
854
|
-
|
|
885
|
+
}
|
|
886
|
+
case "mutation": {
|
|
887
|
+
const writer = buildDbWriter(msg.call_id);
|
|
855
888
|
ctx = {
|
|
856
|
-
db:
|
|
889
|
+
db: writer,
|
|
857
890
|
auth,
|
|
858
891
|
stream,
|
|
859
892
|
scheduler,
|
|
@@ -865,8 +898,12 @@ async function handleCall(msg: CallMessage): Promise<void> {
|
|
|
865
898
|
(err as any).code = code;
|
|
866
899
|
return err;
|
|
867
900
|
},
|
|
901
|
+
requireMember: makeRequireMember(auth.userId, (entity, filter) =>
|
|
902
|
+
writer.query(entity, { ...filter, $limit: 1 }),
|
|
903
|
+
),
|
|
868
904
|
};
|
|
869
905
|
break;
|
|
906
|
+
}
|
|
870
907
|
case "action":
|
|
871
908
|
ctx = buildActionCtx(
|
|
872
909
|
msg.call_id,
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
// SSR client-runtime not-found / error boundary.
|
|
2
|
+
//
|
|
3
|
+
// Extracted from `CLIENT_RUNTIME_SOURCE` in ssr-client-bundler.ts so its
|
|
4
|
+
// RENDER behavior is testable with a real React renderer — the inline copy
|
|
5
|
+
// was a string literal, so nothing could render it, and two real regressions
|
|
6
|
+
// (a client-thrown notFound() blanking the page, then rendering with the
|
|
7
|
+
// wrong props and redirecting to /login) shipped because unit/build tests
|
|
8
|
+
// couldn't reach render-time behavior.
|
|
9
|
+
//
|
|
10
|
+
// The bundler writes this module's SOURCE next to the generated client
|
|
11
|
+
// runtime (as `.pylon/client-boundary.ts`) and the runtime imports
|
|
12
|
+
// `createPylonBoundary` from "./client-boundary", wiring its own internals
|
|
13
|
+
// (loadManifest / loadRouteEntry / navigate / buildTree / the active page
|
|
14
|
+
// props) in as deps. ssr-client-boundary.test.ts imports the same factory and
|
|
15
|
+
// drives it directly. ONE source of truth, no inline drift.
|
|
16
|
+
//
|
|
17
|
+
// Browser-safe by construction: imports ONLY "react" and touches NO DOM
|
|
18
|
+
// globals — every browser capability (the manifest, navigation, the current
|
|
19
|
+
// href) arrives through injected deps. So it bundles cleanly into the client
|
|
20
|
+
// chunk AND typechecks without a DOM lib.
|
|
21
|
+
|
|
22
|
+
import { Component, createElement, useEffect, useState } from "react";
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Resolve the nearest boundary module (`<dir>/not-found` or `<dir>/error`)
|
|
26
|
+
* for a route by walking its component path up to the app root, returning the
|
|
27
|
+
* first manifest route key that exists. Nearest ancestor wins — the same model
|
|
28
|
+
* the server's `findBoundary` uses, but driven off the client build manifest's
|
|
29
|
+
* route keys so the client runtime needs no extra server round-trip.
|
|
30
|
+
*
|
|
31
|
+
* `component` is a cwd-relative path with "/" separators and no extension
|
|
32
|
+
* (e.g. "web/app/dashboard/orgs/[slug]/page"); `routeKeys` is
|
|
33
|
+
* `Object.keys(manifest.routes)`.
|
|
34
|
+
*/
|
|
35
|
+
export function nearestBoundaryComponent(
|
|
36
|
+
component: string,
|
|
37
|
+
fileName: "not-found" | "error",
|
|
38
|
+
routeKeys: Iterable<string>,
|
|
39
|
+
): string | null {
|
|
40
|
+
const keys = routeKeys instanceof Set ? routeKeys : new Set(routeKeys);
|
|
41
|
+
let dir = String(component);
|
|
42
|
+
dir = dir.includes("/") ? dir.slice(0, dir.lastIndexOf("/")) : "";
|
|
43
|
+
while (dir) {
|
|
44
|
+
const key = `${dir}/${fileName}`;
|
|
45
|
+
if (keys.has(key)) return key;
|
|
46
|
+
const slash = dir.lastIndexOf("/");
|
|
47
|
+
dir = slash >= 0 ? dir.slice(0, slash) : "";
|
|
48
|
+
}
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Runtime internals the boundary needs, injected so the module stays
|
|
53
|
+
* browser-safe AND unit-testable. */
|
|
54
|
+
export interface BoundaryDeps {
|
|
55
|
+
/** Resolve the client build manifest (`{ routes: {...} }`). */
|
|
56
|
+
loadManifest: () => Promise<{ routes?: Record<string, unknown> } | null>;
|
|
57
|
+
/** Dynamically load a route entry → its Page + Layout chain. */
|
|
58
|
+
loadRouteEntry: (component: string) => Promise<{ Page: any; Layouts: any[] }>;
|
|
59
|
+
/** Client-side navigation — used by an error boundary's reset(). */
|
|
60
|
+
navigate: (href: string, opts?: { replace?: boolean }) => void;
|
|
61
|
+
/** Wrap a Page in its Layout chain with props (the runtime's buildTree). */
|
|
62
|
+
buildTree: (Page: any, Layouts: any[], props: any) => any;
|
|
63
|
+
/** The props the active page was rendered with (auth, serverData, …). */
|
|
64
|
+
getPageProps: () => any;
|
|
65
|
+
/** Current same-origin href, for an error boundary's reset() re-navigation. */
|
|
66
|
+
getResetHref: () => string;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Build the client error / not-found boundary against the runtime's internals.
|
|
71
|
+
* Returns `withBoundary(tree, component, navEpoch)` — the transparent root
|
|
72
|
+
* wrapper the runtime puts around every page — plus `PylonBoundary` for tests.
|
|
73
|
+
*
|
|
74
|
+
* Behavior: a `notFound()` (digest "PYLON_NOT_FOUND") or any error thrown
|
|
75
|
+
* during a descendant's render is caught; the nearest not-found.tsx / error.tsx
|
|
76
|
+
* for the active route is resolved from the manifest, loaded, and rendered in
|
|
77
|
+
* its own layout chain WITH the page's props (so an auth-guarding layout sees
|
|
78
|
+
* the real auth and doesn't redirect away). Resets on navigation via a changing
|
|
79
|
+
* `navEpoch` prop — not a key — so layouts keep their state across nav.
|
|
80
|
+
*/
|
|
81
|
+
export function createPylonBoundary(deps: BoundaryDeps) {
|
|
82
|
+
const {
|
|
83
|
+
loadManifest,
|
|
84
|
+
loadRouteEntry,
|
|
85
|
+
navigate,
|
|
86
|
+
buildTree,
|
|
87
|
+
getPageProps,
|
|
88
|
+
getResetHref,
|
|
89
|
+
} = deps;
|
|
90
|
+
|
|
91
|
+
async function resolveBoundaryComponent(
|
|
92
|
+
component: string,
|
|
93
|
+
fileName: "not-found" | "error",
|
|
94
|
+
): Promise<string | null> {
|
|
95
|
+
const manifest = await loadManifest();
|
|
96
|
+
if (!manifest || !manifest.routes || !component) return null;
|
|
97
|
+
return nearestBoundaryComponent(
|
|
98
|
+
component,
|
|
99
|
+
fileName,
|
|
100
|
+
Object.keys(manifest.routes),
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Last-resort body when the app ships no not-found.tsx / error.tsx anywhere.
|
|
105
|
+
// Renders a full <html> document so it can replace the document root cleanly
|
|
106
|
+
// (the normal path renders the app's boundary wrapped in the root layout,
|
|
107
|
+
// which also owns <html>).
|
|
108
|
+
function DefaultBoundary(props: any) {
|
|
109
|
+
const isNF = props.kind === "not-found";
|
|
110
|
+
return createElement(
|
|
111
|
+
"html",
|
|
112
|
+
{ lang: "en" },
|
|
113
|
+
createElement(
|
|
114
|
+
"head",
|
|
115
|
+
null,
|
|
116
|
+
createElement("meta", { charSet: "utf-8" }),
|
|
117
|
+
createElement(
|
|
118
|
+
"title",
|
|
119
|
+
null,
|
|
120
|
+
isNF ? "404 — Not found" : "Something went wrong",
|
|
121
|
+
),
|
|
122
|
+
),
|
|
123
|
+
createElement(
|
|
124
|
+
"body",
|
|
125
|
+
{
|
|
126
|
+
style: {
|
|
127
|
+
margin: 0,
|
|
128
|
+
minHeight: "100vh",
|
|
129
|
+
display: "flex",
|
|
130
|
+
alignItems: "center",
|
|
131
|
+
justifyContent: "center",
|
|
132
|
+
fontFamily: "system-ui, -apple-system, sans-serif",
|
|
133
|
+
},
|
|
134
|
+
},
|
|
135
|
+
createElement(
|
|
136
|
+
"div",
|
|
137
|
+
{ style: { textAlign: "center", padding: "2rem" } },
|
|
138
|
+
createElement(
|
|
139
|
+
"h1",
|
|
140
|
+
{ style: { fontSize: "1.375rem", margin: "0 0 0.5rem" } },
|
|
141
|
+
isNF ? "404 — Not found" : "Something went wrong",
|
|
142
|
+
),
|
|
143
|
+
createElement(
|
|
144
|
+
"p",
|
|
145
|
+
{ style: { color: "#666", margin: "0 0 1.25rem" } },
|
|
146
|
+
isNF
|
|
147
|
+
? "This page doesn't exist or was moved."
|
|
148
|
+
: "An unexpected error occurred.",
|
|
149
|
+
),
|
|
150
|
+
createElement(
|
|
151
|
+
"a",
|
|
152
|
+
{ href: "/", style: { color: "#0969da", textDecoration: "none" } },
|
|
153
|
+
"← Go home",
|
|
154
|
+
),
|
|
155
|
+
),
|
|
156
|
+
),
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// Lazily resolves + renders the boundary component in its own layout chain.
|
|
161
|
+
// Renders nothing for the brief moment before the (usually already-cached)
|
|
162
|
+
// entry loads, then the styled boundary — far better than a permanent blank.
|
|
163
|
+
function BoundaryView(props: any) {
|
|
164
|
+
const { component, kind, error, reset } = props;
|
|
165
|
+
const [resolved, setResolved] = useState<any>(null);
|
|
166
|
+
useEffect(() => {
|
|
167
|
+
let cancelled = false;
|
|
168
|
+
const fileName = kind === "not-found" ? "not-found" : "error";
|
|
169
|
+
(async () => {
|
|
170
|
+
const comp = await resolveBoundaryComponent(component, fileName);
|
|
171
|
+
if (cancelled) return;
|
|
172
|
+
if (!comp) {
|
|
173
|
+
setResolved({ missing: true });
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
try {
|
|
177
|
+
const entry = await loadRouteEntry(comp);
|
|
178
|
+
if (!cancelled) setResolved({ entry });
|
|
179
|
+
} catch {
|
|
180
|
+
if (!cancelled) setResolved({ missing: true });
|
|
181
|
+
}
|
|
182
|
+
})();
|
|
183
|
+
return () => {
|
|
184
|
+
cancelled = true;
|
|
185
|
+
};
|
|
186
|
+
}, [component, kind]);
|
|
187
|
+
if (!resolved) return null;
|
|
188
|
+
if (resolved.missing) return createElement(DefaultBoundary, { kind });
|
|
189
|
+
// Reuse the page's props (auth, serverData, params, url, …) so the
|
|
190
|
+
// boundary's layout chain renders with the SAME context the page had — an
|
|
191
|
+
// auth-guarding layout that reads props.auth must not see undefined and
|
|
192
|
+
// redirect away. error.tsx gets the SAFE error projection (message + digest
|
|
193
|
+
// only, never a raw Error/stack — matches the server boundary's #270).
|
|
194
|
+
const bProps: any = { ...getPageProps(), reset };
|
|
195
|
+
if (kind === "error" && error) {
|
|
196
|
+
bProps.error = {
|
|
197
|
+
message: String((error && error.message) || error),
|
|
198
|
+
digest: error && error.digest,
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
return buildTree(resolved.entry.Page, resolved.entry.Layouts, bProps);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
class PylonBoundary extends Component<any, { err: any }> {
|
|
205
|
+
constructor(p: any) {
|
|
206
|
+
super(p);
|
|
207
|
+
this.state = { err: null };
|
|
208
|
+
}
|
|
209
|
+
static getDerivedStateFromError(err: any) {
|
|
210
|
+
return { err };
|
|
211
|
+
}
|
|
212
|
+
componentDidUpdate(prev: any) {
|
|
213
|
+
// A navigation bumps navEpoch — clear a prior error so the freshly
|
|
214
|
+
// rendered children (the new page) show instead of the stale boundary.
|
|
215
|
+
if (prev.navEpoch !== this.props.navEpoch && this.state.err) {
|
|
216
|
+
this.setState({ err: null });
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
componentDidCatch(err: any) {
|
|
220
|
+
// notFound() is expected control flow, not a crash — keep it quiet.
|
|
221
|
+
// Genuine errors stay loud.
|
|
222
|
+
if (!(err && err.digest === "PYLON_NOT_FOUND")) {
|
|
223
|
+
// eslint-disable-next-line no-console
|
|
224
|
+
console.error("[pylon ssr] render error caught by boundary:", err);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
render() {
|
|
228
|
+
const err = this.state.err;
|
|
229
|
+
if (!err) return this.props.children;
|
|
230
|
+
const kind = err.digest === "PYLON_NOT_FOUND" ? "not-found" : "error";
|
|
231
|
+
const self = this;
|
|
232
|
+
return createElement(BoundaryView, {
|
|
233
|
+
component: this.props.component,
|
|
234
|
+
kind,
|
|
235
|
+
error: err,
|
|
236
|
+
reset() {
|
|
237
|
+
self.setState({ err: null });
|
|
238
|
+
navigate(getResetHref(), { replace: true });
|
|
239
|
+
},
|
|
240
|
+
});
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// Wrap a page tree in the root boundary. navEpoch (a prop, not a key) lets the
|
|
245
|
+
// boundary clear its error on navigation without remounting the layouts.
|
|
246
|
+
function withBoundary(tree: any, component: string, navEpoch: number) {
|
|
247
|
+
return createElement(PylonBoundary, { navEpoch, component }, tree);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
return { withBoundary, PylonBoundary };
|
|
251
|
+
}
|
|
@@ -23,9 +23,9 @@ import * as os from "node:os";
|
|
|
23
23
|
|
|
24
24
|
import {
|
|
25
25
|
buildClientBundle,
|
|
26
|
-
nearestBoundaryComponent,
|
|
27
26
|
type PylonBundleManifest,
|
|
28
27
|
} from "./ssr-client-bundler";
|
|
28
|
+
import { nearestBoundaryComponent } from "./ssr-client-boundary";
|
|
29
29
|
|
|
30
30
|
// State that needs cleanup between tests.
|
|
31
31
|
let originalCwd: string | null = null;
|
|
@@ -177,39 +177,6 @@ function discoverRoutes(
|
|
|
177
177
|
}));
|
|
178
178
|
}
|
|
179
179
|
|
|
180
|
-
/**
|
|
181
|
-
* Resolve the nearest boundary module (`<dir>/not-found` or `<dir>/error`)
|
|
182
|
-
* for a route by walking its component path up to the app root, returning the
|
|
183
|
-
* first manifest route key that exists. Nearest ancestor wins — the same model
|
|
184
|
-
* the server's `findBoundary` uses, but driven off the client build manifest's
|
|
185
|
-
* route keys so the client runtime needs no extra server round-trip.
|
|
186
|
-
*
|
|
187
|
-
* Exported as the CANONICAL spec for the client error boundary: the runtime in
|
|
188
|
-
* `CLIENT_RUNTIME_SOURCE` inlines this exact walk (it can't import across the
|
|
189
|
-
* bundle), and `ssr-client-bundler.test.ts` exercises this function so a
|
|
190
|
-
* regression in the algorithm is caught here.
|
|
191
|
-
*
|
|
192
|
-
* `component` is a cwd-relative path with "/" separators and no extension
|
|
193
|
-
* (e.g. "web/app/dashboard/orgs/[slug]/page"); `routeKeys` is
|
|
194
|
-
* `Object.keys(manifest.routes)`.
|
|
195
|
-
*/
|
|
196
|
-
export function nearestBoundaryComponent(
|
|
197
|
-
component: string,
|
|
198
|
-
fileName: "not-found" | "error",
|
|
199
|
-
routeKeys: Iterable<string>,
|
|
200
|
-
): string | null {
|
|
201
|
-
const keys = routeKeys instanceof Set ? routeKeys : new Set(routeKeys);
|
|
202
|
-
let dir = String(component);
|
|
203
|
-
dir = dir.includes("/") ? dir.slice(0, dir.lastIndexOf("/")) : "";
|
|
204
|
-
while (dir) {
|
|
205
|
-
const key = `${dir}/${fileName}`;
|
|
206
|
-
if (keys.has(key)) return key;
|
|
207
|
-
const slash = dir.lastIndexOf("/");
|
|
208
|
-
dir = slash >= 0 ? dir.slice(0, slash) : "";
|
|
209
|
-
}
|
|
210
|
-
return null;
|
|
211
|
-
}
|
|
212
|
-
|
|
213
180
|
/**
|
|
214
181
|
* The shared hydration dispatcher + router. ONE module, imported
|
|
215
182
|
* by every per-route entry. Bun's splitter sees N entries reach
|
|
@@ -237,8 +204,9 @@ export function nearestBoundaryComponent(
|
|
|
237
204
|
const CLIENT_RUNTIME_SOURCE = `// Generated by Pylon SSR (Phase 2 client runtime).
|
|
238
205
|
// DO NOT EDIT — overwritten on every pylon dev / build.
|
|
239
206
|
|
|
240
|
-
import {
|
|
207
|
+
import { createElement } from "react";
|
|
241
208
|
import { hydrateRoot } from "react-dom/client";
|
|
209
|
+
import { createPylonBoundary } from "./client-boundary";
|
|
242
210
|
|
|
243
211
|
const routeCache = Object.create(null);
|
|
244
212
|
let activeRoot = null;
|
|
@@ -278,166 +246,19 @@ let navEpoch = 0;
|
|
|
278
246
|
// (for instance) redirect to /login instead of showing the not-found page.
|
|
279
247
|
let currentPageProps = {};
|
|
280
248
|
|
|
281
|
-
//
|
|
282
|
-
//
|
|
283
|
-
//
|
|
284
|
-
//
|
|
285
|
-
//
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
if (manifest.routes[key]) return key;
|
|
295
|
-
const slash = dir.lastIndexOf("/");
|
|
296
|
-
dir = slash >= 0 ? dir.slice(0, slash) : "";
|
|
297
|
-
}
|
|
298
|
-
return null;
|
|
299
|
-
}
|
|
300
|
-
|
|
301
|
-
// Last-resort body when the app ships no not-found.tsx / error.tsx anywhere.
|
|
302
|
-
// Renders a full <html> document so it can replace the document root cleanly
|
|
303
|
-
// (the normal path renders the app's boundary wrapped in the root layout,
|
|
304
|
-
// which also owns <html>).
|
|
305
|
-
function DefaultBoundary(props) {
|
|
306
|
-
const isNF = props.kind === "not-found";
|
|
307
|
-
return createElement(
|
|
308
|
-
"html",
|
|
309
|
-
{ lang: "en" },
|
|
310
|
-
createElement(
|
|
311
|
-
"head",
|
|
312
|
-
null,
|
|
313
|
-
createElement("meta", { charSet: "utf-8" }),
|
|
314
|
-
createElement("title", null, isNF ? "404 — Not found" : "Something went wrong"),
|
|
315
|
-
),
|
|
316
|
-
createElement(
|
|
317
|
-
"body",
|
|
318
|
-
{
|
|
319
|
-
style: {
|
|
320
|
-
margin: 0,
|
|
321
|
-
minHeight: "100vh",
|
|
322
|
-
display: "flex",
|
|
323
|
-
alignItems: "center",
|
|
324
|
-
justifyContent: "center",
|
|
325
|
-
fontFamily: "system-ui, -apple-system, sans-serif",
|
|
326
|
-
},
|
|
327
|
-
},
|
|
328
|
-
createElement(
|
|
329
|
-
"div",
|
|
330
|
-
{ style: { textAlign: "center", padding: "2rem" } },
|
|
331
|
-
createElement(
|
|
332
|
-
"h1",
|
|
333
|
-
{ style: { fontSize: "1.375rem", margin: "0 0 0.5rem" } },
|
|
334
|
-
isNF ? "404 — Not found" : "Something went wrong",
|
|
335
|
-
),
|
|
336
|
-
createElement(
|
|
337
|
-
"p",
|
|
338
|
-
{ style: { color: "#666", margin: "0 0 1.25rem" } },
|
|
339
|
-
isNF
|
|
340
|
-
? "This page doesn't exist or was moved."
|
|
341
|
-
: "An unexpected error occurred.",
|
|
342
|
-
),
|
|
343
|
-
createElement(
|
|
344
|
-
"a",
|
|
345
|
-
{ href: "/", style: { color: "#0969da", textDecoration: "none" } },
|
|
346
|
-
"\\u2190 Go home",
|
|
347
|
-
),
|
|
348
|
-
),
|
|
349
|
-
),
|
|
350
|
-
);
|
|
351
|
-
}
|
|
352
|
-
|
|
353
|
-
// Lazily resolves + renders the boundary component in its own layout chain.
|
|
354
|
-
// Renders nothing for the brief moment before the (usually already-cached)
|
|
355
|
-
// entry loads, then the styled boundary — far better than a permanent blank.
|
|
356
|
-
function BoundaryView(props) {
|
|
357
|
-
const { component, kind, error, reset } = props;
|
|
358
|
-
const [resolved, setResolved] = useState(null);
|
|
359
|
-
useEffect(() => {
|
|
360
|
-
let cancelled = false;
|
|
361
|
-
const fileName = kind === "not-found" ? "not-found" : "error";
|
|
362
|
-
(async () => {
|
|
363
|
-
const comp = await resolveBoundaryComponent(component, fileName);
|
|
364
|
-
if (cancelled) return;
|
|
365
|
-
if (!comp) {
|
|
366
|
-
setResolved({ missing: true });
|
|
367
|
-
return;
|
|
368
|
-
}
|
|
369
|
-
try {
|
|
370
|
-
const entry = await loadRouteEntry(comp);
|
|
371
|
-
if (!cancelled) setResolved({ entry });
|
|
372
|
-
} catch {
|
|
373
|
-
if (!cancelled) setResolved({ missing: true });
|
|
374
|
-
}
|
|
375
|
-
})();
|
|
376
|
-
return () => {
|
|
377
|
-
cancelled = true;
|
|
378
|
-
};
|
|
379
|
-
}, [component, kind]);
|
|
380
|
-
if (!resolved) return null;
|
|
381
|
-
if (resolved.missing) return createElement(DefaultBoundary, { kind });
|
|
382
|
-
// Reuse the page's props (auth, serverData, params, url, …) so the boundary's
|
|
383
|
-
// layout chain renders with the SAME context the page had — an auth-guarding
|
|
384
|
-
// layout that reads props.auth must not see undefined and redirect away. Add
|
|
385
|
-
// reset (+ the SAFE error projection for error.tsx — message + digest only,
|
|
386
|
-
// never a raw Error/stack, matching the server boundary's #270 posture).
|
|
387
|
-
const bProps = { ...currentPageProps, reset };
|
|
388
|
-
if (kind === "error" && error) {
|
|
389
|
-
bProps.error = {
|
|
390
|
-
message: String((error && error.message) || error),
|
|
391
|
-
digest: error && error.digest,
|
|
392
|
-
};
|
|
393
|
-
}
|
|
394
|
-
return buildTree(resolved.entry.Page, resolved.entry.Layouts, bProps);
|
|
395
|
-
}
|
|
396
|
-
|
|
397
|
-
class PylonBoundary extends Component {
|
|
398
|
-
constructor(p) {
|
|
399
|
-
super(p);
|
|
400
|
-
this.state = { err: null };
|
|
401
|
-
}
|
|
402
|
-
static getDerivedStateFromError(err) {
|
|
403
|
-
return { err };
|
|
404
|
-
}
|
|
405
|
-
componentDidUpdate(prev) {
|
|
406
|
-
// A navigation bumps navEpoch — clear a prior error so the freshly
|
|
407
|
-
// rendered children (the new page) show instead of the stale boundary.
|
|
408
|
-
if (prev.navEpoch !== this.props.navEpoch && this.state.err) {
|
|
409
|
-
this.setState({ err: null });
|
|
410
|
-
}
|
|
411
|
-
}
|
|
412
|
-
componentDidCatch(err) {
|
|
413
|
-
// notFound() is expected control flow, not a crash — keep it quiet.
|
|
414
|
-
// Genuine errors stay loud.
|
|
415
|
-
if (!(err && err.digest === "PYLON_NOT_FOUND")) {
|
|
416
|
-
console.error("[pylon ssr] render error caught by boundary:", err);
|
|
417
|
-
}
|
|
418
|
-
}
|
|
419
|
-
render() {
|
|
420
|
-
const err = this.state.err;
|
|
421
|
-
if (!err) return this.props.children;
|
|
422
|
-
const kind = err.digest === "PYLON_NOT_FOUND" ? "not-found" : "error";
|
|
423
|
-
const self = this;
|
|
424
|
-
return createElement(BoundaryView, {
|
|
425
|
-
component: this.props.component,
|
|
426
|
-
kind,
|
|
427
|
-
error: err,
|
|
428
|
-
reset() {
|
|
429
|
-
self.setState({ err: null });
|
|
430
|
-
navigate(location.pathname + location.search, { replace: true });
|
|
431
|
-
},
|
|
432
|
-
});
|
|
433
|
-
}
|
|
434
|
-
}
|
|
435
|
-
|
|
436
|
-
// Wrap a page tree in the root boundary. navEpoch (a prop, not a key) lets the
|
|
437
|
-
// boundary clear its error on navigation without remounting the layouts.
|
|
438
|
-
function withBoundary(tree, component) {
|
|
439
|
-
return createElement(PylonBoundary, { navEpoch, component }, tree);
|
|
440
|
-
}
|
|
249
|
+
// The not-found / error boundary lives in a real module (./client-boundary,
|
|
250
|
+
// emitted next to this file by the bundler) so its render behavior is
|
|
251
|
+
// unit-testable instead of buried in this string. Wire the runtime's
|
|
252
|
+
// internals in as deps; the returned withBoundary(tree, component, navEpoch)
|
|
253
|
+
// wraps every page tree as the transparent root error boundary.
|
|
254
|
+
const { withBoundary } = createPylonBoundary({
|
|
255
|
+
loadManifest,
|
|
256
|
+
loadRouteEntry,
|
|
257
|
+
navigate,
|
|
258
|
+
buildTree,
|
|
259
|
+
getPageProps: () => currentPageProps,
|
|
260
|
+
getResetHref: () => location.pathname + location.search,
|
|
261
|
+
});
|
|
441
262
|
|
|
442
263
|
// Deterministic stringify — MUST match stableStringify in ssr-runtime.ts so
|
|
443
264
|
// a serverData call's cache key is identical on server and client.
|
|
@@ -641,6 +462,7 @@ export function hydrate(component, Page, Layouts) {
|
|
|
641
462
|
const tree = withBoundary(
|
|
642
463
|
buildTree(Page, Layouts, currentPageProps),
|
|
643
464
|
data.component,
|
|
465
|
+
navEpoch,
|
|
644
466
|
);
|
|
645
467
|
activeRoot = hydrateRoot(document, tree);
|
|
646
468
|
installNavHandlers();
|
|
@@ -723,6 +545,7 @@ async function navigate(href, opts) {
|
|
|
723
545
|
const tree = withBoundary(
|
|
724
546
|
buildTree(route.Page, route.Layouts, currentPageProps),
|
|
725
547
|
data.component,
|
|
548
|
+
navEpoch,
|
|
726
549
|
);
|
|
727
550
|
activeRoot.render(tree);
|
|
728
551
|
const target = url.pathname + url.search;
|
|
@@ -1081,6 +904,21 @@ async function _doBuildInner(
|
|
|
1081
904
|
const runtimePath = path.join(stageDir, "client-runtime.ts");
|
|
1082
905
|
fs.writeFileSync(runtimePath, CLIENT_RUNTIME_SOURCE, "utf8");
|
|
1083
906
|
|
|
907
|
+
// Emit the not-found/error boundary as a sibling module the runtime
|
|
908
|
+
// imports (`./client-boundary`). Its source is this package's real
|
|
909
|
+
// ssr-client-boundary.ts — ONE source of truth, unit-tested directly,
|
|
910
|
+
// rather than an inline string in CLIENT_RUNTIME_SOURCE that nothing
|
|
911
|
+
// could render. Bun pulls it into the shared chunk via the runtime's
|
|
912
|
+
// static `import { createPylonBoundary } from "./client-boundary"`.
|
|
913
|
+
fs.writeFileSync(
|
|
914
|
+
path.join(stageDir, "client-boundary.ts"),
|
|
915
|
+
fs.readFileSync(
|
|
916
|
+
path.join(import.meta.dir, "ssr-client-boundary.ts"),
|
|
917
|
+
"utf8",
|
|
918
|
+
),
|
|
919
|
+
"utf8",
|
|
920
|
+
);
|
|
921
|
+
|
|
1084
922
|
const entryPaths: string[] = [];
|
|
1085
923
|
// entryPath (absolute) → component path (for manifest lookup).
|
|
1086
924
|
const entryToComponent = new Map<string, string>();
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Render integration test for the SSR client not-found / error boundary.
|
|
3
|
+
*
|
|
4
|
+
* Covers the two regressions that shipped because unit + build tests can't
|
|
5
|
+
* reach RENDER-time behavior (they only check pure functions + that the chunk
|
|
6
|
+
* contains the wiring):
|
|
7
|
+
* - 0.3.270: a client-thrown notFound() blanked the whole page (the client
|
|
8
|
+
* runtime had no error boundary at all).
|
|
9
|
+
* - 0.3.271: the boundary rendered not-found.tsx + its layout chain with a
|
|
10
|
+
* MINIMAL props object, so an auth-guarding layout (`if (!auth?.user_id)
|
|
11
|
+
* redirect("/login")`) saw no auth and bounced to /login instead of
|
|
12
|
+
* showing the not-found page.
|
|
13
|
+
*
|
|
14
|
+
* Drives the REAL `createPylonBoundary` with a real React renderer (happy-dom
|
|
15
|
+
* + react-dom/client). Isolated file because it registers DOM globals.
|
|
16
|
+
*
|
|
17
|
+
* NAMING — this file MUST sort AFTER ssr-client-bundler.test.ts (the only test
|
|
18
|
+
* that runs `Bun.build`). `bun test` runs files in order in one process, and
|
|
19
|
+
* once react-dom is loaded a later `Bun.build` fails to read `scheduler`
|
|
20
|
+
* ("Unexpected reading file"). ssr-hydration.test.ts depends on the same
|
|
21
|
+
* ordering; "ssr-runtime-*" sorts after "ssr-client-bundler". Don't rename to
|
|
22
|
+
* sort before the bundler test.
|
|
23
|
+
*/
|
|
24
|
+
import { afterEach, describe, expect, test } from "bun:test";
|
|
25
|
+
import { Window } from "happy-dom";
|
|
26
|
+
import React from "react";
|
|
27
|
+
|
|
28
|
+
import { createPylonBoundary } from "./ssr-client-boundary";
|
|
29
|
+
|
|
30
|
+
// --- DOM globals react-dom/client touches; saved + restored per file. --------
|
|
31
|
+
const DOM_GLOBAL_KEYS = [
|
|
32
|
+
"window",
|
|
33
|
+
"document",
|
|
34
|
+
"navigator",
|
|
35
|
+
"HTMLElement",
|
|
36
|
+
"Node",
|
|
37
|
+
"Event",
|
|
38
|
+
"MutationObserver",
|
|
39
|
+
"requestAnimationFrame",
|
|
40
|
+
"cancelAnimationFrame",
|
|
41
|
+
];
|
|
42
|
+
let savedGlobals: Record<string, any> | null = null;
|
|
43
|
+
|
|
44
|
+
function registerDom(win: any) {
|
|
45
|
+
const g = globalThis as any;
|
|
46
|
+
savedGlobals = {};
|
|
47
|
+
for (const k of DOM_GLOBAL_KEYS) savedGlobals[k] = g[k];
|
|
48
|
+
g.window = win;
|
|
49
|
+
g.document = win.document;
|
|
50
|
+
g.navigator = win.navigator;
|
|
51
|
+
g.HTMLElement = win.HTMLElement;
|
|
52
|
+
g.Node = win.Node;
|
|
53
|
+
g.Event = win.Event;
|
|
54
|
+
g.MutationObserver = win.MutationObserver;
|
|
55
|
+
g.requestAnimationFrame = (cb: any) => setTimeout(() => cb(Date.now()), 0);
|
|
56
|
+
g.cancelAnimationFrame = (id: any) => clearTimeout(id);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
afterEach(() => {
|
|
60
|
+
if (!savedGlobals) return;
|
|
61
|
+
const g = globalThis as any;
|
|
62
|
+
for (const k of DOM_GLOBAL_KEYS) {
|
|
63
|
+
if (savedGlobals[k] === undefined) delete g[k];
|
|
64
|
+
else g[k] = savedGlobals[k];
|
|
65
|
+
}
|
|
66
|
+
savedGlobals = null;
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
// A client notFound(): same digest @pylonsync/react's notFound() stamps on the
|
|
70
|
+
// error it throws (NotFoundError.digest === "PYLON_NOT_FOUND").
|
|
71
|
+
class NotFoundError extends Error {
|
|
72
|
+
readonly digest = "PYLON_NOT_FOUND";
|
|
73
|
+
constructor() {
|
|
74
|
+
super("PYLON_NOT_FOUND");
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// The runtime's buildTree: wrap a Page in its Layout chain (root → leaf).
|
|
79
|
+
const buildTree = (Page: any, Layouts: any[], props: any) =>
|
|
80
|
+
Layouts.reduceRight(
|
|
81
|
+
(child: any, L: any) => React.createElement(L, props, child),
|
|
82
|
+
React.createElement(Page, props),
|
|
83
|
+
);
|
|
84
|
+
|
|
85
|
+
// Mount a tree into a fresh happy-dom root, flush effects, return innerHTML.
|
|
86
|
+
// console.error is captured because React logs boundary-caught errors + an
|
|
87
|
+
// act() advisory; neither is a failure here.
|
|
88
|
+
async function mount(tree: any): Promise<string> {
|
|
89
|
+
const win = new Window({ url: "http://localhost/dashboard" });
|
|
90
|
+
registerDom(win);
|
|
91
|
+
const container = win.document.createElement("div");
|
|
92
|
+
win.document.body.appendChild(container);
|
|
93
|
+
const orig = console.error;
|
|
94
|
+
console.error = () => {};
|
|
95
|
+
try {
|
|
96
|
+
const { createRoot } = await import("react-dom/client");
|
|
97
|
+
const root = createRoot(container as any);
|
|
98
|
+
root.render(tree);
|
|
99
|
+
await new Promise((r) => setTimeout(r, 80));
|
|
100
|
+
return (container as any).innerHTML as string;
|
|
101
|
+
} finally {
|
|
102
|
+
console.error = orig;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
describe("createPylonBoundary (render integration)", () => {
|
|
107
|
+
test("client-thrown notFound() renders the not-found page WITH the page's props — auth flows through, no /login redirect", async () => {
|
|
108
|
+
const redirects: string[] = [];
|
|
109
|
+
// Mirrors app/dashboard/layout.tsx: redirect to /login when no auth.
|
|
110
|
+
function AuthGuardLayout(props: any) {
|
|
111
|
+
if (!props.auth?.user_id) {
|
|
112
|
+
redirects.push("/login");
|
|
113
|
+
return null;
|
|
114
|
+
}
|
|
115
|
+
return props.children;
|
|
116
|
+
}
|
|
117
|
+
function NotFound(props: any) {
|
|
118
|
+
return React.createElement(
|
|
119
|
+
"div",
|
|
120
|
+
{ "data-nf": "1" },
|
|
121
|
+
"NOT FOUND user=" + (props.auth?.user_id ?? "none"),
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
function ThrowingPage(): any {
|
|
125
|
+
throw new NotFoundError();
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const { withBoundary } = createPylonBoundary({
|
|
129
|
+
loadManifest: async () => ({ routes: { "app/dashboard/not-found": {} } }),
|
|
130
|
+
loadRouteEntry: async () => ({ Page: NotFound, Layouts: [AuthGuardLayout] }),
|
|
131
|
+
navigate: () => {},
|
|
132
|
+
buildTree,
|
|
133
|
+
getPageProps: () => ({ auth: { user_id: "u1" }, params: {}, url: "/d" }),
|
|
134
|
+
getResetHref: () => "/d",
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
const html = await mount(
|
|
138
|
+
withBoundary(buildTree(ThrowingPage, [], {}), "app/dashboard/orgs/[s]/page", 0),
|
|
139
|
+
);
|
|
140
|
+
|
|
141
|
+
// Caught the throw + rendered the not-found page (NOT a blank page):
|
|
142
|
+
expect(html).toContain("NOT FOUND");
|
|
143
|
+
// ...with the page's auth in props (the 0.3.271 fix) so the auth-guard
|
|
144
|
+
// layout rendered its children instead of redirecting to /login:
|
|
145
|
+
expect(html).toContain("user=u1");
|
|
146
|
+
expect(redirects).toEqual([]);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
test("negative control: with NO auth in the page props the auth-guard layout redirects — proves the test detects the missing-props bug class", async () => {
|
|
150
|
+
const redirects: string[] = [];
|
|
151
|
+
function AuthGuardLayout(props: any) {
|
|
152
|
+
if (!props.auth?.user_id) {
|
|
153
|
+
redirects.push("/login");
|
|
154
|
+
return null;
|
|
155
|
+
}
|
|
156
|
+
return props.children;
|
|
157
|
+
}
|
|
158
|
+
function NotFound() {
|
|
159
|
+
return React.createElement("div", null, "NOT FOUND");
|
|
160
|
+
}
|
|
161
|
+
function ThrowingPage(): any {
|
|
162
|
+
throw new NotFoundError();
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const { withBoundary } = createPylonBoundary({
|
|
166
|
+
loadManifest: async () => ({ routes: { "app/dashboard/not-found": {} } }),
|
|
167
|
+
loadRouteEntry: async () => ({ Page: NotFound, Layouts: [AuthGuardLayout] }),
|
|
168
|
+
navigate: () => {},
|
|
169
|
+
buildTree,
|
|
170
|
+
// No auth — what the 0.3.270 boundary's minimal props object produced.
|
|
171
|
+
getPageProps: () => ({}),
|
|
172
|
+
getResetHref: () => "/d",
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
const html = await mount(
|
|
176
|
+
withBoundary(buildTree(ThrowingPage, [], {}), "app/dashboard/x/page", 0),
|
|
177
|
+
);
|
|
178
|
+
expect(html).not.toContain("NOT FOUND");
|
|
179
|
+
expect(redirects).toContain("/login");
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
test("a non-notFound error resolves the error boundary, with a SAFE error projection (message + digest only)", async () => {
|
|
183
|
+
let seenError: any = null;
|
|
184
|
+
function ErrorPage(props: any) {
|
|
185
|
+
seenError = props.error;
|
|
186
|
+
return React.createElement("div", null, "ERR:" + (props.error?.message ?? ""));
|
|
187
|
+
}
|
|
188
|
+
function Boom(): any {
|
|
189
|
+
const e: any = new Error("kaboom");
|
|
190
|
+
e.stack = "secret-stack-do-not-leak";
|
|
191
|
+
throw e;
|
|
192
|
+
}
|
|
193
|
+
const { withBoundary } = createPylonBoundary({
|
|
194
|
+
loadManifest: async () => ({ routes: { "app/error": {} } }),
|
|
195
|
+
loadRouteEntry: async () => ({ Page: ErrorPage, Layouts: [] }),
|
|
196
|
+
navigate: () => {},
|
|
197
|
+
buildTree,
|
|
198
|
+
getPageProps: () => ({ auth: { user_id: "u1" } }),
|
|
199
|
+
getResetHref: () => "/d",
|
|
200
|
+
});
|
|
201
|
+
const html = await mount(withBoundary(buildTree(Boom, [], {}), "app/x/page", 0));
|
|
202
|
+
expect(html).toContain("ERR:kaboom");
|
|
203
|
+
// The raw Error (and its stack) must NEVER reach the boundary props.
|
|
204
|
+
expect(seenError).toEqual({ message: "kaboom", digest: undefined });
|
|
205
|
+
expect(JSON.stringify(seenError)).not.toContain("secret-stack");
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
test("no boundary module in the manifest → styled default 404, never a blank page", async () => {
|
|
209
|
+
function ThrowingPage(): any {
|
|
210
|
+
throw new NotFoundError();
|
|
211
|
+
}
|
|
212
|
+
const { withBoundary } = createPylonBoundary({
|
|
213
|
+
loadManifest: async () => ({ routes: { "app/page": {} } }), // no not-found anywhere
|
|
214
|
+
loadRouteEntry: async () => {
|
|
215
|
+
throw new Error("should not be called");
|
|
216
|
+
},
|
|
217
|
+
buildTree,
|
|
218
|
+
navigate: () => {},
|
|
219
|
+
getPageProps: () => ({ auth: { user_id: "u1" } }),
|
|
220
|
+
getResetHref: () => "/d",
|
|
221
|
+
});
|
|
222
|
+
const html = await mount(withBoundary(buildTree(ThrowingPage, [], {}), "app/x/page", 0));
|
|
223
|
+
expect(html).toContain("404 — Not found");
|
|
224
|
+
});
|
|
225
|
+
});
|
package/src/types.ts
CHANGED
|
@@ -467,6 +467,59 @@ export interface Connections {
|
|
|
467
467
|
// Context objects — what handlers receive
|
|
468
468
|
// ---------------------------------------------------------------------------
|
|
469
469
|
|
|
470
|
+
/** Options for `ctx.requireMember()`. */
|
|
471
|
+
export interface RequireMemberOptions {
|
|
472
|
+
/**
|
|
473
|
+
* Allowed role(s). The caller's membership role must be one of these.
|
|
474
|
+
* Omit to require ANY membership regardless of role.
|
|
475
|
+
*/
|
|
476
|
+
role?: string | string[];
|
|
477
|
+
/**
|
|
478
|
+
* The membership entity to check. Default `"OrgMember"` — the same entity
|
|
479
|
+
* the framework's org/tenant machinery uses. Override for a custom model.
|
|
480
|
+
*/
|
|
481
|
+
entity?: string;
|
|
482
|
+
/** Field on the membership entity holding the org/tenant id. Default `"orgId"`. */
|
|
483
|
+
orgField?: string;
|
|
484
|
+
/** Field holding the user id. Default `"userId"`. */
|
|
485
|
+
userField?: string;
|
|
486
|
+
/** Field holding the role. Default `"role"`. */
|
|
487
|
+
roleField?: string;
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
/** The membership row returned by `ctx.requireMember()`. */
|
|
491
|
+
export type MemberRow = Record<string, unknown> & { role?: string };
|
|
492
|
+
|
|
493
|
+
/**
|
|
494
|
+
* Assert the caller is a member of `orgId` (optionally with one of `role`),
|
|
495
|
+
* returning the membership row. Throws a typed error otherwise:
|
|
496
|
+
* `UNAUTHENTICATED` (no signed-in user), `MISSING_ORG` (no orgId), or
|
|
497
|
+
* `FORBIDDEN` (not a member / wrong role).
|
|
498
|
+
*
|
|
499
|
+
* This is the authoritative authorization gate for org-scoped writes —
|
|
500
|
+
* actions + mutations BYPASS entity read policies, so a function that trusts
|
|
501
|
+
* an attacker-supplied `orgId`/`projectId` is an IDOR unless it re-checks
|
|
502
|
+
* membership. `requireMember` makes the safe path the default path.
|
|
503
|
+
*
|
|
504
|
+
* ```ts
|
|
505
|
+
* export default mutation({
|
|
506
|
+
* args: { orgId: v.id("Organization"), name: v.string() },
|
|
507
|
+
* async handler(ctx, args) {
|
|
508
|
+
* await ctx.requireMember(args.orgId, { role: ["owner", "admin"] });
|
|
509
|
+
* // …safe to mutate org-scoped data now…
|
|
510
|
+
* },
|
|
511
|
+
* });
|
|
512
|
+
* ```
|
|
513
|
+
*
|
|
514
|
+
* The membership entity must let the caller read their OWN membership row
|
|
515
|
+
* (the standard `auth.userId == data.userId` read policy) — the check runs
|
|
516
|
+
* with the caller's identity.
|
|
517
|
+
*/
|
|
518
|
+
export type RequireMember = (
|
|
519
|
+
orgId: string,
|
|
520
|
+
opts?: RequireMemberOptions,
|
|
521
|
+
) => Promise<MemberRow>;
|
|
522
|
+
|
|
470
523
|
/** Context for query handlers (read-only).
|
|
471
524
|
*
|
|
472
525
|
* NOTE: `ctx.llm` is NOT exposed here. Queries are reactive: a
|
|
@@ -481,6 +534,8 @@ export interface QueryCtx<R extends AuthRequirement = "optional"> {
|
|
|
481
534
|
auth: AuthInfo<R>;
|
|
482
535
|
/** Environment variables / secrets. */
|
|
483
536
|
env: Record<string, string>;
|
|
537
|
+
/** Assert org membership (optionally a role) — see {@link RequireMember}. */
|
|
538
|
+
requireMember: RequireMember;
|
|
484
539
|
}
|
|
485
540
|
|
|
486
541
|
/** Context for mutation handlers (read + write, transactional). */
|
|
@@ -497,6 +552,8 @@ export interface MutationCtx<R extends AuthRequirement = "optional"> {
|
|
|
497
552
|
connections: Connections;
|
|
498
553
|
/** Create a typed error that triggers rollback. */
|
|
499
554
|
error(code: string, message: string): Error;
|
|
555
|
+
/** Assert org membership (optionally a role) — see {@link RequireMember}. */
|
|
556
|
+
requireMember: RequireMember;
|
|
500
557
|
}
|
|
501
558
|
|
|
502
559
|
/** Context for action handlers (external I/O, non-transactional). */
|
|
@@ -524,6 +581,8 @@ export interface ActionCtx<R extends AuthRequirement = "optional"> {
|
|
|
524
581
|
): Promise<T>;
|
|
525
582
|
/** Create a typed error. */
|
|
526
583
|
error(code: string, message: string): Error;
|
|
584
|
+
/** Assert org membership (optionally a role) — see {@link RequireMember}. */
|
|
585
|
+
requireMember: RequireMember;
|
|
527
586
|
/**
|
|
528
587
|
* HTTP request metadata — present only when the action was invoked via
|
|
529
588
|
* a `defineRoute` HTTP binding. Missing when the action is called from
|