@protobase/server 0.7.0 → 0.8.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.
Files changed (114) hide show
  1. package/dist/assistant/conversations.d.ts +1 -1
  2. package/dist/assistant/tools.d.ts +1 -1
  3. package/dist/assistant/turn.d.ts +2 -2
  4. package/dist/audit/origin.js +12 -0
  5. package/dist/auth/jwt.d.ts +2 -0
  6. package/dist/auth/jwt.js +5 -3
  7. package/dist/batch/reorder.js +1 -1
  8. package/dist/batch/run-batch.js +2 -1
  9. package/dist/better-auth/authenticator.d.ts +7 -3
  10. package/dist/better-auth/authenticator.js +24 -3
  11. package/dist/better-auth/create-auth.d.ts +619 -81
  12. package/dist/better-auth/create-auth.js +64 -27
  13. package/dist/better-auth/organizations/accept-invitation.js +80 -0
  14. package/dist/better-auth/organizations/active-organization.js +12 -0
  15. package/dist/better-auth/organizations/admin-routes.js +83 -0
  16. package/dist/better-auth/organizations/app-roles.js +43 -0
  17. package/dist/better-auth/organizations/host.d.ts +53 -0
  18. package/dist/better-auth/organizations/host.js +251 -0
  19. package/dist/better-auth/organizations/invitation-endpoints.js +133 -0
  20. package/dist/better-auth/organizations/invitation-mail.js +17 -0
  21. package/dist/better-auth/organizations/invitation-token.js +27 -0
  22. package/dist/better-auth/organizations/member-endpoints.js +182 -0
  23. package/dist/better-auth/organizations/options.d.ts +48 -0
  24. package/dist/better-auth/organizations/options.js +23 -0
  25. package/dist/better-auth/organizations/organization-plugin.js +128 -0
  26. package/dist/better-auth/organizations/protobase-plugin.js +21 -0
  27. package/dist/better-auth/organizations/role-definitions.d.ts +15 -0
  28. package/dist/better-auth/organizations/role-definitions.js +56 -0
  29. package/dist/better-auth/organizations/session-organization.js +68 -0
  30. package/dist/better-auth/organizations/store.d.ts +19 -0
  31. package/dist/better-auth/organizations/store.js +73 -0
  32. package/dist/better-auth/staff-checks.js +5 -1
  33. package/dist/better-auth/staff-sign-in-endpoints.js +1 -1
  34. package/dist/better-auth/status-route.js +14 -3
  35. package/dist/better-auth/users.d.ts +2 -1
  36. package/dist/better-auth/users.js +4 -1
  37. package/dist/changes/actor.js +12 -0
  38. package/dist/changes/capture-plan.js +62 -0
  39. package/dist/changes/capture-schema.d.ts +18 -0
  40. package/dist/changes/capture-schema.js +65 -0
  41. package/dist/changes/capture-sql.js +108 -0
  42. package/dist/changes/coalesce.d.ts +38 -0
  43. package/dist/changes/coalesce.js +69 -0
  44. package/dist/changes/events-options.d.ts +10 -0
  45. package/dist/changes/events-options.js +10 -0
  46. package/dist/changes/match-events.d.ts +9 -0
  47. package/dist/changes/match-events.js +55 -0
  48. package/dist/changes/resource-change.d.ts +27 -0
  49. package/dist/changes/resource-change.js +43 -0
  50. package/dist/create-admin.d.ts +11 -2
  51. package/dist/create-admin.js +33 -2
  52. package/dist/deps.d.ts +1 -0
  53. package/dist/files/attach.js +80 -0
  54. package/dist/files/cleanup-runner.d.ts +8 -0
  55. package/dist/files/cleanup-runner.js +58 -0
  56. package/dist/files/cleanup-schedule.d.ts +28 -0
  57. package/dist/files/cleanup-schedule.js +48 -0
  58. package/dist/files/detect.js +51 -0
  59. package/dist/files/download-url.js +43 -0
  60. package/dist/files/download.js +90 -0
  61. package/dist/files/duration.js +35 -0
  62. package/dist/files/file-cleanup.d.ts +18 -0
  63. package/dist/files/file-cleanup.js +17 -0
  64. package/dist/files/local-files.d.ts +17 -0
  65. package/dist/files/local-files.js +101 -0
  66. package/dist/files/media-types.js +99 -0
  67. package/dist/files/options.d.ts +15 -0
  68. package/dist/files/present.d.ts +15 -0
  69. package/dist/files/present.js +59 -0
  70. package/dist/files/processors.d.ts +15 -0
  71. package/dist/files/processors.js +39 -0
  72. package/dist/files/providers.js +70 -0
  73. package/dist/files/routes.js +26 -0
  74. package/dist/files/runtime.d.ts +3 -0
  75. package/dist/files/runtime.js +37 -0
  76. package/dist/files/signer.js +32 -0
  77. package/dist/files/store.d.ts +37 -0
  78. package/dist/files/ticket.js +87 -0
  79. package/dist/files/upload-body.js +85 -0
  80. package/dist/files/upload.js +124 -0
  81. package/dist/files/uri.js +47 -0
  82. package/dist/functions/define-function.d.ts +71 -0
  83. package/dist/functions/define-function.js +36 -0
  84. package/dist/functions/function-app.d.ts +17 -0
  85. package/dist/functions/function-app.js +19 -0
  86. package/dist/functions/read-json.d.ts +35 -0
  87. package/dist/functions/read-json.js +24 -0
  88. package/dist/functions/records.d.ts +43 -0
  89. package/dist/functions/records.js +31 -0
  90. package/dist/functions/require-env.d.ts +7 -0
  91. package/dist/functions/require-env.js +12 -0
  92. package/dist/functions/routes.js +60 -0
  93. package/dist/functions/verify-signature.d.ts +32 -0
  94. package/dist/functions/verify-signature.js +49 -0
  95. package/dist/index.d.ts +26 -3
  96. package/dist/index.js +17 -3
  97. package/dist/list-service.js +4 -2
  98. package/dist/openapi/resource-paths.js +78 -1
  99. package/dist/openapi/schemas.js +28 -3
  100. package/dist/project-config.d.ts +10 -1
  101. package/dist/project-config.js +41 -3
  102. package/dist/read-service.js +5 -1
  103. package/dist/request-access.d.ts +5 -0
  104. package/dist/request-access.js +9 -4
  105. package/dist/resource-source.d.ts +4 -1
  106. package/dist/routes/batch.js +1 -1
  107. package/dist/routes/collection.js +3 -1
  108. package/dist/routes/record.js +2 -5
  109. package/dist/transactions.js +10 -3
  110. package/dist/types.d.ts +25 -5
  111. package/dist/write-pipeline.js +4 -2
  112. package/dist/write-shared.js +6 -3
  113. package/dist/write-update.js +2 -1
  114. package/package.json +6 -4
