@pylonsync/functions 0.3.384 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/auth.d.ts ADDED
@@ -0,0 +1,4 @@
1
+ import type { AuthInfo } from "./types";
2
+ export type AuthClaims = Omit<AuthInfo, "elevate">;
3
+ /** Normalize the Rust wire envelope into the public function auth shape. */
4
+ export declare function normalizeAuthClaims(raw: Record<string, unknown>): AuthClaims;
package/dist/runtime.d.ts CHANGED
@@ -16,6 +16,15 @@
16
16
  */
17
17
  import type { DbReader, DbWriter, Llm, Rooms } from "./types";
18
18
  export declare function buildDbReader(callId: string, ssrRead?: boolean): DbReader;
19
+ /**
20
+ * Query-function caller for SSR `serverData.fn(name, args)`. Sends the same
21
+ * `run_fn` frame `ctx.runQuery` uses, on the render's call_id — the host's
22
+ * render loop executes the query with the PAGE's auth context (anonymous on
23
+ * public pages) and rejects anything that isn't a query. Legacy rpc() (no
24
+ * op_id) is correct here: concurrent calls queue settle-chained on the one
25
+ * call_id, matching how ctx.runQuery behaves inside a function.
26
+ */
27
+ export declare function buildSsrFnCaller(callId: string): (name: string, args?: Record<string, unknown>) => Promise<unknown>;
19
28
  export declare function buildDbWriter(callId: string): DbWriter;
20
29
  /**
21
30
  * Build the LLM client that round-trips through the host runtime.
@@ -394,6 +394,16 @@ export declare function buildHydrationTail(args: {
394
394
  * carrying any stack content. FNV-1a over message+stack, 8 hex chars.
395
395
  */
396
396
  export declare function errorDigest(err: any): string;
397
+ /**
398
+ * Wrap a DbReader so each `serverData.x(...)` call returns a PROMISE CACHED
399
+ * by (method, args) — required for React 19 `use()`, which re-invokes the
400
+ * call on the post-suspense re-render and must get the same (now-resolved)
401
+ * promise instead of a fresh pending one (else it suspends forever). Each
402
+ * resolved value is also recorded into `valueCache` keyed identically, so it
403
+ * can be serialized into `__PYLON_DATA__.ssrData` and replayed on the client
404
+ * — keeping hydration free of mismatches.
405
+ */
406
+ export declare function makeServerData(reader: any, valueCache: Record<string, any>, callFn?: (name: string, args?: Record<string, unknown>) => Promise<unknown>, onFnCall?: () => void): any;
397
407
  /**
398
408
  * #278: does this route STREAM (vs buffer the whole document)? Streaming is
399
409
  * opt-in: a `loading.tsx` (route-level Suspense) or `export const streaming =
package/dist/types.d.ts CHANGED
@@ -40,6 +40,10 @@ export interface AuthInfo<R extends AuthRequirement = "optional"> {
40
40
  /** Active tenant id (selected organization) for multi-tenant apps.
41
41
  * Null when the session hasn't selected one. */
42
42
  tenantId: string | null;
