@protobase/server 0.6.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 (105) 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/auth-schema.d.ts +4 -2
  9. package/dist/better-auth/auth-schema.js +18 -8
  10. package/dist/better-auth/authenticator.d.ts +7 -3
  11. package/dist/better-auth/authenticator.js +24 -3
  12. package/dist/better-auth/create-auth.d.ts +683 -10
  13. package/dist/better-auth/create-auth.js +72 -27
  14. package/dist/better-auth/organizations/accept-invitation.js +80 -0
  15. package/dist/better-auth/organizations/active-organization.js +12 -0
  16. package/dist/better-auth/organizations/admin-routes.js +83 -0
  17. package/dist/better-auth/organizations/app-roles.js +43 -0
  18. package/dist/better-auth/organizations/host.d.ts +53 -0
  19. package/dist/better-auth/organizations/host.js +251 -0
  20. package/dist/better-auth/organizations/invitation-endpoints.js +133 -0
  21. package/dist/better-auth/organizations/invitation-mail.js +17 -0
  22. package/dist/better-auth/organizations/invitation-token.js +27 -0
  23. package/dist/better-auth/organizations/member-endpoints.js +182 -0
  24. package/dist/better-auth/organizations/options.d.ts +48 -0
  25. package/dist/better-auth/organizations/options.js +23 -0
  26. package/dist/better-auth/organizations/organization-plugin.js +128 -0
  27. package/dist/better-auth/organizations/protobase-plugin.js +21 -0
  28. package/dist/better-auth/organizations/role-definitions.d.ts +15 -0
  29. package/dist/better-auth/organizations/role-definitions.js +56 -0
  30. package/dist/better-auth/organizations/session-organization.js +68 -0
  31. package/dist/better-auth/organizations/store.d.ts +19 -0
  32. package/dist/better-auth/organizations/store.js +73 -0
  33. package/dist/better-auth/policy-store.js +44 -35
  34. package/dist/better-auth/snake-case-migration.js +122 -0
  35. package/dist/better-auth/snake-case-names.js +72 -0
  36. package/dist/better-auth/staff-checks.js +5 -1
  37. package/dist/better-auth/staff-log.js +56 -45
  38. package/dist/better-auth/staff-sign-in-endpoints.js +1 -1
  39. package/dist/better-auth/status-route.js +14 -3
  40. package/dist/better-auth/users.d.ts +2 -1
  41. package/dist/better-auth/users.js +4 -1
  42. package/dist/create-admin.d.ts +8 -2
  43. package/dist/create-admin.js +31 -2
  44. package/dist/deps.d.ts +1 -0
  45. package/dist/files/attach.js +80 -0
  46. package/dist/files/cleanup-runner.d.ts +8 -0
  47. package/dist/files/cleanup-runner.js +58 -0
  48. package/dist/files/cleanup-schedule.d.ts +28 -0
  49. package/dist/files/cleanup-schedule.js +48 -0
  50. package/dist/files/detect.js +51 -0
  51. package/dist/files/download-url.js +43 -0
  52. package/dist/files/download.js +90 -0
  53. package/dist/files/duration.js +35 -0
  54. package/dist/files/file-cleanup.d.ts +18 -0
  55. package/dist/files/file-cleanup.js +17 -0
  56. package/dist/files/local-files.d.ts +17 -0
  57. package/dist/files/local-files.js +101 -0
  58. package/dist/files/media-types.js +99 -0
  59. package/dist/files/options.d.ts +15 -0
  60. package/dist/files/present.d.ts +15 -0
  61. package/dist/files/present.js +59 -0
  62. package/dist/files/processors.d.ts +15 -0
  63. package/dist/files/processors.js +39 -0
  64. package/dist/files/providers.js +70 -0
  65. package/dist/files/routes.js +26 -0
  66. package/dist/files/runtime.d.ts +3 -0
  67. package/dist/files/runtime.js +37 -0
  68. package/dist/files/signer.js +32 -0
  69. package/dist/files/store.d.ts +37 -0
  70. package/dist/files/ticket.js +87 -0
  71. package/dist/files/upload-body.js +85 -0
  72. package/dist/files/upload.js +124 -0
  73. package/dist/files/uri.js +47 -0
  74. package/dist/functions/define-function.d.ts +71 -0
  75. package/dist/functions/define-function.js +36 -0
  76. package/dist/functions/function-app.d.ts +17 -0
  77. package/dist/functions/function-app.js +19 -0
  78. package/dist/functions/read-json.d.ts +35 -0
  79. package/dist/functions/read-json.js +24 -0
  80. package/dist/functions/records.d.ts +43 -0
  81. package/dist/functions/records.js +31 -0
  82. package/dist/functions/require-env.d.ts +7 -0
  83. package/dist/functions/require-env.js +12 -0
  84. package/dist/functions/routes.js +60 -0
  85. package/dist/functions/verify-signature.d.ts +32 -0
  86. package/dist/functions/verify-signature.js +49 -0
  87. package/dist/index.d.ts +21 -3
  88. package/dist/index.js +13 -3
  89. package/dist/list-service.js +4 -2
  90. package/dist/openapi/resource-paths.js +78 -1
  91. package/dist/openapi/schemas.js +28 -3
  92. package/dist/project-config.d.ts +7 -1
  93. package/dist/project-config.js +37 -1
  94. package/dist/read-service.js +5 -1
  95. package/dist/request-access.d.ts +5 -0
  96. package/dist/request-access.js +9 -4
  97. package/dist/resource-source.d.ts +4 -1
  98. package/dist/routes/batch.js +1 -1
  99. package/dist/routes/collection.js +3 -1
  100. package/dist/routes/record.js +2 -5
  101. package/dist/types.d.ts +25 -5
  102. package/dist/write-pipeline.js +2 -1
  103. package/dist/write-shared.js +6 -3
  104. package/dist/write-update.js +2 -1
  105. package/package.json +6 -4
