@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.
Files changed (99) 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/better-auth/authenticator.d.ts +7 -3
  9. package/dist/better-auth/authenticator.js +24 -3
  10. package/dist/better-auth/create-auth.d.ts +619 -81
  11. package/dist/better-auth/create-auth.js +64 -27
  12. package/dist/better-auth/organizations/accept-invitation.js +80 -0
  13. package/dist/better-auth/organizations/active-organization.js +12 -0
  14. package/dist/better-auth/organizations/admin-routes.js +83 -0
  15. package/dist/better-auth/organizations/app-roles.js +43 -0
  16. package/dist/better-auth/organizations/host.d.ts +53 -0
  17. package/dist/better-auth/organizations/host.js +251 -0
  18. package/dist/better-auth/organizations/invitation-endpoints.js +133 -0
  19. package/dist/better-auth/organizations/invitation-mail.js +17 -0
  20. package/dist/better-auth/organizations/invitation-token.js +27 -0
  21. package/dist/better-auth/organizations/member-endpoints.js +182 -0
  22. package/dist/better-auth/organizations/options.d.ts +48 -0
  23. package/dist/better-auth/organizations/options.js +23 -0
  24. package/dist/better-auth/organizations/organization-plugin.js +128 -0
  25. package/dist/better-auth/organizations/protobase-plugin.js +21 -0
  26. package/dist/better-auth/organizations/role-definitions.d.ts +15 -0
  27. package/dist/better-auth/organizations/role-definitions.js +56 -0
  28. package/dist/better-auth/organizations/session-organization.js +68 -0
  29. package/dist/better-auth/organizations/store.d.ts +19 -0
  30. package/dist/better-auth/organizations/store.js +73 -0
  31. package/dist/better-auth/staff-checks.js +5 -1
  32. package/dist/better-auth/staff-sign-in-endpoints.js +1 -1
  33. package/dist/better-auth/status-route.js +14 -3
  34. package/dist/better-auth/users.d.ts +2 -1
  35. package/dist/better-auth/users.js +4 -1
  36. package/dist/create-admin.d.ts +8 -2
  37. package/dist/create-admin.js +31 -2
  38. package/dist/deps.d.ts +1 -0
  39. package/dist/files/attach.js +80 -0
  40. package/dist/files/cleanup-runner.d.ts +8 -0
  41. package/dist/files/cleanup-runner.js +58 -0
  42. package/dist/files/cleanup-schedule.d.ts +28 -0
  43. package/dist/files/cleanup-schedule.js +48 -0
  44. package/dist/files/detect.js +51 -0
  45. package/dist/files/download-url.js +43 -0
  46. package/dist/files/download.js +90 -0
  47. package/dist/files/duration.js +35 -0
  48. package/dist/files/file-cleanup.d.ts +18 -0
  49. package/dist/files/file-cleanup.js +17 -0
  50. package/dist/files/local-files.d.ts +17 -0
  51. package/dist/files/local-files.js +101 -0
  52. package/dist/files/media-types.js +99 -0
  53. package/dist/files/options.d.ts +15 -0
  54. package/dist/files/present.d.ts +15 -0
  55. package/dist/files/present.js +59 -0
  56. package/dist/files/processors.d.ts +15 -0
  57. package/dist/files/processors.js +39 -0
  58. package/dist/files/providers.js +70 -0
  59. package/dist/files/routes.js +26 -0
  60. package/dist/files/runtime.d.ts +3 -0
  61. package/dist/files/runtime.js +37 -0
  62. package/dist/files/signer.js +32 -0
  63. package/dist/files/store.d.ts +37 -0
  64. package/dist/files/ticket.js +87 -0
  65. package/dist/files/upload-body.js +85 -0
  66. package/dist/files/upload.js +124 -0
  67. package/dist/files/uri.js +47 -0
  68. package/dist/functions/define-function.d.ts +71 -0
  69. package/dist/functions/define-function.js +36 -0
  70. package/dist/functions/function-app.d.ts +17 -0
  71. package/dist/functions/function-app.js +19 -0
  72. package/dist/functions/read-json.d.ts +35 -0
  73. package/dist/functions/read-json.js +24 -0
  74. package/dist/functions/records.d.ts +43 -0
  75. package/dist/functions/records.js +31 -0
  76. package/dist/functions/require-env.d.ts +7 -0
  77. package/dist/functions/require-env.js +12 -0
  78. package/dist/functions/routes.js +60 -0
  79. package/dist/functions/verify-signature.d.ts +32 -0
  80. package/dist/functions/verify-signature.js +49 -0
  81. package/dist/index.d.ts +21 -3
  82. package/dist/index.js +13 -3
  83. package/dist/list-service.js +4 -2
  84. package/dist/openapi/resource-paths.js +78 -1
  85. package/dist/openapi/schemas.js +28 -3
  86. package/dist/project-config.d.ts +7 -1
  87. package/dist/project-config.js +37 -1
  88. package/dist/read-service.js +5 -1
  89. package/dist/request-access.d.ts +5 -0
  90. package/dist/request-access.js +9 -4
  91. package/dist/resource-source.d.ts +4 -1
  92. package/dist/routes/batch.js +1 -1
  93. package/dist/routes/collection.js +3 -1
  94. package/dist/routes/record.js +2 -5
  95. package/dist/types.d.ts +25 -5
  96. package/dist/write-pipeline.js +2 -1
  97. package/dist/write-shared.js +6 -3
  98. package/dist/write-update.js +2 -1
  99. package/package.json +6 -4
