@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 +4 -0
- package/dist/runtime.d.ts +0 -8
- package/dist/types.d.ts +42 -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 +19 -5
- package/src/types.ts +41 -0
package/dist/auth.d.ts
ADDED
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
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
|
@@ -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
|
-
|
|
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,
|