@@ -51,7 +51,11 @@ const accountChanges = [
51
51
  "/passkey/generate-register-options",
52
52
  "/passkey/verify-registration",
53
53
  "/passkey/update-passkey",
54
- "/passkey/delete-passkey"
54
+ "/passkey/delete-passkey",
55
+ "/invitation/accept",
56
+ "/organization/leave",
57
+ "/organization/transfer-ownership",
58
+ "/organization/delete"
55
59
  ];
56
60
  const staffCannotChange = {
57
61
  code: "STAFF_CANNOT_CHANGE_SIGN_IN",
@@ -1,52 +1,63 @@
1
1
  //#region src/better-auth/staff-log.ts
2
2
  /** Better Auth's model for the log of staff sign-ins: one row per session staff started as someone. */
3
3
  const staffSignInModel = "staffSignIn";
4
- const staffSignInSchema = { [staffSignInModel]: { fields: {
5
- userId: {
6
- type: "string",
7
- required: true
8
- },
9
- userEmail: {
10
- type: "string",
11
- required: true
12
- },
13
- staff: {
14
- type: "string",
15
- required: true
16
- },
17
- staffName: {
18
- type: "string",
19
- required: false
20
- },
21
- staffSubject: {
22
- type: "string",
23
- required: true
24
- },
25
- issuer: {
26
- type: "string",
27
- required: true
28
- },
29
- reason: {
30
- type: "string",
31
- required: true
32
- },
33
- sessionId: {
34
- type: "string",
35
- required: true
36
- },
37
- startedAt: {
38
- type: "date",
39
- required: true
40
- },
41
- expiresAt: {
42
- type: "date",
43
- required: true
44
- },
45
- endedAt: {
46
- type: "date",
47
- required: false
4
+ const staffSignInSchema = { [staffSignInModel]: {
5
+ modelName: "staff_sign_in",
6
+ fields: {
7
+ userId: {
8
+ type: "string",
9
+ required: true,
10
+ fieldName: "user_id"
11
+ },
12
+ userEmail: {
13
+ type: "string",
14
+ required: true,
15
+ fieldName: "user_email"
16
+ },
17
+ staff: {
18
+ type: "string",
19
+ required: true
20
+ },
21
+ staffName: {
22
+ type: "string",
23
+ required: false,
24
+ fieldName: "staff_name"
25
+ },
26
+ staffSubject: {
27
+ type: "string",
28
+ required: true,
29
+ fieldName: "staff_subject"
30
+ },
31
+ issuer: {
32
+ type: "string",
33
+ required: true
34
+ },
35
+ reason: {
36
+ type: "string",
37
+ required: true
38
+ },
39
+ sessionId: {
40
+ type: "string",
41
+ required: true,
42
+ fieldName: "session_id"
43
+ },
44
+ startedAt: {
45
+ type: "date",
46
+ required: true,
47
+ fieldName: "started_at"
48
+ },
49
+ expiresAt: {
50
+ type: "date",
51
+ required: true,
52
+ fieldName: "expires_at"
53
+ },
54
+ endedAt: {
55
+ type: "date",
56
+ required: false,
57
+ fieldName: "ended_at"
58
+ }
48
59
  }
49
- } } };
60
+ } };
50
61
  const entry = (row) => ({
51
62
  id: row.id,
52
63
  user: row.userEmail,
@@ -6,8 +6,8 @@ import { policyRefusal, staffSignInPath } from "./sign-in-enforcement.js";
6
6
  import { staffSignInEmail } from "./sign-in-mail.js";
7
7
  import { logStaffSignIn } from "./staff-log.js";
8
8
  import { APIError, createAuthEndpoint } from "better-auth/api";
9
- import * as z$1 from "zod";
10
9
  import { expireCookie, setSessionCookie } from "better-auth/cookies";
10
+ import * as z$1 from "zod";
11
11
  //#region src/better-auth/staff-sign-in-endpoints.ts
12
12
  const startLifetimeMs = 6e5;
13
13
  const stateCookie = "staff_state";
@@ -1,3 +1,4 @@
1
+ import { membershipRoles } from "./organizations/role-definitions.js";
1
2
  import { effectiveSignInPolicy } from "./sign-in-policy.js";
2
3
  import { readSignInPolicy } from "./policy-store.js";
3
4
  import { hasUsers } from "./users.js";
@@ -8,8 +9,9 @@ import { Hono } from "hono";
8
9
  * `signInMethods`, the ways to sign in the sign-in policy leaves on (`password`, `emailCode`, `passkey`), so it offers
9
10
  * only those; `passwordReset` when reset mail can be sent and passwords are on, so it offers "Forgot password?" only
10
11
  * then; the ids of the `socialProviders` people can sign in with, so it offers "Continue with GitHub" when `github` is
11
- * one; `platformSignIn`, the id and name of the platform's provider when it is one of them; and `staffSignIn`, the
12
- * operator provider's name, when staff can sign in as people.
12
+ * one; `platformSignIn`, the id and name of the platform's provider when it is one of them; `staffSignIn`, the
13
+ * operator provider's name, when staff can sign in as people; and with organizations, who may create them and the roles
14
+ * with their labels, `membership` for those a member can hold, and the roles each one's holders may also give.
13
15
  */
14
16
  const statusRoute = (auth) => {
15
17
  const app = new Hono();
@@ -31,7 +33,16 @@ const statusRoute = (auth) => {
31
33
  passwordReset: auth.passwordReset && policy.password === "allowed",
32
34
  socialProviders,
33
35
  ...platformSignIn && { platformSignIn },
34
- ...staffSignIn && { staffSignIn }
36
+ ...staffSignIn && { staffSignIn },
37
+ ...auth.organizations && { organizations: {
38
+ create: auth.organizations.create,
39
+ roles: auth.roleDefinitions.names.map((name) => ({
40
+ name,
41
+ label: auth.roleDefinitions.labels[name] ?? name,
42
+ membership: membershipRoles(auth.roleDefinitions).includes(name),
43
+ grants: auth.roleDefinitions.grants[name] ?? []
44
+ }))
45
+ } }
35
46
  });
36
47
  });
37
48
  return app;
@@ -28,7 +28,8 @@ export declare const hasUsers: (auth: AdminAuth) => Promise<boolean>;
28
28
  * Creates a user directly in the admin store. Host side only (no HTTP route creates accounts while the store is empty):
29
29
  * the first user is always an `admin`, later ones get `role` (default `user`). The address counts as verified, so the
30
30
  * user can also sign in with an emailed code. `accounts` link the user to sign-in providers up front, so a provider
31
- * signs them in by its own id whatever address it reports; one that signs in another user already is refused.
31
+ * signs them in by its own id whatever address it reports; one that signs in another user already is refused. With
32
+ * organizations, the first user also owns every organization without members, such as those an app's seed made.
32
33
  */
33
34
  export declare const createUser: (auth: AdminAuth, input: NewUser) => Promise<{
34
35
  id: string;
@@ -1,4 +1,5 @@
1
1
  import { parseRoles } from "./parse-roles.js";
2
+ import { ownOrganizationsWithoutMembers } from "./organizations/host.js";
2
3
  //#region src/better-auth/users.ts
3
4
  /** The roles this auth instance allows, `admin` first. */
4
5
  const roleChoices = (auth) => [...auth.roles];
@@ -21,7 +22,8 @@ const checkAccounts = async (auth, accounts) => {
21
22
  * Creates a user directly in the admin store. Host side only (no HTTP route creates accounts while the store is empty):
22
23
  * the first user is always an `admin`, later ones get `role` (default `user`). The address counts as verified, so the
23
24
  * user can also sign in with an emailed code. `accounts` link the user to sign-in providers up front, so a provider
24
- * signs them in by its own id whatever address it reports; one that signs in another user already is refused.
25
+ * signs them in by its own id whatever address it reports; one that signs in another user already is refused. With
26
+ * organizations, the first user also owns every organization without members, such as those an app's seed made.
25
27
  */
26
28
  const createUser = async (auth, input) => {
27
29
  const first = !await hasUsers(auth);
@@ -41,6 +43,7 @@ const createUser = async (auth, input) => {
41
43
  userId: user.id,
42
44
  ...account
43
45
  });
46
+ if (first && auth.organizations) await ownOrganizationsWithoutMembers(auth, user.id);
44
47
  return {
45
48
  id: user.id,
46
49
  email: user.email,
@@ -1,6 +1,8 @@
1
1
  import { AdminAuth } from "./better-auth/create-auth.js";
2
- import { ResourceSource } from "./resource-source.js";
2
+ import { FilesOptions } from "./files/options.js";
3
3
  import { AdminEnv, Authenticator, PageSource, ViewSource } from "./types.js";
4
+ import { ResourceSource } from "./resource-source.js";
5
+ import { FunctionSource } from "./functions/define-function.js";
4
6
  import { AdminOptions } from "./admin-options.js";
5
7
  import { Hono } from "hono";
6
8
  import { UserMenuSource } from "@protobase/schema";
@@ -17,8 +19,12 @@ export type CreateAdminInput = {
17
19
  authenticate: Authenticator;
18
20
  /** A Better Auth instance (see `createAuth`): its handler is mounted at `/api/auth`. */
19
21
  auth?: AdminAuth;
22
+ /** API functions of the app's own, each served at `<systemPath>/functions/<name>` (see `defineFunction`). */
23
+ functions?: Record<string, FunctionSource>;
24
+ /** Where file fields keep their bytes (see `FilesOptions`). */
25
+ files?: FilesOptions;
20
26
  options?: AdminOptions;
21
27
  };
22
28
  /** The admin REST API as a Hono app. It uses only Web APIs, so it runs on Node, Workers and anywhere else Hono does. */
23
- export declare const createAdmin: ({ resources, views, pages, userMenu, db, authenticate, auth, options }: CreateAdminInput) => Hono<AdminEnv, import("hono/types").BlankSchema, "/">;
29
+ export declare const createAdmin: ({ resources, views, pages, userMenu, db, authenticate, auth, functions, files: filesOptions, options }: CreateAdminInput) => Hono<AdminEnv, import("hono/types").BlankSchema, "/">;
24
30
  //#endregion
@@ -4,9 +4,15 @@ import { assistantRecords } from "./assistant/records.js";
4
4
  import { builtInAssistant } from "./assistant/built-in-assistant.js";
5
5
  import { consoleAuditQueue } from "./audit/console-queue.js";
6
6
  import { authBasePath } from "./better-auth/create-auth.js";
7
+ import { organizationRoutes } from "./better-auth/organizations/admin-routes.js";
7
8
  import { statusRoute } from "./better-auth/status-route.js";
8
9
  import { checkShell } from "./check-shell.js";
9
10
  import { checkViews } from "./check-views.js";
11
+ import { createFilesRuntime } from "./files/runtime.js";
12
+ import { fileRoutes } from "./files/routes.js";
13
+ import { toApiFunction } from "./functions/define-function.js";
14
+ import { callerRecords } from "./functions/records.js";
15
+ import { functionRoutes } from "./functions/routes.js";
10
16
  import { runtimeUrl } from "./runtime/runtime-settings.js";
11
17
  import { createMeta } from "./meta.js";
12
18
  import { buildRegistry } from "./registry.js";
@@ -20,9 +26,10 @@ import { Hono } from "hono";
20
26
  import { checkPages } from "@protobase/layout";
21
27
  //#region src/create-admin.ts
22
28
  /** The admin REST API as a Hono app. It uses only Web APIs, so it runs on Node, Workers and anywhere else Hono does. */
23
- const createAdmin = ({ resources, views = [], pages = [], userMenu, db, authenticate, auth, options = {} }) => {
29
+ const createAdmin = ({ resources, views = [], pages = [], userMenu, db, authenticate, auth, functions = {}, files: filesOptions, options = {} }) => {
24
30
  const basePath = options.basePath ?? "/api/v1";
25
31
  const registry = buildRegistry(resources);
32
+ const apiFunctions = Object.fromEntries(Object.entries(functions).map(([name, source]) => [name, toApiFunction(name, source)]));
26
33
  const viewModels = views.map((view) => view.toModel());
27
34
  const userMenuModel = userMenu?.toUserMenuModel();
28
35
  const pageModels = pages.map((entry) => entry.toModel());
@@ -35,6 +42,12 @@ const createAdmin = ({ resources, views = [], pages = [], userMenu, db, authenti
35
42
  const systemPath = basePath.slice(0, basePath.lastIndexOf("/"));
36
43
  const assistant = assistantSettings(options.assistant, globalThis.process?.env ?? {});
37
44
  const runtime = runtimeUrl(options.runtime, globalThis.process?.env ?? {});
45
+ const files = createFilesRuntime({
46
+ ...filesOptions && { options: filesOptions },
47
+ env: globalThis.process?.env ?? {},
48
+ models: registry.entries.map((entry) => entry.model),
49
+ systemPath
50
+ });
38
51
  const deps = {
39
52
  db,
40
53
  registry,
@@ -53,7 +66,8 @@ const createAdmin = ({ resources, views = [], pages = [], userMenu, db, authenti
53
66
  },
54
67
  rowPermissions: (resource) => typeof options.rowPermissions === "object" ? !options.rowPermissions.except.includes(resource) : options.rowPermissions !== false,
55
68
  ...assistant && { assistant: assistant.kind === "external" ? assistant.url : `${systemPath}/assistant` },
56
- ...runtime && { runtime }
69
+ ...runtime && { runtime },
70
+ ...files && { files }
57
71
  };
58
72
  const meta = createMeta(deps);
59
73
  const onError = createErrorHandler(options.onUnhandledError);
@@ -83,6 +97,12 @@ const createAdmin = ({ resources, views = [], pages = [], userMenu, db, authenti
83
97
  app.onError(onError);
84
98
  if (auth) {
85
99
  app.route(authBasePath, statusRoute(auth));
100
+ if (auth.organizations) app.route(authBasePath, organizationRoutes({
101
+ auth,
102
+ audit: deps.audit,
103
+ registry,
104
+ db
105
+ }));
86
106
  app.on(["GET", "POST"], `${authBasePath}/*`, (c) => auth.handler(c.req.raw));
87
107
  }
88
108
  if (assistant?.kind === "built-in") {
@@ -99,7 +119,16 @@ const createAdmin = ({ resources, views = [], pages = [], userMenu, db, authenti
99
119
  report: options.onUnhandledError
100
120
  }));
101
121
  }
122
+ app.route("/", functionRoutes({
123
+ basePath: `${systemPath}/functions`,
124
+ functions: apiFunctions,
125
+ authenticate,
126
+ db,
127
+ records: (session) => callerRecords(deps, session),
128
+ onError
129
+ }));
102
130
  app.route("/", batchRoute(deps, authenticate, meta));
131
+ app.route("/", fileRoutes(deps, authenticate));
103
132
  app.route(basePath, api);
104
133
  app.route(deps.systemPath || "/", system);
105
134
  return app;
package/dist/deps.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import "./types.js";
2
+ import "./files/runtime.js";
2
3
  import "./registry.js";
3
4
  import "./scan-guard.js";
4
5
  import { PageModel } from "@protobase/layout";
@@ -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 };