@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 +4 -0
- package/dist/runtime.d.ts +9 -0
- package/dist/ssr-runtime.d.ts +10 -0
- package/dist/types.d.ts +4 -0
- package/package.json +1 -1
- package/src/auth.test.ts +27 -0
- package/src/auth.ts +19 -0
- package/src/member.test.ts +14 -0
- package/src/runtime.ts +22 -5
- package/src/ssr-client-bundler.ts +13 -0
- package/src/ssr-runtime.ts +48 -5
- package/src/ssr-serverdata-fn.test.ts +83 -0
- package/src/types.ts +4 -0
package/dist/auth.d.ts
ADDED
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.
|
package/dist/ssr-runtime.d.ts
CHANGED
|
@@ -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
package/src/auth.test.ts
ADDED
|
@@ -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
|
+
}
|
package/src/member.test.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
package/src/ssr-runtime.ts
CHANGED
|
@@ -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(
|
|
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
|