@@ -0,0 +1,80 @@
1
+ import { badRequest } from "../problem.js";
2
+ import { baseOf, parseUri } from "./uri.js";
3
+ import { tenantScope } from "../tenant.js";
4
+ import { readTicket } from "./ticket.js";
5
+ //#region src/files/attach.ts
6
+ /** The ticket scope of a caller's upload for one field. */
7
+ const ticketScope = (access, field) => ({
8
+ tenant: String(tenantScope(access.entry, access.session)?.tenantValue ?? ""),
9
+ resource: access.entry.name,
10
+ field,
11
+ user: String(access.session.user.id)
12
+ });
13
+ const fileFields = (access) => Object.values(access.full.fields).filter((field) => field.file);
14
+ /**
15
+ * File values of a validated create or update body, made ready to store: a ticket from `:upload` becomes its clean URI
16
+ * plus the derived fields it carries, `null` clears the field and its derived fields, and the stored value itself is a
17
+ * no-op. Anything else is refused, so a row can only point at a file its writer uploaded for that field.
18
+ */
19
+ const attachFiles = async (access, values, current) => {
20
+ const names = Object.keys(values).filter((name) => access.full.fields[name]?.file);
21
+ const files = access.deps.files;
22
+ if (names.length === 0 || !files) return values;
23
+ const result = { ...values };
24
+ const errors = [];
25
+ const now = Math.floor(Date.now() / 1e3);
26
+ for (const name of names) {
27
+ const field = access.full.fields[name];
28
+ const derive = field.file.derive ?? [];
29
+ const value = values[name];
30
+ if (value === null) {
31
+ for (const target of derive) result[target] = null;
32
+ continue;
33
+ }
34
+ if (current && value === current[name]) {
35
+ delete result[name];
36
+ continue;
37
+ }
38
+ const ticket = await readTicket(files.signer, String(value), ticketScope(access, name), now);
39
+ if (!ticket.ok || ticket.ref.provider !== field.file.provider) {
40
+ const expired = !ticket.ok && ticket.reason === "expired";
41
+ errors.push({
42
+ field: name,
43
+ code: "invalid-value",
44
+ message: expired ? "The upload expired; upload the file again" : "Not a file uploaded for this field; upload it with :upload first"
45
+ });
46
+ continue;
47
+ }
48
+ result[name] = ticket.uri;
49
+ for (const target of derive) result[target] = Object.hasOwn(ticket.derived, target) ? ticket.derived[target] : null;
50
+ }
51
+ if (errors.length > 0) throw badRequest("invalid-record", `The request body is invalid: ${errors[0].field}: ${errors[0].message}`, { errors });
52
+ return result;
53
+ };
54
+ /**
55
+ * Schedules the delete of every file a write took out of its row: a replaced or cleared value, or all of a hard-deleted
56
+ * row's files, each due after its provider's retention. It runs inside the write transaction, before the commit: if
57
+ * the write rolls back, the file is still referenced when the entry is due, and stays.
58
+ */
59
+ const scheduleRemovedFiles = async (access, operation, before, after) => {
60
+ const files = access.deps.files;
61
+ if (!files || !before || operation === "create" || operation === "undelete") return;
62
+ if (operation === "delete" && access.full.softDelete) return;
63
+ const now = Date.now();
64
+ const removed = fileFields(access).flatMap((field) => {
65
+ const old = before[field.name];
66
+ if (typeof old !== "string") return [];
67
+ const next = after?.[field.name];
68
+ if (typeof next === "string" && baseOf(next) === baseOf(old)) return [];
69
+ const ref = parseUri(old);
70
+ if (!ref || !files.providers[ref.provider]) return [];
71
+ return [{
72
+ provider: ref.provider,
73
+ path: ref.path,
74
+ due: new Date(now + files.retention(ref.provider))
75
+ }];
76
+ });
77
+ if (removed.length > 0) await files.schedule.add(removed);
78
+ };
79
+ //#endregion
80
+ export { attachFiles, scheduleRemovedFiles, ticketScope };
@@ -0,0 +1,8 @@
1
+ import { ResourceModel } from "@protobase/schema";
2
+ import { Db } from "@protobase/query";
3
+ //#region src/files/cleanup-runner.d.ts
4
+ export type CleanupReport = {
5
+ deleted: string[];
6
+ kept: string[];
7
+ };
8
+ //#endregion
@@ -0,0 +1,58 @@
1
+ import { sql } from "kysely";
2
+ //#region src/files/cleanup-runner.ts
3
+ /** Every declared file column, once per table and column even when two resources share them. */
4
+ const fileColumns = (models) => {
5
+ const seen = /* @__PURE__ */ new Map();
6
+ for (const model of models) for (const field of Object.values(model.fields)) {
7
+ if (field.type !== "file") continue;
8
+ seen.set(JSON.stringify([
9
+ model.table.schema ?? "",
10
+ model.table.name,
11
+ field.column
12
+ ]), {
13
+ table: model.table,
14
+ column: field.column
15
+ });
16
+ }
17
+ return [...seen.values()];
18
+ };
19
+ const chunk = (items, size) => Array.from({ length: Math.ceil(items.length / size) }, (_, i) => items.slice(i * size, (i + 1) * size));
20
+ /** The identities (`provider:path`) among `uris` that some row still holds. */
21
+ const referenced = async (db, columns, uris) => {
22
+ const found = /* @__PURE__ */ new Set();
23
+ for (const { table, column } of columns) {
24
+ const value = sql`split_part(${sql.id(column)}, '?', 1)`;
25
+ const name = table.schema ? sql.id(table.schema, table.name) : sql.id(table.name);
26
+ const rows = await sql`select distinct ${value} as uri from ${name} where ${value} = any(${uris})`.execute(db);
27
+ for (const row of rows.rows) found.add(row.uri);
28
+ }
29
+ return found;
30
+ };
31
+ /**
32
+ * Handles every scheduled delete that is due: right before deleting, it checks that no declared file column holds the
33
+ * file; one that is referenced again (a rolled-back write, a restore, a shared value) is kept and its entry dropped.
34
+ * Uploads never attached are deleted here too, after their ticket expired. Running it twice at once is harmless.
35
+ */
36
+ const runCleanup = async ({ db, columns, providers, schedule, now = /* @__PURE__ */ new Date() }) => {
37
+ const deleted = /* @__PURE__ */ new Set();
38
+ const kept = /* @__PURE__ */ new Set();
39
+ for (const entries of chunk(await schedule.due(now), 500)) {
40
+ const uris = [...new Set(entries.map((entry) => `${entry.provider}:${entry.path}`))];
41
+ const inUse = await referenced(db, columns, uris);
42
+ for (const entry of entries) {
43
+ const uri = `${entry.provider}:${entry.path}`;
44
+ if (inUse.has(uri)) kept.add(uri);
45
+ else if (!deleted.has(uri)) {
46
+ await providers[entry.provider].store.delete(entry.path);
47
+ deleted.add(uri);
48
+ }
49
+ await schedule.done(entry);
50
+ }
51
+ }
52
+ return {
53
+ deleted: [...deleted],
54
+ kept: [...kept]
55
+ };
56
+ };
57
+ //#endregion
58
+ export { fileColumns, runCleanup };
@@ -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 };