@protobase/server 0.7.0 → 0.8.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/assistant/conversations.d.ts +1 -1
- package/dist/assistant/tools.d.ts +1 -1
- package/dist/assistant/turn.d.ts +2 -2
- package/dist/audit/origin.js +12 -0
- package/dist/auth/jwt.d.ts +2 -0
- package/dist/auth/jwt.js +5 -3
- package/dist/batch/reorder.js +1 -1
- package/dist/better-auth/authenticator.d.ts +7 -3
- package/dist/better-auth/authenticator.js +24 -3
- package/dist/better-auth/create-auth.d.ts +619 -81
- package/dist/better-auth/create-auth.js +64 -27
- package/dist/better-auth/organizations/accept-invitation.js +80 -0
- package/dist/better-auth/organizations/active-organization.js +12 -0
- package/dist/better-auth/organizations/admin-routes.js +83 -0
- package/dist/better-auth/organizations/app-roles.js +43 -0
- package/dist/better-auth/organizations/host.d.ts +53 -0
- package/dist/better-auth/organizations/host.js +251 -0
- package/dist/better-auth/organizations/invitation-endpoints.js +133 -0
- package/dist/better-auth/organizations/invitation-mail.js +17 -0
- package/dist/better-auth/organizations/invitation-token.js +27 -0
- package/dist/better-auth/organizations/member-endpoints.js +182 -0
- package/dist/better-auth/organizations/options.d.ts +48 -0
- package/dist/better-auth/organizations/options.js +23 -0
- package/dist/better-auth/organizations/organization-plugin.js +128 -0
- package/dist/better-auth/organizations/protobase-plugin.js +21 -0
- package/dist/better-auth/organizations/role-definitions.d.ts +15 -0
- package/dist/better-auth/organizations/role-definitions.js +56 -0
- package/dist/better-auth/organizations/session-organization.js +68 -0
- package/dist/better-auth/organizations/store.d.ts +19 -0
- package/dist/better-auth/organizations/store.js +73 -0
- package/dist/better-auth/staff-checks.js +5 -1
- package/dist/better-auth/staff-sign-in-endpoints.js +1 -1
- package/dist/better-auth/status-route.js +14 -3
- package/dist/better-auth/users.d.ts +2 -1
- package/dist/better-auth/users.js +4 -1
- package/dist/create-admin.d.ts +8 -2
- package/dist/create-admin.js +31 -2
- package/dist/deps.d.ts +1 -0
- package/dist/files/attach.js +80 -0
- package/dist/files/cleanup-runner.d.ts +8 -0
- package/dist/files/cleanup-runner.js +58 -0
- package/dist/files/cleanup-schedule.d.ts +28 -0
- package/dist/files/cleanup-schedule.js +48 -0
- package/dist/files/detect.js +51 -0
- package/dist/files/download-url.js +43 -0
- package/dist/files/download.js +90 -0
- package/dist/files/duration.js +35 -0
- package/dist/files/file-cleanup.d.ts +18 -0
- package/dist/files/file-cleanup.js +17 -0
- package/dist/files/local-files.d.ts +17 -0
- package/dist/files/local-files.js +101 -0
- package/dist/files/media-types.js +99 -0
- package/dist/files/options.d.ts +15 -0
- package/dist/files/present.d.ts +15 -0
- package/dist/files/present.js +59 -0
- package/dist/files/processors.d.ts +15 -0
- package/dist/files/processors.js +39 -0
- package/dist/files/providers.js +70 -0
- package/dist/files/routes.js +26 -0
- package/dist/files/runtime.d.ts +3 -0
- package/dist/files/runtime.js +37 -0
- package/dist/files/signer.js +32 -0
- package/dist/files/store.d.ts +37 -0
- package/dist/files/ticket.js +87 -0
- package/dist/files/upload-body.js +85 -0
- package/dist/files/upload.js +124 -0
- package/dist/files/uri.js +47 -0
- package/dist/functions/define-function.d.ts +71 -0
- package/dist/functions/define-function.js +36 -0
- package/dist/functions/function-app.d.ts +17 -0
- package/dist/functions/function-app.js +19 -0
- package/dist/functions/read-json.d.ts +35 -0
- package/dist/functions/read-json.js +24 -0
- package/dist/functions/records.d.ts +43 -0
- package/dist/functions/records.js +31 -0
- package/dist/functions/require-env.d.ts +7 -0
- package/dist/functions/require-env.js +12 -0
- package/dist/functions/routes.js +60 -0
- package/dist/functions/verify-signature.d.ts +32 -0
- package/dist/functions/verify-signature.js +49 -0
- package/dist/index.d.ts +21 -3
- package/dist/index.js +13 -3
- package/dist/list-service.js +4 -2
- package/dist/openapi/resource-paths.js +78 -1
- package/dist/openapi/schemas.js +28 -3
- package/dist/project-config.d.ts +7 -1
- package/dist/project-config.js +37 -1
- package/dist/read-service.js +5 -1
- package/dist/request-access.d.ts +5 -0
- package/dist/request-access.js +9 -4
- package/dist/resource-source.d.ts +4 -1
- package/dist/routes/batch.js +1 -1
- package/dist/routes/collection.js +3 -1
- package/dist/routes/record.js +2 -5
- package/dist/types.d.ts +25 -5
- package/dist/write-pipeline.js +2 -1
- package/dist/write-shared.js +6 -3
- package/dist/write-update.js +2 -1
- package/package.json +6 -4
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { HttpProblem, badRequest } from "../problem.js";
|
|
2
|
+
import { accepts } from "./media-types.js";
|
|
3
|
+
import { cleanName, tenantSegment } from "./uri.js";
|
|
4
|
+
import { tenantScope } from "../tenant.js";
|
|
5
|
+
import { requestAccess, requireOperation, writableFields } from "../request-access.js";
|
|
6
|
+
import { issueTicket } from "./ticket.js";
|
|
7
|
+
import { ticketScope } from "./attach.js";
|
|
8
|
+
import { ticketSeconds } from "./runtime.js";
|
|
9
|
+
import { decideType } from "./detect.js";
|
|
10
|
+
import { FileTooLarge, incomingFile, limitedStream, readHead, tooLarge } from "./upload-body.js";
|
|
11
|
+
//#region src/files/upload.ts
|
|
12
|
+
/** How many first bytes processors get as `head`: enough for image headers, EXIF included. */
|
|
13
|
+
const headBytes = 262144;
|
|
14
|
+
const uploadField = (access, name) => {
|
|
15
|
+
if (!access.resolved.operations.create && !access.resolved.operations.update) requireOperation(access, "update");
|
|
16
|
+
const field = name !== void 0 && Object.hasOwn(access.readable.fields, name) ? access.full.fields[name] : void 0;
|
|
17
|
+
if (!field?.file) throw badRequest("invalid-parameter", `"${name ?? ""}" is not a file field of ${access.entry.name}`, { errors: [{
|
|
18
|
+
parameter: "field",
|
|
19
|
+
message: "Not a file field"
|
|
20
|
+
}] });
|
|
21
|
+
if (!writableFields(access, "create").includes(field.name) && !writableFields(access, "update").includes(field.name)) throw badRequest("invalid-parameter", `"${field.name}" is read-only`, { errors: [{
|
|
22
|
+
parameter: "field",
|
|
23
|
+
message: "Read-only"
|
|
24
|
+
}] });
|
|
25
|
+
return field;
|
|
26
|
+
};
|
|
27
|
+
const deriveValues = async (entry, field, input) => {
|
|
28
|
+
const source = entry.source.state.fields?.[field.name];
|
|
29
|
+
const derived = {};
|
|
30
|
+
for (const [target, processor] of Object.entries(source?.meta.file?.derive ?? {})) {
|
|
31
|
+
const value = !processor.accepts || accepts(processor.accepts, input.type) ? await processor.run({
|
|
32
|
+
name: input.name,
|
|
33
|
+
type: input.type,
|
|
34
|
+
size: input.size,
|
|
35
|
+
head: input.head,
|
|
36
|
+
read: async () => {
|
|
37
|
+
const body = await input.store.read(input.path);
|
|
38
|
+
if (!body) throw new Error(`The upload ${input.path} is gone from its store`);
|
|
39
|
+
return body;
|
|
40
|
+
}
|
|
41
|
+
}) : null;
|
|
42
|
+
const schema = entry.source.state.fields?.[target]?.schema;
|
|
43
|
+
const normalized = value === void 0 ? null : value;
|
|
44
|
+
if (schema && !schema.safeParse(normalized).success) throw new Error(`The processor for "${target}" of ${entry.name} returned ${JSON.stringify(normalized)}, which that field refuses`);
|
|
45
|
+
derived[target] = normalized;
|
|
46
|
+
}
|
|
47
|
+
return derived;
|
|
48
|
+
};
|
|
49
|
+
/**
|
|
50
|
+
* `POST /{resource}:upload?field=<field>`: streams one file to the field's provider and answers with a ticket for a
|
|
51
|
+
* write. The type is decided from the first bytes before anything is stored, a refused type gets 415 and a file over
|
|
52
|
+
* `maxSize` 413. The field's processors then compute its derived values, which the ticket carries signed. An upload no
|
|
53
|
+
* write ever uses is deleted after the ticket expires.
|
|
54
|
+
*/
|
|
55
|
+
const uploadFile = async (deps, entry, session, request) => {
|
|
56
|
+
const files = deps.files;
|
|
57
|
+
const access = await requestAccess(deps, entry, session);
|
|
58
|
+
const field = uploadField(access, new URL(request.url).searchParams.get("field") ?? void 0);
|
|
59
|
+
if (!files) throw new Error(`Resource "${entry.name}" has file fields but no file runtime`);
|
|
60
|
+
const { accept, maxSize, provider: providerName } = field.file;
|
|
61
|
+
const provider = files.providers[providerName];
|
|
62
|
+
const incoming = await incomingFile(request, maxSize);
|
|
63
|
+
const reader = incoming.body.getReader();
|
|
64
|
+
const { head, complete } = await readHead(reader, headBytes);
|
|
65
|
+
if (head.length > maxSize) {
|
|
66
|
+
await reader.cancel();
|
|
67
|
+
throw tooLarge(maxSize);
|
|
68
|
+
}
|
|
69
|
+
const name = cleanName(incoming.name);
|
|
70
|
+
const decision = await decideType({
|
|
71
|
+
head,
|
|
72
|
+
complete,
|
|
73
|
+
name,
|
|
74
|
+
accept,
|
|
75
|
+
...incoming.declared && { declared: incoming.declared }
|
|
76
|
+
});
|
|
77
|
+
if (!accepts(accept, decision.type)) {
|
|
78
|
+
await reader.cancel();
|
|
79
|
+
throw new HttpProblem(415, "unsupported-type", "Unsupported Media Type", `This field takes ${accept.join(", ")}; the file is ${decision.type}`, {
|
|
80
|
+
detected: decision.type,
|
|
81
|
+
accept
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
const expires = Math.floor(Date.now() / 1e3) + ticketSeconds;
|
|
85
|
+
const path = `${await tenantSegment(tenantScope(entry, session)?.tenantValue)}/${crypto.randomUUID()}.${decision.extension}`;
|
|
86
|
+
await files.schedule.add([{
|
|
87
|
+
provider: providerName,
|
|
88
|
+
path,
|
|
89
|
+
due: /* @__PURE__ */ new Date((expires + 3600) * 1e3)
|
|
90
|
+
}]);
|
|
91
|
+
let size;
|
|
92
|
+
try {
|
|
93
|
+
size = (await provider.store.put(path, limitedStream(head, reader, maxSize))).size;
|
|
94
|
+
} catch (error) {
|
|
95
|
+
if (error instanceof FileTooLarge) throw tooLarge(maxSize);
|
|
96
|
+
throw error;
|
|
97
|
+
}
|
|
98
|
+
const derived = await deriveValues(entry, field, {
|
|
99
|
+
name,
|
|
100
|
+
type: decision.type,
|
|
101
|
+
size,
|
|
102
|
+
head,
|
|
103
|
+
store: provider.store,
|
|
104
|
+
path
|
|
105
|
+
});
|
|
106
|
+
const ref = {
|
|
107
|
+
provider: providerName,
|
|
108
|
+
path,
|
|
109
|
+
name,
|
|
110
|
+
size
|
|
111
|
+
};
|
|
112
|
+
return {
|
|
113
|
+
value: await issueTicket(files.signer, ref, derived, ticketScope(access, field.name), expires),
|
|
114
|
+
file: {
|
|
115
|
+
name,
|
|
116
|
+
type: decision.type,
|
|
117
|
+
size
|
|
118
|
+
},
|
|
119
|
+
derived,
|
|
120
|
+
...decision.corrected && { corrected: decision.corrected }
|
|
121
|
+
};
|
|
122
|
+
};
|
|
123
|
+
//#endregion
|
|
124
|
+
export { headBytes, uploadFile };
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { sha256Hex } from "../hash.js";
|
|
2
|
+
//#region src/files/uri.ts
|
|
3
|
+
const provider = /^[a-z][a-z0-9-]{0,31}$/;
|
|
4
|
+
const segment = /^[A-Za-z0-9_-][A-Za-z0-9._-]{0,127}$/;
|
|
5
|
+
const maxPath = 512;
|
|
6
|
+
const isFilePath = (path) => path.length <= maxPath && path.split("/").every((part) => segment.test(part));
|
|
7
|
+
/** The identity of a value or ticket: `{provider}:{path}`. */
|
|
8
|
+
const baseOf = (value) => value.split("?", 1)[0];
|
|
9
|
+
const formatUri = (ref) => {
|
|
10
|
+
const query = new URLSearchParams({
|
|
11
|
+
name: ref.name,
|
|
12
|
+
...ref.size !== void 0 && { size: String(ref.size) }
|
|
13
|
+
});
|
|
14
|
+
return `${ref.provider}:${ref.path}?${query}`;
|
|
15
|
+
};
|
|
16
|
+
const lastSegment = (path) => path.slice(path.lastIndexOf("/") + 1);
|
|
17
|
+
/** The file a value names, or `undefined` when it does not parse. */
|
|
18
|
+
const parseUri = (value) => {
|
|
19
|
+
const match = /^([a-z][a-z0-9-]*):([^?#]+)(?:\?([^#]*))?$/.exec(value);
|
|
20
|
+
if (!match || !provider.test(match[1]) || !isFilePath(match[2])) return void 0;
|
|
21
|
+
const query = new URLSearchParams(match[3] ?? "");
|
|
22
|
+
const size = query.get("size");
|
|
23
|
+
if (size !== null && !/^\d{1,15}$/.test(size)) return void 0;
|
|
24
|
+
return {
|
|
25
|
+
provider: match[1],
|
|
26
|
+
path: match[2],
|
|
27
|
+
name: query.get("name") || lastSegment(match[2]),
|
|
28
|
+
...size !== null && { size: Number(size) }
|
|
29
|
+
};
|
|
30
|
+
};
|
|
31
|
+
const encoder = new TextEncoder();
|
|
32
|
+
/** The uploader's file name without a path or control characters, at most 255 bytes; `file` when nothing is left. */
|
|
33
|
+
const cleanName = (name) => {
|
|
34
|
+
const text = name ?? "";
|
|
35
|
+
let clean = text.slice(Math.max(text.lastIndexOf("/"), text.lastIndexOf("\\")) + 1).replace(/[\u0000-\u001f\u007f]/g, "").trim();
|
|
36
|
+
while (encoder.encode(clean).length > 255) clean = clean.slice(0, -1);
|
|
37
|
+
return clean || "file";
|
|
38
|
+
};
|
|
39
|
+
const tenantText = /^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/;
|
|
40
|
+
/** The first path segment of an upload: the row's tenant as it is when it fits, a hash of it when not, `_` without one. */
|
|
41
|
+
const tenantSegment = async (tenant) => {
|
|
42
|
+
if (tenant === void 0) return "_";
|
|
43
|
+
const text = String(tenant);
|
|
44
|
+
return tenantText.test(text) ? text : `t${(await sha256Hex(text)).slice(0, 32)}`;
|
|
45
|
+
};
|
|
46
|
+
//#endregion
|
|
47
|
+
export { baseOf, cleanName, formatUri, isFilePath, parseUri, tenantSegment };
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { Session } from "../types.js";
|
|
2
|
+
import { FunctionRecords } from "./records.js";
|
|
3
|
+
import { Db } from "@protobase/query";
|
|
4
|
+
//#region src/functions/define-function.d.ts
|
|
5
|
+
/** What every function gets: its name and the raw database. */
|
|
6
|
+
export type PublicFunctionContext = {
|
|
7
|
+
/** The function's name, as in `/api/functions/<name>`. */
|
|
8
|
+
name: string;
|
|
9
|
+
/** The database itself, with no access rules, row filters or tenant scope: privileged work only. */
|
|
10
|
+
db: Db;
|
|
11
|
+
/** The caller, through the app's own `authenticate`: rejects with a 401 problem when the request carries no valid token. */
|
|
12
|
+
caller: () => Promise<FunctionCaller>;
|
|
13
|
+
};
|
|
14
|
+
/** Who called a function, and the app's resources as them. */
|
|
15
|
+
export type FunctionCaller = {
|
|
16
|
+
session: Session;
|
|
17
|
+
/** The app's resources as this caller, under the same access rules, row filters and tenant scope as the REST API. */
|
|
18
|
+
records: FunctionRecords;
|
|
19
|
+
};
|
|
20
|
+
/** What a function that needs a signed-in caller gets: the caller resolved before it runs. */
|
|
21
|
+
export type FunctionContext = PublicFunctionContext & FunctionCaller;
|
|
22
|
+
/** A Web-standard handler. Its request's path is the part after `/api/functions/<name>`: `/` or `/more/parts`. */
|
|
23
|
+
export type FunctionHandler = (request: Request, context: FunctionContext) => Response | Promise<Response>;
|
|
24
|
+
export type PublicFunctionHandler = (request: Request, context: PublicFunctionContext) => Response | Promise<Response>;
|
|
25
|
+
/** Any app with a Hono-style `fetch`, such as `new Hono()`: its `c.env` is the function's context. */
|
|
26
|
+
export type FunctionApp<Context = FunctionContext> = {
|
|
27
|
+
fetch: (request: Request, env: Context) => Response | Promise<Response>;
|
|
28
|
+
};
|
|
29
|
+
/** `hono/cors` options: an origin, a list of them, or a function of the request's origin. */
|
|
30
|
+
export type CorsOptions = {
|
|
31
|
+
origin: string | string[] | ((origin: string) => string | undefined | null);
|
|
32
|
+
allowMethods?: string[];
|
|
33
|
+
allowHeaders?: string[];
|
|
34
|
+
exposeHeaders?: string[];
|
|
35
|
+
maxAge?: number;
|
|
36
|
+
credentials?: boolean;
|
|
37
|
+
};
|
|
38
|
+
type SharedOptions = {
|
|
39
|
+
/** Answers CORS preflights before authentication and adds the CORS headers to every response, problems included. */
|
|
40
|
+
cors?: CorsOptions;
|
|
41
|
+
};
|
|
42
|
+
/** A function anyone may call, such as a webhook; it can still ask for a caller with `context.caller()`. */
|
|
43
|
+
export type PublicFunctionOptions = SharedOptions & {
|
|
44
|
+
public: true;
|
|
45
|
+
};
|
|
46
|
+
export type ProtectedFunctionOptions = SharedOptions & {
|
|
47
|
+
public?: false;
|
|
48
|
+
/** The caller needs one of these roles, otherwise the call is a 403 problem. */
|
|
49
|
+
roles?: string[];
|
|
50
|
+
};
|
|
51
|
+
export type FunctionOptions = PublicFunctionOptions | ProtectedFunctionOptions;
|
|
52
|
+
/** A function as `createAdmin` serves it: its options and what answers its requests. */
|
|
53
|
+
export type ApiFunction = {
|
|
54
|
+
kind: 'function';
|
|
55
|
+
options: PublicFunctionOptions;
|
|
56
|
+
handler: PublicFunctionHandler | FunctionApp<PublicFunctionContext>;
|
|
57
|
+
} | {
|
|
58
|
+
kind: 'function';
|
|
59
|
+
options: ProtectedFunctionOptions;
|
|
60
|
+
handler: FunctionHandler | FunctionApp;
|
|
61
|
+
};
|
|
62
|
+
/** What a function file may default-export: `defineFunction(...)`, a bare handler or a Hono app (both protected). */
|
|
63
|
+
export type FunctionSource = ApiFunction | FunctionHandler | FunctionApp;
|
|
64
|
+
/**
|
|
65
|
+
* A function of your own, served at `/api/functions/<name>`. Without options it needs a signed-in caller, through the
|
|
66
|
+
* app's `authenticate`; `{ public: true }` opens it to anyone, as for a webhook.
|
|
67
|
+
*/
|
|
68
|
+
export declare function defineFunction(handler: FunctionHandler | FunctionApp): ApiFunction;
|
|
69
|
+
export declare function defineFunction(options: PublicFunctionOptions, handler: PublicFunctionHandler | FunctionApp<PublicFunctionContext>): ApiFunction;
|
|
70
|
+
export declare function defineFunction(options: ProtectedFunctionOptions, handler: FunctionHandler | FunctionApp): ApiFunction;
|
|
71
|
+
//#endregion
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
//#region src/functions/define-function.ts
|
|
2
|
+
function defineFunction(first, handler) {
|
|
3
|
+
if (handler === void 0) return {
|
|
4
|
+
kind: "function",
|
|
5
|
+
options: {},
|
|
6
|
+
handler: first
|
|
7
|
+
};
|
|
8
|
+
return {
|
|
9
|
+
kind: "function",
|
|
10
|
+
options: first,
|
|
11
|
+
handler
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
const isApp = (value) => typeof value === "object" && value !== null && typeof value.fetch === "function";
|
|
15
|
+
const isApiFunction = (value) => typeof value === "object" && value !== null && value.kind === "function";
|
|
16
|
+
/** A function's name is its URL segment: letters, digits, `-` and `_`, starting with a letter or digit. */
|
|
17
|
+
const functionNamePattern = /^[A-Za-z0-9][A-Za-z0-9_-]*$/;
|
|
18
|
+
/** The function a source stands for; anything else is an error naming it. */
|
|
19
|
+
const toApiFunction = (name, source) => {
|
|
20
|
+
if (!functionNamePattern.test(name)) throw new Error(`The function name "${name}" is not a URL segment: use letters, digits, - and _`);
|
|
21
|
+
if (isApiFunction(source)) {
|
|
22
|
+
if (typeof source.handler !== "function" && !isApp(source.handler)) throw new Error(`The function "${name}" has no handler: pass a (request, context) function or a Hono app`);
|
|
23
|
+
return source;
|
|
24
|
+
}
|
|
25
|
+
if (typeof source === "function" || isApp(source)) return defineFunction(source);
|
|
26
|
+
throw new Error(`The function "${name}" must default-export defineFunction(...), a (request, context) handler or a Hono app`);
|
|
27
|
+
};
|
|
28
|
+
/** Calls a function's handler or app with its request and context; anything but a Response is an error. */
|
|
29
|
+
const invokeFunction = async (name, fn, request, context) => {
|
|
30
|
+
const { handler } = fn;
|
|
31
|
+
const response = isApp(handler) ? await handler.fetch(request, context) : await handler(request, context);
|
|
32
|
+
if (!(response instanceof Response)) throw new Error(`The function "${name}" returned no Response`);
|
|
33
|
+
return response;
|
|
34
|
+
};
|
|
35
|
+
//#endregion
|
|
36
|
+
export { defineFunction, functionNamePattern, invokeFunction, toApiFunction };
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { FunctionContext, PublicFunctionContext } from "./define-function.js";
|
|
2
|
+
import { Env, Hono } from "hono";
|
|
3
|
+
//#region src/functions/function-app.d.ts
|
|
4
|
+
/** A Hono app's env for a protected function: `c.env` is its context, `c.env.records` the caller's records. */
|
|
5
|
+
export type FunctionEnv = {
|
|
6
|
+
Bindings: FunctionContext;
|
|
7
|
+
};
|
|
8
|
+
/** A Hono app's env for a public function. */
|
|
9
|
+
export type PublicFunctionEnv = {
|
|
10
|
+
Bindings: PublicFunctionContext;
|
|
11
|
+
};
|
|
12
|
+
/**
|
|
13
|
+
* `new Hono()` for a function, whose errors and unknown paths reach the app's own error handling: problems as
|
|
14
|
+
* problem+json, anything else a 500 the app reports. A plain Hono app answers them itself.
|
|
15
|
+
*/
|
|
16
|
+
export declare const functionApp: <E extends Env = FunctionEnv>() => Hono<E, import("hono/types").BlankSchema, "/">;
|
|
17
|
+
//#endregion
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { notFound } from "../problem.js";
|
|
2
|
+
import { Hono } from "hono";
|
|
3
|
+
//#region src/functions/function-app.ts
|
|
4
|
+
/**
|
|
5
|
+
* `new Hono()` for a function, whose errors and unknown paths reach the app's own error handling: problems as
|
|
6
|
+
* problem+json, anything else a 500 the app reports. A plain Hono app answers them itself.
|
|
7
|
+
*/
|
|
8
|
+
const functionApp = () => {
|
|
9
|
+
const app = new Hono();
|
|
10
|
+
app.onError((error) => {
|
|
11
|
+
throw error;
|
|
12
|
+
});
|
|
13
|
+
app.notFound((c) => {
|
|
14
|
+
throw notFound(`There is no route at ${c.req.path}`);
|
|
15
|
+
});
|
|
16
|
+
return app;
|
|
17
|
+
};
|
|
18
|
+
//#endregion
|
|
19
|
+
export { functionApp };
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
//#region src/functions/read-json.d.ts
|
|
2
|
+
type Issue = {
|
|
3
|
+
message: string;
|
|
4
|
+
path?: ReadonlyArray<PropertyKey | {
|
|
5
|
+
key: PropertyKey;
|
|
6
|
+
}>;
|
|
7
|
+
};
|
|
8
|
+
/** A Standard Schema (https://standardschema.dev), such as a zod 4, Valibot or ArkType schema. */
|
|
9
|
+
export type StandardSchema<Output = unknown> = {
|
|
10
|
+
readonly '~standard': {
|
|
11
|
+
readonly version: 1;
|
|
12
|
+
readonly validate: (value: unknown) => {
|
|
13
|
+
value: Output;
|
|
14
|
+
issues?: undefined;
|
|
15
|
+
} | {
|
|
16
|
+
issues: ReadonlyArray<Issue>;
|
|
17
|
+
} | Promise<{
|
|
18
|
+
value: Output;
|
|
19
|
+
issues?: undefined;
|
|
20
|
+
} | {
|
|
21
|
+
issues: ReadonlyArray<Issue>;
|
|
22
|
+
}>;
|
|
23
|
+
readonly types?: {
|
|
24
|
+
readonly input: unknown;
|
|
25
|
+
readonly output: Output;
|
|
26
|
+
};
|
|
27
|
+
};
|
|
28
|
+
};
|
|
29
|
+
/**
|
|
30
|
+
* The request's JSON body, checked against `schema` when one is given. A body that is no JSON is a 400 `malformed-json`
|
|
31
|
+
* problem, one the schema refuses a 400 `invalid-body` problem listing each issue by field.
|
|
32
|
+
*/
|
|
33
|
+
export declare function readJson(request: Request): Promise<unknown>;
|
|
34
|
+
export declare function readJson<Output>(request: Request, schema: StandardSchema<Output>): Promise<Output>;
|
|
35
|
+
//#endregion
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { badRequest } from "../problem.js";
|
|
2
|
+
//#region src/functions/read-json.ts
|
|
3
|
+
const fieldOf = (issue) => (issue.path ?? []).map((part) => String(typeof part === "object" ? part.key : part)).join(".");
|
|
4
|
+
async function readJson(request, schema) {
|
|
5
|
+
const text = await request.text();
|
|
6
|
+
let body;
|
|
7
|
+
try {
|
|
8
|
+
body = JSON.parse(text);
|
|
9
|
+
} catch (error) {
|
|
10
|
+
if (error instanceof SyntaxError) throw badRequest("malformed-json", "The request body is not valid JSON");
|
|
11
|
+
throw error;
|
|
12
|
+
}
|
|
13
|
+
if (!schema) return body;
|
|
14
|
+
const result = await schema["~standard"].validate(body);
|
|
15
|
+
if (!result.issues) return result.value;
|
|
16
|
+
const errors = result.issues.map((issue) => ({
|
|
17
|
+
field: fieldOf(issue),
|
|
18
|
+
message: issue.message
|
|
19
|
+
}));
|
|
20
|
+
const first = errors[0];
|
|
21
|
+
throw badRequest("invalid-body", `The request body is invalid: ${first.field ? `${first.field}: ` : ""}${first.message}`, { errors });
|
|
22
|
+
}
|
|
23
|
+
//#endregion
|
|
24
|
+
export { readJson };
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { Row } from "../types.js";
|
|
2
|
+
import "../deps.js";
|
|
3
|
+
import { RecordKeyInput, ToolRecord, ToolRecords } from "../assistant/records.js";
|
|
4
|
+
//#region src/functions/records.d.ts
|
|
5
|
+
/** The list parameters of the REST API (AIP-132), as values rather than query text. */
|
|
6
|
+
export type ListParams = {
|
|
7
|
+
filter?: string;
|
|
8
|
+
order_by?: string;
|
|
9
|
+
page_size?: number;
|
|
10
|
+
page_token?: string;
|
|
11
|
+
fields?: string[];
|
|
12
|
+
count?: 'exact';
|
|
13
|
+
show_deleted?: boolean;
|
|
14
|
+
};
|
|
15
|
+
/**
|
|
16
|
+
* A page of records as the REST API lists it: each item with its `etag` and, unless the resource opts out, whether the
|
|
17
|
+
* caller may update and delete it; and the token of the next page, empty on the last.
|
|
18
|
+
*/
|
|
19
|
+
export type RecordPage = {
|
|
20
|
+
items: Array<Row & {
|
|
21
|
+
etag?: string;
|
|
22
|
+
permissions?: {
|
|
23
|
+
update: boolean;
|
|
24
|
+
delete: boolean;
|
|
25
|
+
};
|
|
26
|
+
}>;
|
|
27
|
+
next_page_token: string;
|
|
28
|
+
total_size_estimate: number;
|
|
29
|
+
total_size?: number;
|
|
30
|
+
};
|
|
31
|
+
/**
|
|
32
|
+
* The app's resources as the caller: their access rules, row filters, tenant scope, validation and write hooks apply as
|
|
33
|
+
* in the REST API. A refusal or a missing record rejects with an `HttpProblem`, which becomes the function's response
|
|
34
|
+
* when it is not caught.
|
|
35
|
+
*/
|
|
36
|
+
export type FunctionRecords = ToolRecords & {
|
|
37
|
+
list: (resource: string, params?: ListParams) => Promise<RecordPage>;
|
|
38
|
+
/** With `etag`, fails with 412 when the record changed since it was read. Resolves to the record as it was. */
|
|
39
|
+
delete: (resource: string, key: RecordKeyInput, options?: {
|
|
40
|
+
etag?: string;
|
|
41
|
+
}) => Promise<ToolRecord>;
|
|
42
|
+
};
|
|
43
|
+
//#endregion
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { notFound } from "../problem.js";
|
|
2
|
+
import { decodeKeyText } from "../key.js";
|
|
3
|
+
import { runWrite } from "../write-pipeline.js";
|
|
4
|
+
import { assistantRecords } from "../assistant/records.js";
|
|
5
|
+
import { listRecords } from "../list-service.js";
|
|
6
|
+
import { encodeKey } from "@protobase/schema";
|
|
7
|
+
//#region src/functions/records.ts
|
|
8
|
+
const filterLength = 2e5;
|
|
9
|
+
/** `FunctionRecords` over the admin's resources for `session`. */
|
|
10
|
+
const callerRecords = (deps, session) => {
|
|
11
|
+
const entryOf = (resource) => {
|
|
12
|
+
const entry = deps.registry.find(resource);
|
|
13
|
+
if (!entry) throw notFound(`There is no resource "${resource}"`);
|
|
14
|
+
return entry;
|
|
15
|
+
};
|
|
16
|
+
return {
|
|
17
|
+
...assistantRecords(deps, session),
|
|
18
|
+
list: async (resource, params = {}) => (await listRecords(deps, entryOf(resource), session, params, filterLength)).body,
|
|
19
|
+
delete: async (resource, input, { etag } = {}) => {
|
|
20
|
+
const entry = entryOf(resource);
|
|
21
|
+
const key = decodeKeyText(entry, Array.isArray(input) ? encodeKey(input) : String(input));
|
|
22
|
+
return runWrite(deps, entry, session, {
|
|
23
|
+
operation: "delete",
|
|
24
|
+
key,
|
|
25
|
+
ifMatch: etag ?? "*"
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
};
|
|
29
|
+
};
|
|
30
|
+
//#endregion
|
|
31
|
+
export { callerRecords };
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
//#region src/functions/require-env.d.ts
|
|
2
|
+
/**
|
|
3
|
+
* An environment variable the function cannot work without, such as an API key: `protobase dev` reads it from `.env`,
|
|
4
|
+
* `protobase serve` from its environment. A missing one is an error, so the call is a 500 the app reports.
|
|
5
|
+
*/
|
|
6
|
+
export declare const requireEnv: (name: string) => any;
|
|
7
|
+
//#endregion
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
//#region src/functions/require-env.ts
|
|
2
|
+
/**
|
|
3
|
+
* An environment variable the function cannot work without, such as an API key: `protobase dev` reads it from `.env`,
|
|
4
|
+
* `protobase serve` from its environment. A missing one is an error, so the call is a 500 the app reports.
|
|
5
|
+
*/
|
|
6
|
+
const requireEnv = (name) => {
|
|
7
|
+
const value = globalThis.process?.env?.[name];
|
|
8
|
+
if (!value) throw new Error(`The environment variable ${name} is not set`);
|
|
9
|
+
return value;
|
|
10
|
+
};
|
|
11
|
+
//#endregion
|
|
12
|
+
export { requireEnv };
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { forbidden, notFound } from "../problem.js";
|
|
2
|
+
import { invokeFunction } from "./define-function.js";
|
|
3
|
+
import { Hono } from "hono";
|
|
4
|
+
import { cors } from "hono/cors";
|
|
5
|
+
//#region src/functions/routes.ts
|
|
6
|
+
const relativeRequest = (request, prefix) => {
|
|
7
|
+
const url = new URL(request.url);
|
|
8
|
+
url.pathname = url.pathname.slice(prefix.length) || "/";
|
|
9
|
+
return new Request(url, request);
|
|
10
|
+
};
|
|
11
|
+
const requireRoles = (name, session, roles) => {
|
|
12
|
+
if (!roles || roles.some((role) => session.user.roles.includes(role))) return;
|
|
13
|
+
throw forbidden("function-forbidden", `You may not call the function "${name}"`);
|
|
14
|
+
};
|
|
15
|
+
/**
|
|
16
|
+
* Every function at `<basePath>/<name>` and below, any method. A protected function authenticates the request first, so
|
|
17
|
+
* a call without a valid token is the authenticator's 401 problem; a public one gets the caller only when it asks.
|
|
18
|
+
*/
|
|
19
|
+
const functionRoutes = ({ basePath, functions, authenticate, db, records, onError }) => {
|
|
20
|
+
const app = new Hono().basePath(basePath);
|
|
21
|
+
app.onError(onError);
|
|
22
|
+
const callerOf = async (request) => {
|
|
23
|
+
const session = await authenticate(request);
|
|
24
|
+
return {
|
|
25
|
+
session,
|
|
26
|
+
records: records(session)
|
|
27
|
+
};
|
|
28
|
+
};
|
|
29
|
+
for (const [name, fn] of Object.entries(functions)) {
|
|
30
|
+
const prefix = `${basePath}/${name}`;
|
|
31
|
+
const { options } = fn;
|
|
32
|
+
if (options.cors) app.use(`/${name}/*`, cors(options.cors));
|
|
33
|
+
app.all(`/${name}/*`, async (c) => {
|
|
34
|
+
const request = relativeRequest(c.req.raw, prefix);
|
|
35
|
+
if (options.public) {
|
|
36
|
+
let caller;
|
|
37
|
+
return invokeFunction(name, fn, request, {
|
|
38
|
+
name,
|
|
39
|
+
db,
|
|
40
|
+
caller: () => caller ??= callerOf(c.req.raw)
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
const caller = await callerOf(c.req.raw);
|
|
44
|
+
requireRoles(name, caller.session, options.roles);
|
|
45
|
+
const context = {
|
|
46
|
+
name,
|
|
47
|
+
db,
|
|
48
|
+
...caller,
|
|
49
|
+
caller: async () => caller
|
|
50
|
+
};
|
|
51
|
+
return invokeFunction(name, fn, request, context);
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
app.all("/*", (c) => {
|
|
55
|
+
throw notFound(`There is no function at ${c.req.path}`);
|
|
56
|
+
});
|
|
57
|
+
return app;
|
|
58
|
+
};
|
|
59
|
+
//#endregion
|
|
60
|
+
export { functionRoutes };
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
//#region src/functions/verify-signature.d.ts
|
|
2
|
+
export type SignatureOptions = {
|
|
3
|
+
/** The shared secret, as the sender shows it. */
|
|
4
|
+
secret: string;
|
|
5
|
+
/** The header that carries the signature, such as `x-hub-signature-256`. */
|
|
6
|
+
header: string;
|
|
7
|
+
/** Text before the signature in that header, such as `sha256=`. */
|
|
8
|
+
prefix?: string;
|
|
9
|
+
/** How the signature is written. Default `hex`. */
|
|
10
|
+
encoding?: 'hex' | 'base64';
|
|
11
|
+
/** Default `SHA-256`. */
|
|
12
|
+
hash?: 'SHA-1' | 'SHA-256' | 'SHA-512';
|
|
13
|
+
};
|
|
14
|
+
/**
|
|
15
|
+
* The request's body as text, once its HMAC signature checks out against `secret`, as GitHub, Shopify and many others
|
|
16
|
+
* sign their webhooks. A missing or wrong signature is a 401 `invalid-signature` problem.
|
|
17
|
+
*/
|
|
18
|
+
export declare const verifySignature: (request: Request, { secret, header, prefix, encoding, hash }: SignatureOptions) => Promise<string>;
|
|
19
|
+
export type StandardWebhookOptions = {
|
|
20
|
+
/** The endpoint's secret, `whsec_` and base64. */
|
|
21
|
+
secret: string;
|
|
22
|
+
/** How old, or how far ahead, a message may be. Default 300. */
|
|
23
|
+
toleranceSeconds?: number;
|
|
24
|
+
/** The current time in milliseconds; for tests. */
|
|
25
|
+
now?: () => number;
|
|
26
|
+
};
|
|
27
|
+
/**
|
|
28
|
+
* The request's body as text, once its Standard Webhooks signature checks out (https://www.standardwebhooks.com), as
|
|
29
|
+
* Supabase Auth hooks, Svix, Resend and others sign theirs. A missing, stale or wrong signature is a 401 problem.
|
|
30
|
+
*/
|
|
31
|
+
export declare const verifyStandardWebhook: (request: Request, { secret, toleranceSeconds, now }: StandardWebhookOptions) => Promise<string>;
|
|
32
|
+
//#endregion
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { HttpProblem } from "../problem.js";
|
|
2
|
+
//#region src/functions/verify-signature.ts
|
|
3
|
+
const invalid = (detail) => new HttpProblem(401, "invalid-signature", "Unauthorized", detail);
|
|
4
|
+
const decodeHex = (text) => /^(?:[0-9a-f]{2})+$/i.test(text) ? new Uint8Array(text.match(/../g).map((pair) => parseInt(pair, 16))) : void 0;
|
|
5
|
+
const decodeBase64 = (text) => /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(text) && text.length > 0 ? Uint8Array.from(atob(text), (char) => char.charCodeAt(0)) : void 0;
|
|
6
|
+
const verifyHmac = async (key, hash, signature, content) => {
|
|
7
|
+
const cryptoKey = await crypto.subtle.importKey("raw", key, {
|
|
8
|
+
name: "HMAC",
|
|
9
|
+
hash
|
|
10
|
+
}, false, ["verify"]);
|
|
11
|
+
return crypto.subtle.verify("HMAC", cryptoKey, signature, new TextEncoder().encode(content));
|
|
12
|
+
};
|
|
13
|
+
/**
|
|
14
|
+
* The request's body as text, once its HMAC signature checks out against `secret`, as GitHub, Shopify and many others
|
|
15
|
+
* sign their webhooks. A missing or wrong signature is a 401 `invalid-signature` problem.
|
|
16
|
+
*/
|
|
17
|
+
const verifySignature = async (request, { secret, header, prefix = "", encoding = "hex", hash = "SHA-256" }) => {
|
|
18
|
+
const body = await request.text();
|
|
19
|
+
const value = request.headers.get(header);
|
|
20
|
+
if (!value?.startsWith(prefix)) throw invalid(`The request has no ${header} signature`);
|
|
21
|
+
const text = value.slice(prefix.length).trim();
|
|
22
|
+
const signature = encoding === "hex" ? decodeHex(text) : decodeBase64(text);
|
|
23
|
+
if (!signature || !await verifyHmac(new TextEncoder().encode(secret), hash, signature, body)) throw invalid(`The ${header} signature does not match the body`);
|
|
24
|
+
return body;
|
|
25
|
+
};
|
|
26
|
+
/**
|
|
27
|
+
* The request's body as text, once its Standard Webhooks signature checks out (https://www.standardwebhooks.com), as
|
|
28
|
+
* Supabase Auth hooks, Svix, Resend and others sign theirs. A missing, stale or wrong signature is a 401 problem.
|
|
29
|
+
*/
|
|
30
|
+
const verifyStandardWebhook = async (request, { secret, toleranceSeconds = 300, now = Date.now }) => {
|
|
31
|
+
const body = await request.text();
|
|
32
|
+
const id = request.headers.get("webhook-id");
|
|
33
|
+
const timestamp = request.headers.get("webhook-timestamp");
|
|
34
|
+
const signatures = request.headers.get("webhook-signature");
|
|
35
|
+
if (!id || !timestamp || !signatures) throw invalid("The request has no webhook-id, webhook-timestamp and webhook-signature headers");
|
|
36
|
+
const seconds = Number(timestamp);
|
|
37
|
+
if (!Number.isInteger(seconds) || Math.abs(now() / 1e3 - seconds) > toleranceSeconds) throw invalid("The webhook-timestamp is too old or too far ahead");
|
|
38
|
+
const key = decodeBase64(secret.replace(/^whsec_/, ""));
|
|
39
|
+
if (!key) throw new Error("The Standard Webhooks secret is not base64 (whsec_...)");
|
|
40
|
+
const content = `${id}.${timestamp}.${body}`;
|
|
41
|
+
for (const entry of signatures.split(" ")) {
|
|
42
|
+
const [version, text = ""] = entry.split(",");
|
|
43
|
+
const signature = decodeBase64(text);
|
|
44
|
+
if (version === "v1" && signature && await verifyHmac(key, "SHA-256", signature, content)) return body;
|
|
45
|
+
}
|
|
46
|
+
throw invalid("No webhook-signature matches the body");
|
|
47
|
+
};
|
|
48
|
+
//#endregion
|
|
49
|
+
export { verifySignature, verifyStandardWebhook };
|