@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,28 @@
1
+ import { FileProvider } from "./store.js";
2
+ //#region src/files/cleanup-schedule.d.ts
3
+ /** A file to delete once `due`, unless a row references it again by then. */
4
+ export type ScheduledDelete = {
5
+ provider: string;
6
+ path: string;
7
+ due: Date;
8
+ };
9
+ export type CleanupEntry = ScheduledDelete & {
10
+ id: string;
11
+ };
12
+ /**
13
+ * Where scheduled deletes wait. The default keeps them in each provider's own storage (`storageCleanupSchedule`); a
14
+ * table in a schema of its own, written by a migration at update time, can take its place behind the same three calls.
15
+ */
16
+ export type CleanupSchedule = {
17
+ add(deletes: ScheduledDelete[]): Promise<void>;
18
+ /** Every entry due at `now`. */
19
+ due(now: Date): Promise<CleanupEntry[]>;
20
+ /** Drops an entry once it is handled. */
21
+ done(entry: CleanupEntry): Promise<void>;
22
+ };
23
+ /**
24
+ * Scheduled deletes as a to-do folder in each provider's storage: one JSON object per delete in `.cleanup/`, named after
25
+ * its due time (`.cleanup/2026-10-12T03-00-00Z-<uuid>.json`) and saying which provider and path to delete.
26
+ */
27
+ export declare const storageCleanupSchedule: (providers: Record<string, FileProvider>) => CleanupSchedule;
28
+ //#endregion
@@ -0,0 +1,48 @@
1
+ //#region src/files/cleanup-schedule.ts
2
+ const cleanupFolder = ".cleanup/";
3
+ const stamp = (date) => date.toISOString().slice(0, 19).replaceAll(":", "-") + "Z";
4
+ const entryPattern = /^\.cleanup\/(\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}Z)-[0-9a-f-]{36}\.json$/;
5
+ const isScheduledDelete = (value) => typeof value === "object" && value !== null && typeof value.provider === "string" && typeof value.path === "string" && typeof value.due === "string";
6
+ /**
7
+ * Scheduled deletes as a to-do folder in each provider's storage: one JSON object per delete in `.cleanup/`, named after
8
+ * its due time (`.cleanup/2026-10-12T03-00-00Z-<uuid>.json`) and saying which provider and path to delete.
9
+ */
10
+ const storageCleanupSchedule = (providers) => {
11
+ const storeOf = (provider) => {
12
+ const found = providers[provider];
13
+ if (!found) throw new Error(`No file provider "${provider}" is configured`);
14
+ return found.store;
15
+ };
16
+ return {
17
+ add: async (deletes) => {
18
+ for (const { provider, path, due } of deletes) {
19
+ const body = new Blob([JSON.stringify({
20
+ provider,
21
+ path,
22
+ due: due.toISOString()
23
+ })]).stream();
24
+ await storeOf(provider).put(`${cleanupFolder}${stamp(due)}-${crypto.randomUUID()}.json`, body);
25
+ }
26
+ },
27
+ due: async (now) => {
28
+ const entries = [];
29
+ for (const [provider, { store }] of Object.entries(providers)) for (const object of await store.list(cleanupFolder)) {
30
+ const match = entryPattern.exec(object.path);
31
+ if (!match) throw new Error(`"${object.path}" in file provider "${provider}" is not a scheduled delete; remove it`);
32
+ if (match[1] > stamp(now)) continue;
33
+ const content = JSON.parse(await new Response(await store.read(object.path)).text());
34
+ if (!isScheduledDelete(content) || content.provider !== provider) throw new Error(`"${object.path}" in file provider "${provider}" is not a scheduled delete of it; remove it`);
35
+ entries.push({
36
+ id: object.path,
37
+ provider,
38
+ path: content.path,
39
+ due: new Date(content.due)
40
+ });
41
+ }
42
+ return entries;
43
+ },
44
+ done: (entry) => storeOf(entry.provider).delete(entry.id)
45
+ };
46
+ };
47
+ //#endregion
48
+ export { cleanupFolder, storageCleanupSchedule };
@@ -0,0 +1,51 @@
1
+ import { canonicalExtension, isKnownType, octetStream, textTypes, typeOfExtension } from "./media-types.js";
2
+ import { fileTypeFromBuffer } from "file-type";
3
+ //#region src/files/detect.ts
4
+ const isText = (head, complete) => {
5
+ if (head.includes(0)) return false;
6
+ try {
7
+ new TextDecoder("utf-8", { fatal: true }).decode(head, { stream: !complete });
8
+ return true;
9
+ } catch (error) {
10
+ if (error instanceof TypeError) return false;
11
+ throw error;
12
+ }
13
+ };
14
+ const declaredType = (declared) => {
15
+ const type = declared?.split(";", 1)[0].trim().toLowerCase();
16
+ return type && type !== "application/octet-stream" ? type : void 0;
17
+ };
18
+ const decided = (type, hints) => {
19
+ const from = hints.find((hint) => hint !== void 0 && hint !== type);
20
+ return {
21
+ type,
22
+ extension: canonicalExtension(type),
23
+ ...from && { corrected: {
24
+ from,
25
+ to: type
26
+ } }
27
+ };
28
+ };
29
+ /**
30
+ * The type of an upload, decided once from three signals: the content's magic bytes (C), the name's extension (E) and the
31
+ * client's declared type (H). A recognized C always wins. Unrecognized text takes E when it is a text type, H when there
32
+ * is no extension, otherwise text/plain. Unrecognized binary takes E only when the field lists it exactly and H does not
33
+ * disagree; anything else is application/octet-stream. H never decides on its own.
34
+ */
35
+ const decideType = async ({ head, complete, name, declared, accept }) => {
36
+ const fromName = name === void 0 ? void 0 : typeOfExtension(name);
37
+ const hasExtension = name !== void 0 && /\.[^.]+$/.test(name);
38
+ const header = declaredType(declared);
39
+ const hints = [fromName, header];
40
+ const detected = await fileTypeFromBuffer(head);
41
+ if (detected) return decided(isKnownType(detected.mime) ? detected.mime : octetStream, hints);
42
+ if (isText(head, complete)) {
43
+ if (fromName && textTypes.has(fromName)) return decided(fromName, hints);
44
+ if (!hasExtension && header && textTypes.has(header)) return decided(header, hints);
45
+ return decided("text/plain", hints);
46
+ }
47
+ if (fromName !== void 0 && fromName !== "application/octet-stream" && accept.includes(fromName) && (header === void 0 || header === fromName)) return decided(fromName, hints);
48
+ return decided(octetStream, hints);
49
+ };
50
+ //#endregion
51
+ export { decideType };
@@ -0,0 +1,43 @@
1
+ //#region src/files/download-url.ts
2
+ /** Download URLs stay valid one to two hours and repeat within the hour, so browsers can cache them. */
3
+ const downloadExpiry = (now = Date.now()) => (Math.ceil(now / 36e5) + 1) * 3600;
4
+ const parts = (grant) => [
5
+ grant.provider,
6
+ grant.path,
7
+ grant.resource,
8
+ grant.key,
9
+ grant.field,
10
+ grant.expires
11
+ ];
12
+ /** `<base>/<provider>/<path>?r=<resource>&k=<key>&f=<field>&exp=<seconds>&sig=<signature>` */
13
+ const signedDownloadUrl = async (signer, base, grant) => {
14
+ const query = new URLSearchParams({
15
+ r: grant.resource,
16
+ k: grant.key,
17
+ f: grant.field,
18
+ exp: String(grant.expires),
19
+ sig: await signer.sign("download", parts(grant))
20
+ });
21
+ return `${base}/${grant.provider}/${grant.path}?${query}`;
22
+ };
23
+ /** The grant of a download URL whose signature checks out; `expired` once it is past its time. */
24
+ const readDownloadUrl = async (signer, provider, path, query, now = Date.now()) => {
25
+ const expires = Number(query.get("exp"));
26
+ const grant = {
27
+ provider,
28
+ path,
29
+ resource: query.get("r") ?? "",
30
+ key: query.get("k") ?? "",
31
+ field: query.get("f") ?? "",
32
+ expires
33
+ };
34
+ const signature = query.get("sig") ?? "";
35
+ if (!Number.isSafeInteger(expires) || !await signer.verify("download", parts(grant), signature)) return { status: "invalid" };
36
+ if (expires * 1e3 < now) return { status: "expired" };
37
+ return {
38
+ status: "ok",
39
+ grant
40
+ };
41
+ };
42
+ //#endregion
43
+ export { downloadExpiry, readDownloadUrl, signedDownloadUrl };
@@ -0,0 +1,90 @@
1
+ import { HttpProblem, forbidden, notFound } from "../problem.js";
2
+ import { decodeKeyText } from "../key.js";
3
+ import { readDownloadUrl } from "./download-url.js";
4
+ import { downloadName, isInline, servedType } from "./media-types.js";
5
+ import { baseOf, isFilePath, parseUri, tenantSegment } from "./uri.js";
6
+ import { fetchRecord } from "../records.js";
7
+ import { readTransaction } from "../transactions.js";
8
+ //#region src/files/download.ts
9
+ const gone = () => notFound("There is no such file");
10
+ const rangeOf = (header, size) => {
11
+ if (!header) return void 0;
12
+ const match = /^bytes=(\d*)-(\d*)$/.exec(header.trim());
13
+ if (!match || match[1] === "" && match[2] === "") return false;
14
+ const start = match[1] === "" ? Math.max(size - Number(match[2]), 0) : Number(match[1]);
15
+ const end = match[1] === "" || match[2] === "" ? size - 1 : Math.min(Number(match[2]), size - 1);
16
+ return start <= end && start < size ? {
17
+ start,
18
+ end
19
+ } : false;
20
+ };
21
+ const disposition = (name, type) => {
22
+ const ascii = name.replace(/[^\x20-\x7e]|["\\]/g, "_");
23
+ return `${isInline(type) ? "inline" : "attachment"}; filename="${ascii}"; filename*=UTF-8''${encodeURIComponent(name)}`;
24
+ };
25
+ const grantedName = async (deps, provider, path, query) => {
26
+ const read = await readDownloadUrl(deps.files.signer, provider, path, query);
27
+ if (read.status === "invalid") throw forbidden("invalid-signature", "This file link is not valid");
28
+ if (read.status === "expired") throw forbidden("link-expired", "This file link expired; load the record again for a new one");
29
+ const { grant } = read;
30
+ const entry = deps.registry.find(grant.resource);
31
+ const field = entry?.model.fields[grant.field];
32
+ if (!entry || field?.type !== "file") throw gone();
33
+ const tenantField = entry.model.tenant ? entry.model.fields[entry.model.tenant] : void 0;
34
+ const fields = tenantField ? [field, tenantField] : [field];
35
+ const row = await readTransaction(deps.db, deps.statementTimeoutMs, (trx) => fetchRecord(trx, entry, decodeKeyText(entry, grant.key), void 0, {
36
+ deleted: "show",
37
+ fields
38
+ }));
39
+ const value = row?.record[field.name];
40
+ if (typeof value !== "string" || baseOf(value) !== `${provider}:${path}`) throw gone();
41
+ if (tenantField && path.split("/")[0] !== await tenantSegment(row.record[tenantField.name])) throw gone();
42
+ return {
43
+ name: parseUri(value)?.name ?? path,
44
+ expires: grant.expires
45
+ };
46
+ };
47
+ /**
48
+ * `GET <systemPath>/files/<provider>/<path>`: a public provider's file for anyone; a private one's only through a signed
49
+ * URL whose row still holds it. The Content-Type comes from the path's extension, never from what was uploaded, and
50
+ * nothing renders: `nosniff`, a sandboxing CSP, and `inline` only for images, PDF, audio and video.
51
+ */
52
+ const serveFile = async (deps, request, provider, path) => {
53
+ const configured = deps.files?.providers[provider];
54
+ if (!configured || !isFilePath(path)) throw gone();
55
+ const granted = configured.public ? void 0 : await grantedName(deps, provider, path, new URL(request.url).searchParams);
56
+ const object = await configured.store.head(path);
57
+ if (!object) throw gone();
58
+ const type = servedType(path);
59
+ const name = downloadName(granted?.name ?? path.slice(path.lastIndexOf("/") + 1), type);
60
+ const caching = granted ? `private, max-age=${Math.max(granted.expires - Math.floor(Date.now() / 1e3), 0)}` : "public, max-age=31536000, immutable";
61
+ const headers = {
62
+ "content-type": type,
63
+ "content-disposition": disposition(name, type),
64
+ "x-content-type-options": "nosniff",
65
+ "content-security-policy": "sandbox; default-src 'none'",
66
+ "cache-control": caching,
67
+ "accept-ranges": "bytes"
68
+ };
69
+ const range = rangeOf(request.headers.get("range"), object.size);
70
+ if (range === false) throw new HttpProblem(416, "range-not-satisfiable", "Range Not Satisfiable", "The requested range is outside the file", {}, { "content-range": `bytes */${object.size}` });
71
+ const body = await configured.store.read(path, range);
72
+ if (!body) throw gone();
73
+ if (!range) return new Response(body, {
74
+ status: 200,
75
+ headers: {
76
+ ...headers,
77
+ "content-length": String(object.size)
78
+ }
79
+ });
80
+ return new Response(body, {
81
+ status: 206,
82
+ headers: {
83
+ ...headers,
84
+ "content-length": String(range.end - range.start + 1),
85
+ "content-range": `bytes ${range.start}-${range.end}/${object.size}`
86
+ }
87
+ });
88
+ };
89
+ //#endregion
90
+ export { serveFile };
@@ -0,0 +1,35 @@
1
+ //#region src/files/duration.ts
2
+ const units = {
3
+ s: 1e3,
4
+ second: 1e3,
5
+ m: 6e4,
6
+ min: 6e4,
7
+ minute: 6e4,
8
+ h: 36e5,
9
+ hour: 36e5,
10
+ d: 864e5,
11
+ day: 864e5,
12
+ w: 6048e5,
13
+ week: 6048e5
14
+ };
15
+ /** Milliseconds from a number of them or text such as `1 day`, `14d`, `12 hours` or `0`. */
16
+ const parseDuration = (value, label) => {
17
+ if (typeof value === "number") {
18
+ if (!Number.isSafeInteger(value) || value < 0) throw new Error(`${label} must be a whole number of milliseconds, not ${value}`);
19
+ return value;
20
+ }
21
+ const invalid = /* @__PURE__ */ new Error(`${label} is "${value}"; write it like "1 day", "14d" or "12 hours"`);
22
+ const match = /^\s*(\d+)\s*([a-z]*)\s*$/i.exec(value);
23
+ if (!match) throw invalid;
24
+ const amount = Number(match[1]);
25
+ const word = match[2].toLowerCase();
26
+ if (word === "") {
27
+ if (amount === 0) return 0;
28
+ throw invalid;
29
+ }
30
+ const unit = units[word] ?? units[word.replace(/s$/, "")];
31
+ if (unit === void 0) throw invalid;
32
+ return amount * unit;
33
+ };
34
+ //#endregion
35
+ export { parseDuration };
@@ -0,0 +1,18 @@
1
+ import { FilesOptions } from "./options.js";
2
+ import { CleanupReport } from "./cleanup-runner.js";
3
+ import { ResourceSource } from "../resource-source.js";
4
+ import { Db } from "@protobase/query";
5
+ //#region src/files/file-cleanup.d.ts
6
+ export type FileCleanupInput = {
7
+ resources: ResourceSource[];
8
+ db: Db;
9
+ files?: FilesOptions;
10
+ };
11
+ /**
12
+ * The scheduled deletes of a project's files, for a runner outside a request: `protobase files cleanup`, or an interval
13
+ * in `protobase serve`. `undefined` when the project has no file fields.
14
+ */
15
+ export declare const createFileCleanup: ({ resources, db, files }: FileCleanupInput) => {
16
+ run: (now?: Date) => Promise<CleanupReport>;
17
+ } | undefined;
18
+ //#endregion
@@ -0,0 +1,17 @@
1
+ import { cleanupFiles, createFilesRuntime } from "./runtime.js";
2
+ //#region src/files/file-cleanup.ts
3
+ /**
4
+ * The scheduled deletes of a project's files, for a runner outside a request: `protobase files cleanup`, or an interval
5
+ * in `protobase serve`. `undefined` when the project has no file fields.
6
+ */
7
+ const createFileCleanup = ({ resources, db, files }) => {
8
+ const runtime = createFilesRuntime({
9
+ ...files && { options: files },
10
+ env: globalThis.process?.env ?? {},
11
+ models: resources.map((source) => source.toModel()),
12
+ systemPath: ""
13
+ });
14
+ return runtime && { run: (now) => cleanupFiles(runtime, db, now) };
15
+ };
16
+ //#endregion
17
+ export { createFileCleanup };
@@ -0,0 +1,17 @@
1
+ import { FileProvider, FileStore } from "./store.js";
2
+ //#region src/files/local-files.d.ts
3
+ export type LocalFilesOptions = {
4
+ /** The folder the files live in, created when missing; relative to the working directory. */
5
+ dir: string;
6
+ /** Files anyone with the link may open, served without a signature. */
7
+ public?: boolean;
8
+ /** Where public files are linked from instead of the app's own `/api/files/<provider>`, such as a CDN in front of it. */
9
+ publicUrl?: string;
10
+ /** How long bytes outlive their last reference; default `files.retention`, or 0 for a public provider. */
11
+ retention?: number | string;
12
+ };
13
+ /** A store in a folder on local disk. Writes go to `.tmp/` first and are renamed into place, so they are atomic. */
14
+ export declare const localFileStore: (dir: string) => FileStore;
15
+ /** A provider on local disk: `localFiles({ dir: './data/files/private' })`. */
16
+ export declare const localFiles: ({ dir, public: isPublic, publicUrl, retention }: LocalFilesOptions) => FileProvider;
17
+ //#endregion
@@ -0,0 +1,101 @@
1
+ import { HttpProblem } from "../problem.js";
2
+ import { parseDuration } from "./duration.js";
3
+ //#region src/files/local-files.ts
4
+ const node = async () => ({
5
+ fs: await import("node:fs/promises"),
6
+ streams: await import("node:fs"),
7
+ path: await import("node:path"),
8
+ stream: await import("node:stream"),
9
+ pipeline: (await import("node:stream/promises")).pipeline,
10
+ crypto: await import("node:crypto")
11
+ });
12
+ const codeOf = (error) => error instanceof Error ? error.code : void 0;
13
+ const storageFull = () => new HttpProblem(507, "storage-full", "Insufficient Storage", "There is no room left to store this file");
14
+ /** A store in a folder on local disk. Writes go to `.tmp/` first and are renamed into place, so they are atomic. */
15
+ const localFileStore = (dir) => {
16
+ const resolve = async (relative) => {
17
+ const { path } = await node();
18
+ const root = path.resolve(dir);
19
+ const full = path.resolve(root, relative);
20
+ if (!full.startsWith(`${root}${path.sep}`)) throw new Error(`"${relative}" is outside the file store`);
21
+ return full;
22
+ };
23
+ const head = async (relative) => {
24
+ const { fs } = await node();
25
+ try {
26
+ const stat = await fs.stat(await resolve(relative));
27
+ return stat.isFile() ? {
28
+ path: relative,
29
+ size: stat.size,
30
+ modified: stat.mtime
31
+ } : void 0;
32
+ } catch (error) {
33
+ if (codeOf(error) === "ENOENT" || codeOf(error) === "ENOTDIR") return void 0;
34
+ throw error;
35
+ }
36
+ };
37
+ const put = async (relative, body) => {
38
+ const { fs, streams, path, stream, pipeline, crypto } = await node();
39
+ const full = await resolve(relative);
40
+ const temporary = await resolve(`.tmp/${crypto.randomUUID()}`);
41
+ await fs.mkdir(path.dirname(temporary), { recursive: true });
42
+ await fs.mkdir(path.dirname(full), { recursive: true });
43
+ try {
44
+ await pipeline(stream.Readable.fromWeb(body), streams.createWriteStream(temporary, { flags: "wx" }));
45
+ await fs.rename(temporary, full);
46
+ } catch (error) {
47
+ await fs.rm(temporary, { force: true });
48
+ if (codeOf(error) === "ENOSPC" || codeOf(error) === "EDQUOT") throw storageFull();
49
+ throw error;
50
+ }
51
+ return { size: (await fs.stat(full)).size };
52
+ };
53
+ const read = async (relative, range) => {
54
+ const { streams, stream } = await node();
55
+ if (!await head(relative)) return void 0;
56
+ const reader = streams.createReadStream(await resolve(relative), range ? {
57
+ start: range.start,
58
+ end: range.end
59
+ } : {});
60
+ return stream.Readable.toWeb(reader);
61
+ };
62
+ const remove = async (relative) => {
63
+ const { fs } = await node();
64
+ await fs.rm(await resolve(relative), { force: true });
65
+ };
66
+ const list = async (prefix) => {
67
+ const { fs, path } = await node();
68
+ const root = path.resolve(dir);
69
+ const folder = await resolve(prefix.replace(/\/$/, ""));
70
+ let names;
71
+ try {
72
+ names = await fs.readdir(folder, { recursive: true });
73
+ } catch (error) {
74
+ if (codeOf(error) === "ENOENT") return [];
75
+ throw error;
76
+ }
77
+ const found = [];
78
+ for (const name of names) {
79
+ const relative = path.relative(root, path.join(folder, name)).split(path.sep).join("/");
80
+ const object = await head(relative);
81
+ if (object) found.push(object);
82
+ }
83
+ return found.sort((a, b) => a.path < b.path ? -1 : a.path > b.path ? 1 : 0);
84
+ };
85
+ return {
86
+ put,
87
+ head,
88
+ read,
89
+ delete: remove,
90
+ list
91
+ };
92
+ };
93
+ /** A provider on local disk: `localFiles({ dir: './data/files/private' })`. */
94
+ const localFiles = ({ dir, public: isPublic = false, publicUrl, retention }) => ({
95
+ store: localFileStore(dir),
96
+ public: isPublic || publicUrl !== void 0,
97
+ ...publicUrl !== void 0 && { publicUrl: publicUrl.replace(/\/$/, "") },
98
+ ...retention !== void 0 && { retention: parseDuration(retention, "retention") }
99
+ });
100
+ //#endregion
101
+ export { localFileStore, localFiles };
@@ -0,0 +1,99 @@
1
+ //#region src/files/media-types.ts
2
+ /**
3
+ * The one table between types and extensions. An upload's decided type picks its path's extension, and every download's
4
+ * Content-Type is looked up from that extension here, so what is served never comes from a client's header.
5
+ */
6
+ const table = [
7
+ ["image/png", ["png"]],
8
+ ["image/jpeg", ["jpg", "jpeg"]],
9
+ ["image/gif", ["gif"]],
10
+ ["image/webp", ["webp"]],
11
+ ["image/avif", ["avif"]],
12
+ ["image/heic", ["heic"]],
13
+ ["image/bmp", ["bmp"]],
14
+ ["image/tiff", ["tif", "tiff"]],
15
+ ["image/x-icon", ["ico"]],
16
+ ["image/svg+xml", ["svg"]],
17
+ ["application/pdf", ["pdf"]],
18
+ ["application/zip", ["zip"]],
19
+ ["application/gzip", ["gz"]],
20
+ ["application/x-tar", ["tar"]],
21
+ ["application/x-7z-compressed", ["7z"]],
22
+ ["application/vnd.rar", ["rar"]],
23
+ ["application/vnd.openxmlformats-officedocument.wordprocessingml.document", ["docx"]],
24
+ ["application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", ["xlsx"]],
25
+ ["application/vnd.openxmlformats-officedocument.presentationml.presentation", ["pptx"]],
26
+ ["application/vnd.oasis.opendocument.text", ["odt"]],
27
+ ["application/vnd.oasis.opendocument.spreadsheet", ["ods"]],
28
+ ["application/vnd.oasis.opendocument.presentation", ["odp"]],
29
+ ["application/msword", ["doc"]],
30
+ ["application/vnd.ms-excel", ["xls"]],
31
+ ["application/vnd.ms-powerpoint", ["ppt"]],
32
+ ["application/rtf", ["rtf"]],
33
+ ["application/epub+zip", ["epub"]],
34
+ ["application/x-msdownload", ["exe"]],
35
+ ["application/x-sqlite3", ["sqlite"]],
36
+ ["audio/mpeg", ["mp3"]],
37
+ ["audio/wav", ["wav"]],
38
+ ["audio/ogg", ["ogg"]],
39
+ ["audio/mp4", ["m4a"]],
40
+ ["audio/flac", ["flac"]],
41
+ ["video/mp4", ["mp4"]],
42
+ ["video/webm", ["webm"]],
43
+ ["video/quicktime", ["mov"]],
44
+ ["video/x-matroska", ["mkv"]],
45
+ ["text/csv", ["csv"]],
46
+ ["text/tab-separated-values", ["tsv"]],
47
+ ["text/plain", ["txt"]],
48
+ ["text/markdown", ["md"]],
49
+ ["application/json", ["json"]],
50
+ ["application/octet-stream", ["bin"]]
51
+ ];
52
+ const octetStream = "application/octet-stream";
53
+ const byExtension = new Map(table.flatMap(([type, extensions]) => extensions.map((extension) => [extension, type])));
54
+ const byType = new Map(table.map(([type, extensions]) => [type, extensions[0]]));
55
+ /** Text formats a name's extension may give text content, which magic bytes cannot tell apart. */
56
+ const textTypes = /* @__PURE__ */ new Set([
57
+ "text/csv",
58
+ "text/tab-separated-values",
59
+ "text/plain",
60
+ "text/markdown",
61
+ "application/json"
62
+ ]);
63
+ const inlineTypes = /* @__PURE__ */ new Set([
64
+ "image/png",
65
+ "image/jpeg",
66
+ "image/gif",
67
+ "image/webp",
68
+ "image/avif",
69
+ "image/bmp",
70
+ "application/pdf"
71
+ ]);
72
+ const extensionOf = (name) => {
73
+ const dot = name.lastIndexOf(".");
74
+ return dot <= 0 || dot === name.length - 1 ? void 0 : name.slice(dot + 1).toLowerCase();
75
+ };
76
+ /** The type of a name or path, from its extension; `undefined` for an extension the table does not know. */
77
+ const typeOfExtension = (name) => {
78
+ const extension = extensionOf(name);
79
+ return extension === void 0 ? void 0 : byExtension.get(extension);
80
+ };
81
+ /** The type a stored path is served as. */
82
+ const servedType = (path) => typeOfExtension(path) ?? "application/octet-stream";
83
+ /** The canonical extension of a type the table knows. */
84
+ const canonicalExtension = (type) => byType.get(type);
85
+ const isKnownType = (type) => byType.has(type);
86
+ const isInline = (type) => inlineTypes.has(type) || type.startsWith("audio/") || type.startsWith("video/");
87
+ /** Whether `type` passes a field's allowed list: exact types, `type/*` wildcards, `*\/*`; an empty list allows any. */
88
+ const accepts = (allowed, type) => allowed.length === 0 || allowed.some((entry) => entry === "*/*" || entry === type || entry.endsWith("/*") && type.startsWith(entry.slice(0, -1)));
89
+ /** The uploader's name with its extension made to match the stored type: `photo.png` holding a JPEG downloads as `photo.jpg`. */
90
+ const downloadName = (name, type) => {
91
+ const canonical = canonicalExtension(type);
92
+ if (!canonical || type === "application/octet-stream") return name;
93
+ const known = typeOfExtension(name);
94
+ if (known === type) return name;
95
+ const extension = extensionOf(name);
96
+ return `${extension !== void 0 && known !== void 0 ? name.slice(0, -extension.length - 1) : name}.${canonical}`;
97
+ };
98
+ //#endregion
99
+ export { accepts, canonicalExtension, downloadName, extensionOf, isInline, isKnownType, octetStream, servedType, textTypes, typeOfExtension };
@@ -0,0 +1,15 @@
1
+ import { FileProvider } from "./store.js";
2
+ //#region src/files/options.d.ts
3
+ /** `files` in `protobase.config.ts`. Environment variables override it (see `resolveProviders`). */
4
+ export type FilesOptions = {
5
+ /**
6
+ * Providers by name, such as `private: localFiles({ dir: './data/files' })`. A name is part of every stored value, so
7
+ * name the role (`archive`), not the vendor. Without config, `private` and `public` are folders under `data/files`.
8
+ */
9
+ providers?: Record<string, FileProvider>;
10
+ /** How long bytes outlive their last reference, for restores; default `1 day`. Public providers default to 0. */
11
+ retention?: number | string;
12
+ /** Signs upload tickets and download URLs; default `PROTOBASE_FILES_SECRET`, then `BETTER_AUTH_SECRET`. */
13
+ secret?: string;
14
+ };
15
+ //#endregion
@@ -0,0 +1,15 @@
1
+ import "../types.js";
2
+ import "../request-access.js";
3
+ //#region src/files/present.d.ts
4
+ /** A file field as responses carry it; `missing` when the value does not parse or names a provider the config lacks. */
5
+ export type FileObject = {
6
+ uri: string;
7
+ name: string;
8
+ type: string;
9
+ size?: number;
10
+ url?: string;
11
+ } | {
12
+ uri: string;
13
+ missing: true;
14
+ };
15
+ //#endregion
@@ -0,0 +1,59 @@
1
+ import { downloadExpiry, signedDownloadUrl } from "./download-url.js";
2
+ import { servedType } from "./media-types.js";
3
+ import { parseUri } from "./uri.js";
4
+ import { encodeKey } from "@protobase/schema";
5
+ //#region src/files/present.ts
6
+ const fileObject = async (files, access, row, field, value, expires) => {
7
+ const ref = parseUri(value);
8
+ const provider = ref && files.providers[ref.provider];
9
+ if (!ref || !provider) return {
10
+ uri: value,
11
+ missing: true
12
+ };
13
+ const described = {
14
+ uri: value,
15
+ name: ref.name,
16
+ type: servedType(ref.path),
17
+ ...ref.size !== void 0 && { size: ref.size }
18
+ };
19
+ if (provider.public) return {
20
+ ...described,
21
+ url: `${provider.publicUrl ?? `${files.path}/${ref.provider}`}/${ref.path}`
22
+ };
23
+ const key = access.full.primaryKey.map((name) => row[name]);
24
+ if (key.some((part) => typeof part !== "string" && typeof part !== "number")) return described;
25
+ const keyText = encodeKey(key);
26
+ const url = await signedDownloadUrl(files.signer, files.path, {
27
+ provider: ref.provider,
28
+ path: ref.path,
29
+ resource: access.entry.name,
30
+ key: keyText,
31
+ field,
32
+ expires
33
+ });
34
+ return {
35
+ ...described,
36
+ url
37
+ };
38
+ };
39
+ /**
40
+ * Records as responses carry them: each readable file field as `{ uri, name, type, size, url }`. The URL is minted here,
41
+ * after the ETag, for a caller who may already see the row: private files get a signed link to this row's field, public
42
+ * ones their permanent address. Stored values, hooks and ETags only ever see the URI.
43
+ */
44
+ const presentFiles = async (access, rows) => {
45
+ const files = access.deps.files;
46
+ const fields = Object.values(access.model.fields).filter((field) => field.type === "file");
47
+ if (!files || fields.length === 0) return rows;
48
+ const expires = downloadExpiry();
49
+ return Promise.all(rows.map(async (row) => {
50
+ const shown = { ...row };
51
+ for (const field of fields) {
52
+ const value = row[field.name];
53
+ if (typeof value === "string") shown[field.name] = await fileObject(files, access, row, field.name, value, expires);
54
+ }
55
+ return shown;
56
+ }));
57
+ };
58
+ //#endregion
59
+ export { presentFiles };
@@ -0,0 +1,15 @@
1
+ import { FileProcessor } from "@protobase/schema";
2
+ //#region src/files/processors.d.ts
3
+ /** A processor of the project's own: `processor({ accepts: ['image/*'], run: async (file) => … })`. */
4
+ export declare const processor: (definition: FileProcessor) => FileProcessor;
5
+ /** An image's width or height in pixels, as shown (EXIF rotation applied), for an integer field. Null for other files. */
6
+ export declare const imageSize: (dimension: "width" | "height") => FileProcessor;
7
+ /** An image's width divided by its height, as decimal text with `decimals` places (default 4), for a decimal field. */
8
+ export declare const aspectRatio: ({ decimals }?: {
9
+ decimals?: number;
10
+ }) => FileProcessor;
11
+ /** The file's size in bytes, for an integer field to filter or sort on. */
12
+ export declare const fileSize: () => FileProcessor;
13
+ /** The file's detected type, for a text field to filter or sort on. */
14
+ export declare const fileType: () => FileProcessor;
15
+ //#endregion