43
+ /** Exact role slugs for the active organization/session. Custom roles do
44
+ * not imply `member` or `admin`; compare explicitly or use
45
+ * `ctx.requireMember` for an authoritative membership lookup. */
46
+ roles: string[];
43
47
  /**
44
48
  * Promote the call's auth context after the handler has done its
45
49
  * own authentication check (HMAC signature verification on a
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pylonsync/functions",
3
- "version": "0.3.384",
3
+ "version": "0.4.0",
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",
@@ -0,0 +1,27 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { normalizeAuthClaims } from "./auth";
3
+
4
+ describe("function auth normalization", () => {
5
+ test("preserves custom roles from the Rust wire envelope", () => {
6
+ expect(
7
+ normalizeAuthClaims({
8
+ user_id: "u1",
9
+ is_admin: false,
10
+ tenant_id: "org_1",
11
+ roles: ["reviewer"],
12
+ }),
13
+ ).toEqual({
14
+ userId: "u1",
15
+ isAdmin: false,
16
+ tenantId: "org_1",
17
+ roles: ["reviewer"],
18
+ });
19
+ });
20
+
21
+ test("defaults missing roles to an empty array and drops non-strings", () => {
22
+ expect(normalizeAuthClaims({}).roles).toEqual([]);
23
+ expect(normalizeAuthClaims({ roles: ["billing", 42, null] }).roles).toEqual([
24
+ "billing",
25
+ ]);
26
+ });
27
+ });
package/src/auth.ts ADDED
@@ -0,0 +1,19 @@
1
+ import type { AuthInfo } from "./types";
2
+
3
+ export type AuthClaims = Omit<AuthInfo, "elevate">;
4
+
5
+ /** Normalize the Rust wire envelope into the public function auth shape. */
6
+ export function normalizeAuthClaims(
7
+ raw: Record<string, unknown>,
8
+ ): AuthClaims {
9
+ return {
10
+ userId:
11
+ ((raw.userId ?? raw.user_id) as string | null | undefined) ?? null,
12
+ isAdmin: Boolean(raw.isAdmin ?? raw.is_admin),
13
+ tenantId:
14
+ ((raw.tenantId ?? raw.tenant_id) as string | null | undefined) ?? null,
15
+ roles: Array.isArray(raw.roles)
16
+ ? raw.roles.filter((role): role is string => typeof role === "string")
17
+ : [],
18
+ };
19
+ }
@@ -65,6 +65,20 @@ describe("ctx.requireMember", () => {
65
65
  ).toBe("FORBIDDEN");
66
66
  });
67
67
 
