@pylonsync/functions 0.3.385 → 0.4.1

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,14 +16,6 @@
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
19
  export declare function buildSsrFnCaller(callId: string): (name: string, args?: Record<string, unknown>) => Promise<unknown>;
28
20
  export declare function buildDbWriter(callId: string): DbWriter;
29
21
  /**
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
@@ -546,6 +550,38 @@ export type MemberRow = Record<string, unknown> & {
546
550
  * with the caller's identity.
547
551
  */
548
552
  export type RequireMember = (orgId: string, opts?: RequireMemberOptions) => Promise<MemberRow>;
553
+ /**
554
+ * Signed file-download URLs. `GET /api/files/<id>` only serves a file to its
555
+ * owner (or an unscoped admin) — `signedUrl` is the supported way to
556
+ * authorize a cross-user read: the host mints a short-lived HMAC-signed
557
+ * path (`/api/files/<id>?sig=...&exp=...`) that the GET handler honors as
558
+ * an alternative to the owner check, anonymously fetchable (works in
559
+ * `<img src>` and download links).
560
+ *
561
+ * WHO gets a URL is the calling function's responsibility — wrap the mint
562
+ * in a membership-gated function so authorization stays app-policy-driven:
563
+ *
564
+ * ```ts
565
+ * export default query({
566
+ * args: { eventId: v.id("Event"), fileId: v.string() },
567
+ * async handler(ctx, args) {
568
+ * const event = await ctx.db.get("Event", args.eventId);
569
+ * await ctx.requireMember(event.orgId, { role: "organizer" });
570
+ * return { url: await ctx.files.signedUrl(args.fileId) };
571
+ * },
572
+ * });
573
+ * ```
574
+ */
575
+ export interface Files {
576
+ /**
577
+ * Mint a signed download path for a file id. `ttlSecs` defaults to 300
578
+ * and is capped by the host (24h) — a signed URL is a bearer capability,
579
+ * so keep lifetimes short.
580
+ */
581
+ signedUrl(fileId: string, opts?: {
582
+ ttlSecs?: number;
583
+ }): Promise<string>;
584
+ }
549
585
  /** Context for query handlers (read-only).
550
586
  *
551
587
  * NOTE: `ctx.llm` is NOT exposed here. Queries are reactive: a
@@ -569,6 +605,8 @@ export interface QueryCtx<R extends AuthRequirement = "optional"> {
569
605
  error(code: string, message: string): Error;
570
606
  /** Assert org membership (optionally a role) — see {@link RequireMember}. */
571
607
  requireMember: RequireMember;
608
+ /** Signed file-download URLs — see {@link Files}. */
609
+ files: Files;
572
610
  }
573
611
  /** Context for mutation handlers (read + write, transactional). */
