@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.
- package/dist/assistant/conversations.d.ts +1 -1
- package/dist/assistant/tools.d.ts +1 -1
- package/dist/assistant/turn.d.ts +2 -2
- package/dist/audit/origin.js +12 -0
- package/dist/auth/jwt.d.ts +2 -0
- package/dist/auth/jwt.js +5 -3
- package/dist/batch/reorder.js +1 -1
- package/dist/better-auth/auth-schema.d.ts +4 -2
- package/dist/better-auth/auth-schema.js +18 -8
- package/dist/better-auth/authenticator.d.ts +7 -3
- package/dist/better-auth/authenticator.js +24 -3
- package/dist/better-auth/create-auth.d.ts +683 -10
- package/dist/better-auth/create-auth.js +72 -27
- package/dist/better-auth/organizations/accept-invitation.js +80 -0
- package/dist/better-auth/organizations/active-organization.js +12 -0
- package/dist/better-auth/organizations/admin-routes.js +83 -0
- package/dist/better-auth/organizations/app-roles.js +43 -0
- package/dist/better-auth/organizations/host.d.ts +53 -0
- package/dist/better-auth/organizations/host.js +251 -0
- package/dist/better-auth/organizations/invitation-endpoints.js +133 -0
- package/dist/better-auth/organizations/invitation-mail.js +17 -0
- package/dist/better-auth/organizations/invitation-token.js +27 -0
- package/dist/better-auth/organizations/member-endpoints.js +182 -0
- package/dist/better-auth/organizations/options.d.ts +48 -0
- package/dist/better-auth/organizations/options.js +23 -0
- package/dist/better-auth/organizations/organization-plugin.js +128 -0
- package/dist/better-auth/organizations/protobase-plugin.js +21 -0
- package/dist/better-auth/organizations/role-definitions.d.ts +15 -0
- package/dist/better-auth/organizations/role-definitions.js +56 -0
- package/dist/better-auth/organizations/session-organization.js +68 -0
- package/dist/better-auth/organizations/store.d.ts +19 -0
- package/dist/better-auth/organizations/store.js +73 -0
- package/dist/better-auth/policy-store.js +44 -35
- package/dist/better-auth/snake-case-migration.js +122 -0
- package/dist/better-auth/snake-case-names.js +72 -0
- package/dist/better-auth/staff-checks.js +5 -1
- package/dist/better-auth/staff-log.js +56 -45
- package/dist/better-auth/staff-sign-in-endpoints.js +1 -1
- package/dist/better-auth/status-route.js +14 -3
- package/dist/better-auth/users.d.ts +2 -1
- package/dist/better-auth/users.js +4 -1
- package/dist/create-admin.d.ts +8 -2
- package/dist/create-admin.js +31 -2
- package/dist/deps.d.ts +1 -0
- package/dist/files/attach.js +80 -0
- package/dist/files/cleanup-runner.d.ts +8 -0
- package/dist/files/cleanup-runner.js +58 -0
- package/dist/files/cleanup-schedule.d.ts +28 -0
- package/dist/files/cleanup-schedule.js +48 -0
- package/dist/files/detect.js +51 -0
- package/dist/files/download-url.js +43 -0
- package/dist/files/download.js +90 -0
- package/dist/files/duration.js +35 -0
- package/dist/files/file-cleanup.d.ts +18 -0
- package/dist/files/file-cleanup.js +17 -0
- package/dist/files/local-files.d.ts +17 -0
- package/dist/files/local-files.js +101 -0
- package/dist/files/media-types.js +99 -0
- package/dist/files/options.d.ts +15 -0
- package/dist/files/present.d.ts +15 -0
- package/dist/files/present.js +59 -0
- package/dist/files/processors.d.ts +15 -0
- package/dist/files/processors.js +39 -0
- package/dist/files/providers.js +70 -0
- package/dist/files/routes.js +26 -0
- package/dist/files/runtime.d.ts +3 -0
- package/dist/files/runtime.js +37 -0
- package/dist/files/signer.js +32 -0
- package/dist/files/store.d.ts +37 -0
- package/dist/files/ticket.js +87 -0
- package/dist/files/upload-body.js +85 -0
- package/dist/files/upload.js +124 -0
- package/dist/files/uri.js +47 -0
- package/dist/functions/define-function.d.ts +71 -0
- package/dist/functions/define-function.js +36 -0
- package/dist/functions/function-app.d.ts +17 -0
- package/dist/functions/function-app.js +19 -0
- package/dist/functions/read-json.d.ts +35 -0
- package/dist/functions/read-json.js +24 -0
- package/dist/functions/records.d.ts +43 -0
- package/dist/functions/records.js +31 -0
- package/dist/functions/require-env.d.ts +7 -0
- package/dist/functions/require-env.js +12 -0
- package/dist/functions/routes.js +60 -0
- package/dist/functions/verify-signature.d.ts +32 -0
- package/dist/functions/verify-signature.js +49 -0
- package/dist/index.d.ts +21 -3
- package/dist/index.js +13 -3
- package/dist/list-service.js +4 -2
- package/dist/openapi/resource-paths.js +78 -1
- package/dist/openapi/schemas.js +28 -3
- package/dist/project-config.d.ts +7 -1
- package/dist/project-config.js +37 -1
- package/dist/read-service.js +5 -1
- package/dist/request-access.d.ts +5 -0
- package/dist/request-access.js +9 -4
- package/dist/resource-source.d.ts +4 -1
- package/dist/routes/batch.js +1 -1
- package/dist/routes/collection.js +3 -1
- package/dist/routes/record.js +2 -5
- package/dist/types.d.ts +25 -5
- package/dist/write-pipeline.js +2 -1
- package/dist/write-shared.js +6 -3
- package/dist/write-update.js +2 -1
- package/package.json +6 -4
|
@@ -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]: {
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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;
|
|
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,
|
package/dist/create-admin.d.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { AdminAuth } from "./better-auth/create-auth.js";
|
|
2
|
-
import {
|
|
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
|
package/dist/create-admin.js
CHANGED
|
@@ -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
|
@@ -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,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 };
|