@pylonsync/functions 0.3.271 → 0.3.272

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pylonsync/functions",
3
- "version": "0.3.271",
3
+ "version": "0.3.272",
4
4
  "description": "TypeScript function runtime for pylon — defines server-side queries, mutations, and actions.",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
package/src/index.ts CHANGED
@@ -46,4 +46,7 @@ export type {
46
46
  AuthMode,
47
47
  AuthRequirement,
48
48
  FnDefinition,
49
+ RequireMember,
50
+ RequireMemberOptions,
51
+ MemberRow,
49
52
  } from "./types";
@@ -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
- ctx = { db: buildDbReader(msg.call_id), auth, env };
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
- case "mutation":
885
+ }
886
+ case "mutation": {
887
+ const writer = buildDbWriter(msg.call_id);
855
888
  ctx = {
856
- db: buildDbWriter(msg.call_id),
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 { Component, createElement, useEffect, useState } from "react";
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
- // Walk up the active route's component path to the nearest boundary module
282
- // (<dir>/not-found or <dir>/error) present in the manifest. Manifest route
283
- // keys are cwd-relative component paths with "/" separators (e.g.
284
- // "web/app/dashboard/not-found"), so no platform normalization is needed.
285
- // MUST stay in lockstep with the exported nearestBoundaryComponent() that
286
- // function is the tested spec for this exact walk.
287
- async function resolveBoundaryComponent(component, fileName) {
288
- const manifest = await loadManifest();
289
- if (!manifest || !manifest.routes || !component) return null;
290
- let dir = String(component);
291
- dir = dir.includes("/") ? dir.slice(0, dir.lastIndexOf("/")) : "";
292
- while (dir) {
293
- const key = dir + "/" + fileName;
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