68
+ test("custom roles are exact-match and inherit no built-in role", async () => {
69
+ const reviewer = { id: "m1", role: "reviewer" };
70
+ const requireMember = makeRequireMember("u1", spyRead([reviewer]).read);
71
+
72
+ expect(
73
+ await codeOf(() =>
74
+ requireMember("org_1", { role: ["owner", "admin", "member"] }),
75
+ ),
76
+ ).toBe("FORBIDDEN");
77
+ expect(await requireMember("org_1", { role: ["reviewer"] })).toEqual(
78
+ reviewer,
79
+ );
80
+ });
81
+
68
82
  test("role gate: passes when the member's role IS allowed (string or array)", async () => {
69
83
  const owner = { id: "m1", role: "owner" };
70
84
  expect(
package/src/runtime.ts CHANGED
@@ -35,6 +35,7 @@ import type {
35
35
  FnDefinition,
36
36
  AuthInfo,
37
37
  } from "./types";
38
+ import { normalizeAuthClaims } from "./auth";
38
39
  import { makeRequireMember } from "./member";
39
40
  import { isDevMode } from "./ssr-runtime";
40
41
  import { validateArgs } from "./validators";
@@ -509,6 +510,26 @@ export function buildDbReader(callId: string, ssrRead = false): DbReader {
509
510
  };
510
511
  }
511
512
 
513
+ /**
514
+ * Query-function caller for SSR `serverData.fn(name, args)`. Sends the same
515
+ * `run_fn` frame `ctx.runQuery` uses, on the render's call_id — the host's
516
+ * render loop executes the query with the PAGE's auth context (anonymous on
517
+ * public pages) and rejects anything that isn't a query. Legacy rpc() (no
518
+ * op_id) is correct here: concurrent calls queue settle-chained on the one
519
+ * call_id, matching how ctx.runQuery behaves inside a function.
520
+ */
521
+ export function buildSsrFnCaller(
522
+ callId: string,
523
+ ): (name: string, args?: Record<string, unknown>) => Promise<unknown> {
524
+ return (name, args) =>
525
+ rpc(callId, {
526
+ type: "run_fn",
527
+ fn_name: name,
528
+ fn_type: "query",
529
+ args: args ?? {},
530
+ });
531
+ }
532
+
512
533
  function buildReaderOps(
513
534
  callId: string,
514
535
  unsafeOp: boolean,
@@ -1062,11 +1083,7 @@ async function handleCall(msg: CallMessage): Promise<void> {
1062
1083
  // already got camelCase don't regress.
1063
1084
  const rawAuth = msg.auth as unknown as Record<string, unknown>;
1064
1085
  const auth: AuthInfo = {
1065
- userId: ((rawAuth.userId ?? rawAuth.user_id) as string | null | undefined) ?? null,
1066
- isAdmin: Boolean(rawAuth.isAdmin ?? rawAuth.is_admin),
1067
- tenantId:
1068
- ((rawAuth.tenantId ?? rawAuth.tenant_id) as string | null | undefined) ??
1069
- null,
1086
+ ...normalizeAuthClaims(rawAuth),
1070
1087
  // `elevate` round-trips through the host runtime which mutates
1071
1088
  // the per-call caller_is_admin flag — that's what subsequent
1072
1089
  // scheduler.runAfter() reads. We also mutate the local
@@ -385,6 +385,14 @@ function makeClientServerData(ssrData) {
385
385
  };
386
386
  const sd = wrap("");
387
387
  sd.unsafe = wrap("u:");
388
+ // serverData.fn(name, args) — key MUST match the server's makeServerData
389
+ // ("fn:" + name + ":" + stableStringify(args ?? {})) so hydration replays
390
+ // the query function's SSR'd result without a mismatch.
391
+ sd.fn = (name, args) => {
392
+ const key = "fn:" + name + ":" + stableStringify(args ?? {});
393
+ if (!pc.has(key)) pc.set(key, fulfilledThenable(cache[key]));
394
+ return pc.get(key);
395
+ };
388
396
  return sd;
389
397
  }
390
398
 
@@ -411,6 +419,11 @@ function makePendingServerData() {
411
419
  };
412
420
  const sd = wrap("");
413
421
  sd.unsafe = wrap("u:");
422
+ sd.fn = (name, args) => {
423
+ const key = "fn:" + name + ":" + stableStringify(args ?? {});
424
+ if (!pc.has(key)) pc.set(key, pending());
425
+ return pc.get(key);
426
+ };
414
427
  return sd;
415
428
  }
416
429
 
@@ -2193,7 +2193,19 @@ const SERVER_DATA_METHODS = [
2193
2193
  * can be serialized into `__PYLON_DATA__.ssrData` and replayed on the client
2194
2194
  * — keeping hydration free of mismatches.
2195
2195
  */
2196
- function makeServerData(reader: any, valueCache: Record<string, any>): any {
2196
+ export function makeServerData(
2197
+ reader: any,
2198
+ valueCache: Record<string, any>,
2199
+ // `serverData.fn(name, args)` — runs a registered QUERY function with the
2200
+ // page's own auth context (the host's render loop enforces query-only and
2201
+ // executes it exactly like ctx.runQuery). Lets a page SSR data whose safe
2202
+ // public projection lives in a gated query instead of open row policies.
2203
+ callFn?: (name: string, args?: Record<string, unknown>) => Promise<unknown>,
2204
+ // Fired on every serverData.fn call so the render can veto shared caching
2205
+ // when the call carried a real identity (the fn may read ctx.auth, making
2206
+ // its output identity-specific without the page ever touching props.auth).
2207
+ onFnCall?: () => void,
2208
+ ): any {
2197
2209
  const promiseCache = new Map<string, Promise<any>>();
2198
2210
  const wrap = (r: any, prefix: string): any => {
2199
2211
  const out: any = {};
@@ -2215,6 +2227,23 @@ function makeServerData(reader: any, valueCache: Record<string, any>): any {
2215
2227
  };
2216
2228
  const sd = wrap(reader, "");
2217
2229
  if (reader.unsafe) sd.unsafe = wrap(reader.unsafe, "u:");
2230
+ if (callFn) {
2231
+ sd.fn = (name: string, args?: Record<string, unknown>) => {
2232
+ // Key MUST match the client shim's `fn` key so hydration replays
2233
+ // the value from ssrData without a mismatch.
2234
+ const key = "fn:" + name + ":" + stableStringify(args ?? {});
2235
+ let p = promiseCache.get(key);
2236
+ if (!p) {
2237
+ onFnCall?.();
2238
+ p = callFn(name, args).then((value: any) => {
2239
+ valueCache[key] = value;
2240
+ return value;
2241
+ });
2242
+ promiseCache.set(key, p);
2243
+ }
2244
+ return p;
2245
+ };
2246
+ }
2218
2247
  return sd;
2219
2248
  }
2220
2249
 
@@ -2794,7 +2823,11 @@ export async function handleRenderRoute(
2794
2823
  // gate as a query function's ctx.db, and rejects any write. Promise-
2795
2824
  // cached so `use()` doesn't re-suspend forever; resolved values land in
2796
2825
  // `ssrValueCache` for hydration replay.
2797
- const { buildDbReader } = await import("./runtime");
2826
+ const { buildDbReader, buildSsrFnCaller } = await import("./runtime");
2827
+ // Reading auth at all (even for an anon request) opts the render OUT of
2828
+ // caching, because the output could differ by identity. Declared before
2829
+ // serverData so `serverData.fn` can flip it too (below).
2830
+ let authTouched = false;
2798
2831
  // `ssrRead: true` — serverData results are serialized into the
2799
2832
  // client-visible `__PYLON_DATA__` blob, so the host applies the same
2800
2833
  // per-row policy filter + `server_only`/`passwordHash` projection the
@@ -2802,6 +2835,19 @@ export async function handleRenderRoute(
2802
2835
  const serverData = makeServerData(
2803
2836
  buildDbReader(msg.call_id, true),
2804
2837
  ssrValueCache,
2838
+ buildSsrFnCaller(msg.call_id),
2839
+ // A query function runs with the page's auth and may read ctx.auth
2840
+ // internally — identity-specific output the read-tracking proxies
2841
+ // can't see. An identity-carrying render that called serverData.fn
2842
+ // must therefore veto shared caches exactly as if it read props.auth.
2843
+ // Anonymous renders stay cacheable: the anonymous result is uniform
2844
+ // across every anonymous visitor.
2845
+ () => {
2846
+ const a = msg.auth as Record<string, unknown> | undefined;
2847
+ if (a && (a.user_id || a.is_admin || a.tenant_id)) {
2848
+ authTouched = true;
2849
+ }
2850
+ },
2805
2851
  );
2806
2852
 
2807
2853
  // #277 cache-safety proof. A render is shareable (CDN/disk cacheable) ONLY
@@ -2820,9 +2866,6 @@ export async function handleRenderRoute(
2820
2866
  return proxy;
2821
2867
  };
2822
2868
 
2823
- // Reading auth at all (even for an anon request) opts the render OUT of
2824
- // caching, because the output could differ by identity.
2825
- let authTouched = false;
2826
2869
  const authProxy = track(
2827
2870
  msg.auth as Record<string, unknown> | undefined,
2828
2871
  () => {
@@ -0,0 +1,83 @@
1
+ /**
2
+ * `serverData.fn(name, args)` — SSR pages calling query functions with the
3
+ * page's auth context. Pins the pieces that must not drift:
4
+ *
5
+ * - the cache key shape ("fn:" + name + ":" + stableStringify(args ?? {})),
6
+ * which the client hydration shim replays from ssrData — a mismatch means
7
+ * hydration re-suspends forever;
8
+ * - promise caching per (name, args) so React 19 `use()` gets the SAME
9
+ * promise on the post-suspense re-render;
10
+ * - resolved values recorded into the valueCache (the ssrData blob);
11
+ * - the onFnCall notification that lets the render veto shared caching for
12
+ * identity-carrying requests;
13
+ * - the client runtime template carrying the matching `fn` shim.
14
+ */
15
+ import { describe, expect, test } from "bun:test";
16
+ import { makeServerData } from "./ssr-runtime";
17
+
18
+ const noopReader = {};
19
+
20
+ describe("makeServerData.fn", () => {
21
+ test("caches per (name, args) and records into valueCache with the wire key", async () => {
22
+ const valueCache: Record<string, any> = {};
23
+ const calls: Array<[string, any]> = [];
24
+ const sd = makeServerData(noopReader, valueCache, async (name, args) => {
25
+ calls.push([name, args]);
26
+ return { rows: [name, args?.x ?? null] };
27
+ });
28
+
29
+ const p1 = sd.fn("getPublicSchedule", { x: 1 });
30
+ const p2 = sd.fn("getPublicSchedule", { x: 1 });
31
+ expect(p1).toBe(p2); // use() re-render must see the SAME promise
32
+
33
+ const v = await p1;
34
+ expect(v).toEqual({ rows: ["getPublicSchedule", 1] });
35
+ expect(calls.length).toBe(1); // deduped
36
+
37
+ // Distinct args → distinct call, distinct cache entry.
38
+ await sd.fn("getPublicSchedule", { x: 2 });
39
+ expect(calls.length).toBe(2);
40
+
41
+ // No args normalizes to {} in the key.
42
+ await sd.fn("getSpeakers");
43
+ expect(Object.keys(valueCache).sort()).toEqual([
44
+ 'fn:getPublicSchedule:{"x":1}',
45
+ 'fn:getPublicSchedule:{"x":2}',
46
+ "fn:getSpeakers:{}",
47
+ ]);
48
+ });
49
+
50
+ test("onFnCall fires once per unique call (the cache-veto hook)", async () => {
51
+ let touches = 0;
52
+ const sd = makeServerData(
53
+ noopReader,
54
+ {},
55
+ async () => null,
56
+ () => {
57
+ touches++;
58
+ },
59
+ );
60
+ sd.fn("a");
61
+ sd.fn("a"); // cached — no second touch
62
+ sd.fn("b");
63
+ expect(touches).toBe(2);
64
+ });
65
+
66
+ test("no callFn → no fn method (older host pairing)", () => {
67
+ const sd = makeServerData(noopReader, {});
68
+ expect(sd.fn).toBeUndefined();
69
+ });
70
+ });
71
+
72
+ describe("client runtime shim parity", () => {
73
+ test("CLIENT_RUNTIME_SOURCE replays fn results with the identical key", async () => {
74
+ const src = await Bun.file(
75
+ new URL("./ssr-client-bundler.ts", import.meta.url).pathname,
76
+ ).text();
77
+ // Both client stand-ins (hydration replay + optimistic-nav pending) must
78
+ // build the exact server-side key. String-level guard against drift.
79
+ const keyExpr = '"fn:" + name + ":" + stableStringify(args ?? {})';
80
+ const count = src.split(keyExpr).length - 1;
81
+ expect(count).toBeGreaterThanOrEqual(2);
82
+ });
83
+ });
package/src/types.ts CHANGED
@@ -47,6 +47,10 @@ export interface AuthInfo<R extends AuthRequirement = "optional"> {
47
47
  /** Active tenant id (selected organization) for multi-tenant apps.
48
48
  * Null when the session hasn't selected one. */
49
49
  tenantId: string | null;
50
+ /** Exact role slugs for the active organization/session. Custom roles do
51
+ * not imply `member` or `admin`; compare explicitly or use
52
+ * `ctx.requireMember` for an authoritative membership lookup. */
53
+ roles: string[];
50
54
  /**
51
55
  * Promote the call's auth context after the handler has done its
52
56
  * own authentication check (HMAC signature verification on a