@@ -0,0 +1,39 @@
1
+ import { imageSize } from "image-size";
2
+ //#region src/files/processors.ts
3
+ const shownSize = (head) => {
4
+ let size;
5
+ try {
6
+ size = imageSize(head);
7
+ } catch (error) {
8
+ if (error instanceof TypeError || error instanceof RangeError) return void 0;
9
+ throw error;
10
+ }
11
+ return (size.orientation ?? 1) >= 5 ? {
12
+ width: size.height,
13
+ height: size.width
14
+ } : {
15
+ width: size.width,
16
+ height: size.height
17
+ };
18
+ };
19
+ /** A processor of the project's own: `processor({ accepts: ['image/*'], run: async (file) => … })`. */
20
+ const processor = (definition) => definition;
21
+ /** An image's width or height in pixels, as shown (EXIF rotation applied), for an integer field. Null for other files. */
22
+ const imageSize$1 = (dimension) => processor({
23
+ accepts: ["image/*"],
24
+ run: (file) => shownSize(file.head)?.[dimension] ?? null
25
+ });
26
+ /** An image's width divided by its height, as decimal text with `decimals` places (default 4), for a decimal field. */
27
+ const aspectRatio = ({ decimals = 4 } = {}) => processor({
28
+ accepts: ["image/*"],
29
+ run: (file) => {
30
+ const size = shownSize(file.head);
31
+ return size && size.height > 0 ? (size.width / size.height).toFixed(decimals) : null;
32
+ }
33
+ });
34
+ /** The file's size in bytes, for an integer field to filter or sort on. */
35
+ const fileSize = () => processor({ run: (file) => file.size });
36
+ /** The file's detected type, for a text field to filter or sort on. */
37
+ const fileType = () => processor({ run: (file) => file.type });
38
+ //#endregion
39
+ export { aspectRatio, fileSize, fileType, imageSize$1 as imageSize, processor };
@@ -0,0 +1,70 @@
1
+ import { parseDuration } from "./duration.js";
2
+ import { localFiles } from "./local-files.js";
3
+ import { checkedProvider } from "@protobase/schema";
4
+ //#region src/files/providers.ts
5
+ const prefix = "PROTOBASE_FILES_";
6
+ /** `PROTOBASE_FILES_<NAME>` configures a provider, except for these settings. */
7
+ const settingVariables = [
8
+ "RETENTION",
9
+ "SECRET",
10
+ "CLEANUP_MINUTES"
11
+ ];
12
+ const reservedNames = new Set(settingVariables.map((name) => name.toLowerCase().replaceAll("_", "-")));
13
+ const checkedName = (name) => {
14
+ checkedProvider(name);
15
+ if (reservedNames.has(name)) throw new Error(`"${name}" cannot name a file provider: PROTOBASE_FILES_${name.toUpperCase().replaceAll("-", "_")} is a setting`);
16
+ return name;
17
+ };
18
+ const variableOf = (name) => `${prefix}${name.toUpperCase().replaceAll("-", "_")}`;
19
+ /** A provider from `file:///data/files/private?public=1&public_url=…&retention=14d` (a relative `file:data/files` works too). */
20
+ const providerFromUrl = (text, variable) => {
21
+ let url;
22
+ try {
23
+ url = new URL(text);
24
+ } catch (error) {
25
+ if (error instanceof TypeError) throw new Error(`${variable} is not a URL; write it like file:///data/files/private`);
26
+ throw error;
27
+ }
28
+ if (url.protocol !== "file:") throw new Error(`${variable} uses ${url.protocol}; not implemented: only file: providers (local disk) are supported so far`);
29
+ const flag = url.searchParams.get("public");
30
+ const publicUrl = url.searchParams.get("public_url") ?? void 0;
31
+ const retention = url.searchParams.get("retention") ?? void 0;
32
+ return localFiles({
33
+ dir: decodeURIComponent(url.pathname),
34
+ public: flag === "1" || flag === "true",
35
+ ...publicUrl && { publicUrl },
36
+ ...retention && { retention }
37
+ });
38
+ };
39
+ /**
40
+ * The providers in effect: the config's, each replaced by `PROTOBASE_FILES_<NAME>` when that is set, plus any the
41
+ * environment adds. `private` and `public` default to `data/files/private` and `data/files/public` when `used` needs them.
42
+ */
43
+ const resolveProviders = (options, env, used) => {
44
+ const providers = {};
45
+ for (const [name, provider] of Object.entries(options.providers ?? {})) providers[checkedName(name)] = provider;
46
+ for (const [variable, value] of Object.entries(env)) {
47
+ if (!variable.startsWith(prefix) || !value) continue;
48
+ const suffix = variable.slice(16);
49
+ if (settingVariables.includes(suffix)) continue;
50
+ const name = checkedName(suffix.toLowerCase().replaceAll("_", "-"));
51
+ providers[name] = providerFromUrl(value, variable);
52
+ }
53
+ for (const name of used) {
54
+ if (providers[name]) continue;
55
+ if (name !== "private" && name !== "public") throw new Error(`No file provider "${name}": add it to files.providers in protobase.config.ts or set ${variableOf(name)}`);
56
+ providers[name] = localFiles({
57
+ dir: `data/files/${name}`,
58
+ public: name === "public"
59
+ });
60
+ }
61
+ return providers;
62
+ };
63
+ /** How long a provider keeps bytes after their last reference. */
64
+ const retentionOf = (options, env) => {
65
+ const fallback = env[`${prefix}RETENTION`] ?? options.retention ?? "1 day";
66
+ const global = parseDuration(fallback, env[`${prefix}RETENTION`] ? `${prefix}RETENTION` : "files.retention");
67
+ return (provider) => provider.retention ?? (provider.public ? 0 : global);
68
+ };
69
+ //#endregion
70
+ export { providerFromUrl, resolveProviders, retentionOf, settingVariables };
@@ -0,0 +1,26 @@
1
+ import { forbidden } from "../problem.js";
2
+ import { serveFile } from "./download.js";
3
+ import { cleanupFiles } from "./runtime.js";
4
+ import { Hono } from "hono";
5
+ //#region src/files/routes.ts
6
+ /**
7
+ * Beside the API, on the app's own host: `GET <systemPath>/files/<provider>/<path>` serves files, and
8
+ * `POST <systemPath>/files:cleanup` runs the scheduled deletes that are due, for an admin (a platform calls it on a schedule).
9
+ */
10
+ const fileRoutes = (deps, authenticate) => {
11
+ const app = new Hono();
12
+ const files = deps.files;
13
+ if (!files) return app;
14
+ app.get(`${files.path}/:provider/*`, (c) => {
15
+ const provider = c.req.param("provider");
16
+ const path = decodeURIComponent(new URL(c.req.url).pathname.slice(`${files.path}/${provider}/`.length));
17
+ return serveFile(deps, c.req.raw, provider, path);
18
+ });
19
+ app.post(`${files.path}:cleanup`, async (c) => {
20
+ if (!(await authenticate(c.req.raw)).user.roles.includes("admin")) throw forbidden("access-denied", "Only an admin may run the file cleanup");
21
+ return c.json(await cleanupFiles(files, deps.db));
22
+ });
23
+ return app;
24
+ };
25
+ //#endregion
26
+ export { fileRoutes };
@@ -0,0 +1,3 @@
1
+ import "./cleanup-runner.js";
2
+ import { ResourceModel } from "@protobase/schema";
3
+ import { Db } from "@protobase/query";
@@ -0,0 +1,37 @@
1
+ import { fileColumns, runCleanup } from "./cleanup-runner.js";
2
+ import { storageCleanupSchedule } from "./cleanup-schedule.js";
3
+ import { resolveProviders, retentionOf } from "./providers.js";
4
+ import { createSigner } from "./signer.js";
5
+ //#region src/files/runtime.ts
6
+ /** How long an upload ticket stays usable, so a form left open still saves. */
7
+ const ticketSeconds = 86400;
8
+ /**
9
+ * The runtime, when a resource has a file field or the config names providers. It fails at startup when a field's
10
+ * provider is not configured or nothing signs uploads.
11
+ */
12
+ const createFilesRuntime = ({ options = {}, env, models, systemPath }) => {
13
+ const used = [...new Set(models.flatMap((model) => Object.values(model.fields).flatMap((field) => field.file ? [field.file.provider] : [])))];
14
+ if (used.length === 0 && !options.providers) return void 0;
15
+ const secret = options.secret ?? env.PROTOBASE_FILES_SECRET ?? env.BETTER_AUTH_SECRET;
16
+ if (!secret) throw new Error("File fields need a secret to sign uploads: set BETTER_AUTH_SECRET, PROTOBASE_FILES_SECRET or files.secret");
17
+ const providers = resolveProviders(options, env, used);
18
+ const retention = retentionOf(options, env);
19
+ return {
20
+ providers,
21
+ signer: createSigner(secret),
22
+ schedule: storageCleanupSchedule(providers),
23
+ retention: (provider) => retention(providers[provider]),
24
+ path: `${systemPath}/files`,
25
+ columns: fileColumns(models)
26
+ };
27
+ };
28
+ /** Runs the scheduled deletes that are due (see `runCleanup`). */
29
+ const cleanupFiles = (runtime, db, now) => runCleanup({
30
+ db,
31
+ columns: runtime.columns,
32
+ providers: runtime.providers,
33
+ schedule: runtime.schedule,
34
+ ...now && { now }
35
+ });
36
+ //#endregion
37
+ export { cleanupFiles, createFilesRuntime, ticketSeconds };
@@ -0,0 +1,32 @@
1
+ //#region src/files/signer.ts
2
+ const encoder = new TextEncoder();
3
+ const toBase64Url = (bytes) => btoa(String.fromCharCode(...new Uint8Array(bytes))).replaceAll("+", "-").replaceAll("/", "_").replace(/=+$/, "");
4
+ const fromBase64Url = (text) => {
5
+ if (!/^[A-Za-z0-9_-]{43}$/.test(text)) return void 0;
6
+ return Uint8Array.from(atob(text.replaceAll("-", "+").replaceAll("_", "/")), (char) => char.charCodeAt(0));
7
+ };
8
+ const createSigner = (secret) => {
9
+ const key = (async () => {
10
+ const material = await crypto.subtle.importKey("raw", encoder.encode(secret), "HKDF", false, ["deriveKey"]);
11
+ return crypto.subtle.deriveKey({
12
+ name: "HKDF",
13
+ hash: "SHA-256",
14
+ salt: /* @__PURE__ */ new Uint8Array(0),
15
+ info: encoder.encode("protobase files")
16
+ }, material, {
17
+ name: "HMAC",
18
+ hash: "SHA-256",
19
+ length: 256
20
+ }, false, ["sign", "verify"]);
21
+ })();
22
+ const content = (purpose, parts) => encoder.encode(JSON.stringify([purpose, ...parts]));
23
+ return {
24
+ sign: async (purpose, parts) => toBase64Url(await crypto.subtle.sign("HMAC", await key, content(purpose, parts))),
25
+ verify: async (purpose, parts, signature) => {
26
+ const bytes = fromBase64Url(signature);
27
+ return bytes !== void 0 && crypto.subtle.verify("HMAC", await key, bytes, content(purpose, parts));
28
+ }
29
+ };
30
+ };
31
+ //#endregion
32
+ export { createSigner };
@@ -0,0 +1,37 @@
1
+ //#region src/files/store.d.ts
2
+ /** One stored object. */
3
+ export type StoredObject = {
4
+ path: string;
5
+ size: number;
6
+ modified: Date;
7
+ };
8
+ /**
9
+ * Where a provider keeps bytes: local disk now, an S3-compatible bucket later. Paths are relative and use `/`. Every
10
+ * operation on a path that does not exist is harmless: `head` and `read` give `undefined`, `delete` does nothing.
11
+ */
12
+ export type FileStore = {
13
+ /** Writes the whole stream, or nothing when it fails: a reader never sees part of an object. */
14
+ put(path: string, body: ReadableStream<Uint8Array>): Promise<{
15
+ size: number;
16
+ }>;
17
+ head(path: string): Promise<StoredObject | undefined>;
18
+ /** The bytes, or the inclusive byte range `start`–`end`. */
19
+ read(path: string, range?: {
20
+ start: number;
21
+ end: number;
22
+ }): Promise<ReadableStream<Uint8Array> | undefined>;
23
+ delete(path: string): Promise<void>;
24
+ /** Every object under `prefix` (a folder such as `.cleanup/`), sorted by path. */
25
+ list(prefix: string): Promise<StoredObject[]>;
26
+ };
27
+ /** A configured provider: its store, whether its files are public, and how long bytes outlive their last reference. */
28
+ export type FileProvider = {
29
+ store: FileStore;
30
+ /** Served to anyone at a permanent URL. */
31
+ public: boolean;
32
+ /** Where public files are linked from, such as a CDN; default the app's own `/api/files/<provider>`. */
33
+ publicUrl?: string;
34
+ /** Milliseconds between a file leaving its row and its bytes being deleted; default `files.retention` (0 when public). */
35
+ retention?: number;
36
+ };
37
+ //#endregion
@@ -0,0 +1,87 @@
1
+ import { formatUri, parseUri } from "./uri.js";
2
+ //#region src/files/ticket.ts
3
+ const sortedEntries = (derived) => Object.entries(derived).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0);
4
+ const parts = (uri, derived, scope, expires) => [
5
+ uri,
6
+ sortedEntries(derived),
7
+ scope.tenant,
8
+ scope.resource,
9
+ scope.field,
10
+ scope.user,
11
+ expires
12
+ ];
13
+ /**
14
+ * What `:upload` answers with, and what a write sends back: the file's URI with its derived values, an expiry (Unix
15
+ * seconds) and a signature over them and the scope, so it works only for that uploader, tenant, resource and field.
16
+ */
17
+ const issueTicket = async (signer, ref, derived, scope, expires) => {
18
+ const uri = formatUri(ref);
19
+ return `${uri}&${new URLSearchParams([
20
+ ...sortedEntries(derived).map(([column, value]) => [`d.${column}`, JSON.stringify(value)]),
21
+ ["exp", String(expires)],
22
+ ["sig", await signer.sign("ticket", parts(uri, derived, scope, expires))]
23
+ ])}`;
24
+ };
25
+ const parseJson = (text) => {
26
+ try {
27
+ return { value: JSON.parse(text) };
28
+ } catch (error) {
29
+ if (error instanceof SyntaxError) return void 0;
30
+ throw error;
31
+ }
32
+ };
33
+ /** The clean URI and derived values of a ticket that verifies for `scope` and has not expired at `now` (Unix seconds). */
34
+ const readTicket = async (signer, value, scope, now) => {
35
+ const query = new URLSearchParams(value.slice(value.indexOf("?") + 1));
36
+ const signature = query.get("sig");
37
+ if (!value.includes("?") || signature === null) return {
38
+ ok: false,
39
+ reason: "not-a-ticket"
40
+ };
41
+ const derived = {};
42
+ for (const [name, text] of query) {
43
+ if (!name.startsWith("d.")) continue;
44
+ const parsed = parseJson(text);
45
+ if (!parsed) return {
46
+ ok: false,
47
+ reason: "invalid"
48
+ };
49
+ derived[name.slice(2)] = parsed.value;
50
+ }
51
+ const known = /* @__PURE__ */ new Set([
52
+ "name",
53
+ "size",
54
+ "exp",
55
+ "sig"
56
+ ]);
57
+ if ([...query.keys()].some((name) => !known.has(name) && !name.startsWith("d."))) return {
58
+ ok: false,
59
+ reason: "invalid"
60
+ };
61
+ const parsed = parseUri(value.slice(0, value.indexOf("?")) + `?${new URLSearchParams({
62
+ name: query.get("name") ?? "",
63
+ size: query.get("size") ?? ""
64
+ })}`);
65
+ const expires = Number(query.get("exp"));
66
+ if (!parsed || parsed.size === void 0 || !Number.isSafeInteger(expires)) return {
67
+ ok: false,
68
+ reason: "invalid"
69
+ };
70
+ const uri = formatUri(parsed);
71
+ if (!await signer.verify("ticket", parts(uri, derived, scope, expires), signature)) return {
72
+ ok: false,
73
+ reason: "invalid"
74
+ };
75
+ if (expires < now) return {
76
+ ok: false,
77
+ reason: "expired"
78
+ };
79
+ return {
80
+ ok: true,
81
+ uri,
82
+ ref: parsed,
83
+ derived
84
+ };
85
+ };
86
+ //#endregion
87
+ export { issueTicket, readTicket };
@@ -0,0 +1,85 @@
1
+ import { HttpProblem, badRequest } from "../problem.js";
2
+ //#region src/files/upload-body.ts
3
+ const tooLarge = (maxSize) => new HttpProblem(413, "file-too-large", "Payload Too Large", `The file is larger than this field allows (${maxSize} bytes)`);
4
+ const multipartSlack = 65536;
5
+ /**
6
+ * The file of an upload request: the raw body with `?name=` and its Content-Type, or the one file part of a
7
+ * `multipart/form-data` body with that part's file name and type. A multipart body is read whole, after its size is
8
+ * checked; a raw body streams.
9
+ */
10
+ const incomingFile = async (request, maxSize) => {
11
+ const header = request.headers.get("content-length");
12
+ const length = header === null ? void 0 : Number(header);
13
+ const contentType = request.headers.get("content-type") ?? void 0;
14
+ const name = new URL(request.url).searchParams.get("name") ?? void 0;
15
+ if (!contentType?.toLowerCase().startsWith("multipart/form-data")) {
16
+ if (length !== void 0 && length > maxSize) throw tooLarge(maxSize);
17
+ return {
18
+ body: request.body ?? new Blob([]).stream(),
19
+ ...name !== void 0 && { name },
20
+ ...contentType && { declared: contentType },
21
+ ...length !== void 0 && { length }
22
+ };
23
+ }
24
+ if (length !== void 0 && length > maxSize + multipartSlack) throw tooLarge(maxSize);
25
+ const files = [...(await request.formData()).values()].filter((value) => typeof value !== "string");
26
+ if (files.length !== 1) throw badRequest("invalid-upload", `A multipart upload carries exactly one file part, not ${files.length}`);
27
+ const file = files[0];
28
+ if (file.size > maxSize) throw tooLarge(maxSize);
29
+ return {
30
+ body: file.stream(),
31
+ name: file.name || name,
32
+ ...file.type && { declared: file.type },
33
+ length: file.size
34
+ };
35
+ };
36
+ const concat = (chunks, length) => {
37
+ const joined = new Uint8Array(length);
38
+ let offset = 0;
39
+ for (const chunk of chunks) {
40
+ joined.set(chunk, offset);
41
+ offset += chunk.length;
42
+ }
43
+ return joined;
44
+ };
45
+ /** The first `want` bytes (or all, when there are fewer), and whether that was the whole stream. */
46
+ const readHead = async (reader, want) => {
47
+ const chunks = [];
48
+ let length = 0;
49
+ while (length < want) {
50
+ const next = await reader.read();
51
+ if (next.done) return {
52
+ head: concat(chunks, length),
53
+ complete: true
54
+ };
55
+ chunks.push(next.value);
56
+ length += next.value.length;
57
+ }
58
+ return {
59
+ head: concat(chunks, length),
60
+ complete: false
61
+ };
62
+ };
63
+ var FileTooLarge = class extends Error {};
64
+ /** The head followed by the rest of the reader, failing with `FileTooLarge` past `maxSize` bytes. */
65
+ const limitedStream = (head, reader, maxSize) => {
66
+ let size = head.length;
67
+ return new ReadableStream({
68
+ start: (controller) => {
69
+ if (head.length > 0) controller.enqueue(head);
70
+ },
71
+ pull: async (controller) => {
72
+ const next = await reader.read();
73
+ if (next.done) return controller.close();
74
+ size += next.value.length;
75
+ if (size > maxSize) {
76
+ await reader.cancel();
77
+ return controller.error(new FileTooLarge());
78
+ }
79
+ controller.enqueue(next.value);
80
+ },
81
+ cancel: (reason) => reader.cancel(reason)
82
+ });
83
+ };
84
+ //#endregion
85
+ export { FileTooLarge, incomingFile, limitedStream, readHead, tooLarge };
@@ -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