574
612
  export interface MutationCtx<R extends AuthRequirement = "optional"> {
@@ -584,6 +622,8 @@ export interface MutationCtx<R extends AuthRequirement = "optional"> {
584
622
  rooms: Rooms;
585
623
  /** Per-user OAuth connection registry. */
586
624
  connections: Connections;
625
+ /** Signed file-download URLs — see {@link Files}. */
626
+ files: Files;
587
627
  /** Create a typed error that triggers rollback. */
588
628
  error(code: string, message: string): Error;
589
629
  /** Assert org membership (optionally a role) — see {@link RequireMember}. */
@@ -604,6 +644,8 @@ export interface ActionCtx<R extends AuthRequirement = "optional"> {
604
644
  connections: Connections;
605
645
  /** Environment variables / secrets. */
606
646
  env: Record<string, string>;
647
+ /** Signed file-download URLs — see {@link Files}. */
648
+ files: Files;
607
649
  /** Run a registered query within its own read transaction. */
608
650
  runQuery<T = unknown>(fnName: string, args: Record<string, unknown>): Promise<T>;
609
651
  /** Run a registered mutation within its own write transaction. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pylonsync/functions",
3
- "version": "0.3.385",
3
+ "version": "0.4.1",
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
@@ -21,6 +21,7 @@ import type {
21
21
  EmailAttachment,
22
22
  EmailOptions,
23
23
  EmailSender,
24
+ Files,
24
25
  Stream,
25
26
  Scheduler,
26
27
  Llm,
@@ -35,6 +36,7 @@ import type {
35
36
  FnDefinition,
36
37
  AuthInfo,
37
38
  } from "./types";
39
+ import { normalizeAuthClaims } from "./auth";
38
40
  import { makeRequireMember } from "./member";
39
41
  import { isDevMode } from "./ssr-runtime";
40
42
  import { validateArgs } from "./validators";
@@ -517,6 +519,19 @@ export function buildDbReader(callId: string, ssrRead = false): DbReader {
517
519
  * op_id) is correct here: concurrent calls queue settle-chained on the one
518
520
  * call_id, matching how ctx.runQuery behaves inside a function.
519
521
  */
522
+ /** `ctx.files` — signed download URLs, minted host-side. */
523
+ function buildFiles(callId: string): Files {
524
+ return {
525
+ async signedUrl(fileId, opts) {
526
+ return rpc(callId, {
527
+ type: "sign_file_url",
528
+ file_id: fileId,
529
+ ttl_secs: opts?.ttlSecs,
530
+ }) as Promise<string>;
531
+ },
532
+ };
533
+ }
534
+
520
535
  export function buildSsrFnCaller(
521
536
  callId: string,
522
537
  ): (name: string, args?: Record<string, unknown>) => Promise<unknown> {
@@ -990,6 +1005,7 @@ function buildActionCtx(
990
1005
  (err as any).code = code;
991
1006
  return err;
992
1007
  },
1008
+ files: buildFiles(callId),
993
1009
  // Actions have no ctx.db; read membership via the built-in internal query.
994
1010
  requireMember: makeRequireMember(auth.userId, (entity, filter) =>
995
1011
  rpc(callId, {
@@ -1082,11 +1098,7 @@ async function handleCall(msg: CallMessage): Promise<void> {
1082
1098
  // already got camelCase don't regress.
1083
1099
  const rawAuth = msg.auth as unknown as Record<string, unknown>;
1084
1100
  const auth: AuthInfo = {
1085
- userId: ((rawAuth.userId ?? rawAuth.user_id) as string | null | undefined) ?? null,
1086
- isAdmin: Boolean(rawAuth.isAdmin ?? rawAuth.is_admin),
1087
- tenantId:
1088
- ((rawAuth.tenantId ?? rawAuth.tenant_id) as string | null | undefined) ??
1089
- null,
1101
+ ...normalizeAuthClaims(rawAuth),
1090
1102
  // `elevate` round-trips through the host runtime which mutates
1091
1103
  // the per-call caller_is_admin flag — that's what subsequent
1092
1104
  // scheduler.runAfter() reads. We also mutate the local
@@ -1136,6 +1148,7 @@ async function handleCall(msg: CallMessage): Promise<void> {
1136
1148
  requireMember: makeRequireMember(auth.userId, (entity, filter) =>
1137
1149
  reader.query(entity, { ...filter, $limit: 1 }),
1138
1150
  ),
1151
+ files: buildFiles(msg.call_id),
1139
1152
  };
1140
1153
  break;
1141
1154
  }
@@ -1158,6 +1171,7 @@ async function handleCall(msg: CallMessage): Promise<void> {
1158
1171
  requireMember: makeRequireMember(auth.userId, (entity, filter) =>
1159
1172
  writer.query(entity, { ...filter, $limit: 1 }),
1160
1173
  ),
1174
+ files: buildFiles(msg.call_id),
1161
1175
  };
1162
1176
  break;
1163
1177
  }
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
@@ -640,6 +644,37 @@ export type RequireMember = (
640
644
  opts?: RequireMemberOptions,
641
645
  ) => Promise<MemberRow>;
642
646
 
647
+ /**
648
+ * Signed file-download URLs. `GET /api/files/<id>` only serves a file to its
649
+ * owner (or an unscoped admin) — `signedUrl` is the supported way to
650
+ * authorize a cross-user read: the host mints a short-lived HMAC-signed
651
+ * path (`/api/files/<id>?sig=...&exp=...`) that the GET handler honors as
652
+ * an alternative to the owner check, anonymously fetchable (works in
653
+ * `<img src>` and download links).
654
+ *
655
+ * WHO gets a URL is the calling function's responsibility — wrap the mint
656
+ * in a membership-gated function so authorization stays app-policy-driven:
657
+ *
658
+ * ```ts
659
+ * export default query({
660
+ * args: { eventId: v.id("Event"), fileId: v.string() },
661
+ * async handler(ctx, args) {
662
+ * const event = await ctx.db.get("Event", args.eventId);
663
+ * await ctx.requireMember(event.orgId, { role: "organizer" });
664
+ * return { url: await ctx.files.signedUrl(args.fileId) };
665
+ * },
666
+ * });
667
+ * ```
668
+ */
669
+ export interface Files {
670
+ /**
671
+ * Mint a signed download path for a file id. `ttlSecs` defaults to 300
672
+ * and is capped by the host (24h) — a signed URL is a bearer capability,
673
+ * so keep lifetimes short.
674
+ */
675
+ signedUrl(fileId: string, opts?: { ttlSecs?: number }): Promise<string>;
676
+ }
677
+
643
678
  /** Context for query handlers (read-only).
644
679
  *
645
680
  * NOTE: `ctx.llm` is NOT exposed here. Queries are reactive: a
@@ -663,6 +698,8 @@ export interface QueryCtx<R extends AuthRequirement = "optional"> {
663
698
  error(code: string, message: string): Error;
664
699
  /** Assert org membership (optionally a role) — see {@link RequireMember}. */
665
700
  requireMember: RequireMember;
701
+ /** Signed file-download URLs — see {@link Files}. */
702
+ files: Files;
666
703
  }
667
704
 
668
705
  /** Context for mutation handlers (read + write, transactional). */
@@ -679,6 +716,8 @@ export interface MutationCtx<R extends AuthRequirement = "optional"> {
679
716
  rooms: Rooms;
680
717
  /** Per-user OAuth connection registry. */
681
718
  connections: Connections;
719
+ /** Signed file-download URLs — see {@link Files}. */
720
+ files: Files;
682
721
  /** Create a typed error that triggers rollback. */
683
722
  error(code: string, message: string): Error;
684
723
  /** Assert org membership (optionally a role) — see {@link RequireMember}. */
@@ -700,6 +739,8 @@ export interface ActionCtx<R extends AuthRequirement = "optional"> {
700
739
  connections: Connections;
701
740
  /** Environment variables / secrets. */
702
741
  env: Record<string, string>;
742
+ /** Signed file-download URLs — see {@link Files}. */
743
+ files: Files;
703
744
  /** Run a registered query within its own read transaction. */
704
745
  runQuery<T = unknown>(
705
746
  fnName: string,