@protobase/server 0.7.0 → 0.8.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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/batch/run-batch.js +2 -1
- 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 +619 -81
- package/dist/better-auth/create-auth.js +64 -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/staff-checks.js +5 -1
- 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/changes/actor.js +12 -0
- package/dist/changes/capture-plan.js +62 -0
- package/dist/changes/capture-schema.d.ts +18 -0
- package/dist/changes/capture-schema.js +65 -0
- package/dist/changes/capture-sql.js +108 -0
- package/dist/changes/coalesce.d.ts +38 -0
- package/dist/changes/coalesce.js +69 -0
- package/dist/changes/events-options.d.ts +10 -0
- package/dist/changes/events-options.js +10 -0
- package/dist/changes/match-events.d.ts +9 -0
- package/dist/changes/match-events.js +55 -0
- package/dist/changes/resource-change.d.ts +27 -0
- package/dist/changes/resource-change.js +43 -0
- package/dist/create-admin.d.ts +11 -2
- package/dist/create-admin.js +33 -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 +26 -3
- package/dist/index.js +17 -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 +10 -1
- package/dist/project-config.js +41 -3
- 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/transactions.js +10 -3
- package/dist/types.d.ts +25 -5
- package/dist/write-pipeline.js +4 -2
- package/dist/write-shared.js +6 -3
- package/dist/write-update.js +2 -1
- package/package.json +6 -4
package/dist/openapi/schemas.js
CHANGED
|
@@ -24,6 +24,26 @@ const hoist = (entry, schema) => {
|
|
|
24
24
|
hoisted
|
|
25
25
|
};
|
|
26
26
|
};
|
|
27
|
+
/** How responses carry a file field. */
|
|
28
|
+
const fileSchema = {
|
|
29
|
+
type: "object",
|
|
30
|
+
required: ["uri"],
|
|
31
|
+
description: "A stored file. `url` downloads it: a signed link valid one to two hours for private files, a permanent one for public files. A value that names no configured provider comes as `{ uri, missing: true }`.",
|
|
32
|
+
properties: {
|
|
33
|
+
uri: {
|
|
34
|
+
type: "string",
|
|
35
|
+
description: "The stored value, `{provider}:{path}?name=…&size=…`; send it back unchanged to keep the file"
|
|
36
|
+
},
|
|
37
|
+
name: { type: "string" },
|
|
38
|
+
type: {
|
|
39
|
+
type: "string",
|
|
40
|
+
description: "Detected from the content at upload"
|
|
41
|
+
},
|
|
42
|
+
size: { type: "integer" },
|
|
43
|
+
url: { type: "string" },
|
|
44
|
+
missing: { type: "boolean" }
|
|
45
|
+
}
|
|
46
|
+
};
|
|
27
47
|
const only = (schema, names) => ({
|
|
28
48
|
...schema,
|
|
29
49
|
properties: Object.fromEntries(names.filter((name) => schema.properties?.[name]).map((name) => [name, schema.properties[name]])),
|
|
@@ -40,7 +60,10 @@ const resourceSchemas = (access) => {
|
|
|
40
60
|
const output = hoist(entry, convert(entry, "output"));
|
|
41
61
|
const input = hoist(entry, convert(entry, "input"));
|
|
42
62
|
const record = only(output.schema, readable);
|
|
43
|
-
for (const field of Object.values(model.fields))
|
|
63
|
+
for (const field of Object.values(model.fields)) {
|
|
64
|
+
if (field.file && record.properties?.[field.name]) record.properties[field.name] = field.nullable ? { anyOf: [{ $ref: "#/components/schemas/File" }, { type: "null" }] } : { $ref: "#/components/schemas/File" };
|
|
65
|
+
if (field.readOnly && record.properties?.[field.name]) record.properties[field.name].readOnly = true;
|
|
66
|
+
}
|
|
44
67
|
record.properties.etag = {
|
|
45
68
|
type: "string",
|
|
46
69
|
readOnly: true,
|
|
@@ -70,6 +93,7 @@ const resourceSchemas = (access) => {
|
|
|
70
93
|
for (const field of names) {
|
|
71
94
|
const fieldModel = writable.fields[field];
|
|
72
95
|
if (fieldModel?.default && "db" in fieldModel.default && properties[field]) properties[field].description = "Defaults to a value the database generates";
|
|
96
|
+
if (fieldModel?.file && properties[field]) properties[field].description = `The \`value\` of \`POST /${entry.name}:upload?field=${field}\`, or the stored \`uri\` to keep the file`;
|
|
73
97
|
}
|
|
74
98
|
const required = input.schema.required?.filter((field) => names.includes(field) && !optional.has(field)) ?? [];
|
|
75
99
|
return {
|
|
@@ -86,8 +110,9 @@ const resourceSchemas = (access) => {
|
|
|
86
110
|
[name]: record,
|
|
87
111
|
...resolved.operations.create && { [`${name}Create`]: create },
|
|
88
112
|
...resolved.operations.update && { [`${name}Update`]: update },
|
|
89
|
-
...output.hoisted
|
|
113
|
+
...output.hoisted,
|
|
114
|
+
...Object.values(model.fields).some((field) => field.file) && { File: fileSchema }
|
|
90
115
|
};
|
|
91
116
|
};
|
|
92
117
|
//#endregion
|
|
93
|
-
export { resourceSchemas };
|
|
118
|
+
export { fileSchema, resourceSchemas };
|
package/dist/project-config.d.ts
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
import { AdminAuth } from "./better-auth/create-auth.js";
|
|
2
|
+
import { EventsOptions } from "./changes/events-options.js";
|
|
3
|
+
import { FilesOptions } from "./files/options.js";
|
|
2
4
|
import { Authenticator } from "./types.js";
|
|
5
|
+
import { FunctionSource } from "./functions/define-function.js";
|
|
3
6
|
import { AdminOptions } from "./admin-options.js";
|
|
4
7
|
import { Db } from "@protobase/query";
|
|
5
8
|
//#region src/project-config.d.ts
|
|
@@ -15,13 +18,19 @@ export type ProjectConfig = {
|
|
|
15
18
|
authenticate?: Authenticator;
|
|
16
19
|
/** A Better Auth instance; createAdmin mounts its routes under /api/auth. */
|
|
17
20
|
auth?: AdminAuth;
|
|
21
|
+
/** The API functions, each served at `/api/functions/<name>`; default: one per file in ./functions. */
|
|
22
|
+
functions?: Record<string, FunctionSource>;
|
|
23
|
+
/** Where file fields keep their bytes: providers by name, retention and the signing secret (see `FilesOptions`). */
|
|
24
|
+
files?: FilesOptions;
|
|
25
|
+
/** Where the changes that resources' events are raised from are captured (see `EventsOptions`). */
|
|
26
|
+
events?: EventsOptions;
|
|
18
27
|
options?: AdminOptions;
|
|
19
28
|
};
|
|
20
29
|
/** Typed identity, for `export default defineConfig({ extends: [...], config, auth, ... })` in `protobase.config.ts`. */
|
|
21
30
|
export declare const defineConfig: (config: ProjectConfig) => ProjectConfig;
|
|
22
31
|
/**
|
|
23
32
|
* One config from several, each after the ones it `extends`, the last being the app's own. Named things are all kept:
|
|
24
|
-
* resources, views and pages of the config modules, assistant tools; one name defined by two configs is an error naming
|
|
33
|
+
* resources, views and pages of the config modules, functions, assistant tools, file providers; one name defined by two configs is an error naming
|
|
25
34
|
* both. Write hooks run in the same order. Every other value is the last config's that sets it, so the app's own wins.
|
|
26
35
|
*/
|
|
27
36
|
export declare const mergeConfig: (...configs: ProjectConfig[]) => ProjectConfig;
|
package/dist/project-config.js
CHANGED
|
@@ -72,6 +72,38 @@ const mergedAssistant = (named) => {
|
|
|
72
72
|
...tools.length > 0 && { tools: tools.map(({ tool }) => tool) }
|
|
73
73
|
};
|
|
74
74
|
};
|
|
75
|
+
const mergedFunctions = (named) => {
|
|
76
|
+
const all = named.flatMap(({ config, label }) => Object.entries(config.functions ?? {}).map(([name, value]) => ({
|
|
77
|
+
name,
|
|
78
|
+
value,
|
|
79
|
+
label
|
|
80
|
+
})));
|
|
81
|
+
if (all.length === 0) return void 0;
|
|
82
|
+
checkUnique("The function", all);
|
|
83
|
+
return Object.fromEntries(all.map(({ name, value }) => [name, value]));
|
|
84
|
+
};
|
|
85
|
+
const mergedFiles = (named) => {
|
|
86
|
+
const all = named.flatMap(({ config, label }) => config.files ? [{
|
|
87
|
+
files: config.files,
|
|
88
|
+
label
|
|
89
|
+
}] : []);
|
|
90
|
+
if (all.length === 0) return void 0;
|
|
91
|
+
const providers = all.flatMap(({ files, label }) => Object.entries(files.providers ?? {}).map(([name, value]) => ({
|
|
92
|
+
name,
|
|
93
|
+
value,
|
|
94
|
+
label
|
|
95
|
+
})));
|
|
96
|
+
checkUnique("The file provider", providers);
|
|
97
|
+
const { retention, secret } = lastSet(all.map(({ files }) => ({
|
|
98
|
+
retention: files.retention,
|
|
99
|
+
secret: files.secret
|
|
100
|
+
})));
|
|
101
|
+
return {
|
|
102
|
+
...providers.length > 0 && { providers: Object.fromEntries(providers.map(({ name, value }) => [name, value])) },
|
|
103
|
+
...retention !== void 0 && { retention },
|
|
104
|
+
...secret !== void 0 && { secret }
|
|
105
|
+
};
|
|
106
|
+
};
|
|
75
107
|
const mergedOptions = (named) => {
|
|
76
108
|
const all = named.flatMap(({ config }) => config.options ? [config.options] : []);
|
|
77
109
|
if (all.length === 0) return void 0;
|
|
@@ -85,17 +117,20 @@ const mergedOptions = (named) => {
|
|
|
85
117
|
};
|
|
86
118
|
/**
|
|
87
119
|
* One config from several, each after the ones it `extends`, the last being the app's own. Named things are all kept:
|
|
88
|
-
* resources, views and pages of the config modules, assistant tools; one name defined by two configs is an error naming
|
|
120
|
+
* resources, views and pages of the config modules, functions, assistant tools, file providers; one name defined by two configs is an error naming
|
|
89
121
|
* both. Write hooks run in the same order. Every other value is the last config's that sets it, so the app's own wins.
|
|
90
122
|
*/
|
|
91
123
|
const mergeConfig = (...configs) => {
|
|
92
124
|
const named = configs.flatMap((config, index) => flatten(config, config.name ?? `config ${index + 1}`));
|
|
93
|
-
const { db, authenticate, auth } = lastSet(named.map(({ config }) => ({
|
|
125
|
+
const { db, authenticate, auth, events } = lastSet(named.map(({ config }) => ({
|
|
94
126
|
db: config.db,
|
|
95
127
|
authenticate: config.authenticate,
|
|
96
|
-
auth: config.auth
|
|
128
|
+
auth: config.auth,
|
|
129
|
+
events: config.events
|
|
97
130
|
})));
|
|
98
131
|
const module = mergedModule(named);
|
|
132
|
+
const functions = mergedFunctions(named);
|
|
133
|
+
const files = mergedFiles(named);
|
|
99
134
|
const options = mergedOptions(named);
|
|
100
135
|
const name = configs.at(-1)?.name;
|
|
101
136
|
return {
|
|
@@ -104,6 +139,9 @@ const mergeConfig = (...configs) => {
|
|
|
104
139
|
...db && { db },
|
|
105
140
|
...authenticate && { authenticate },
|
|
106
141
|
...auth && { auth },
|
|
142
|
+
...functions && { functions },
|
|
143
|
+
...files && { files },
|
|
144
|
+
...events && { events },
|
|
107
145
|
...options && { options }
|
|
108
146
|
};
|
|
109
147
|
};
|
package/dist/read-service.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { notFound } from "./problem.js";
|
|
2
|
+
import { presentFiles } from "./files/present.js";
|
|
2
3
|
import { pickFields } from "./rows.js";
|
|
3
4
|
import { fetchRecord } from "./records.js";
|
|
4
5
|
import { tenantScope } from "./tenant.js";
|
|
@@ -59,7 +60,10 @@ const readRecord = async (deps, entry, session, key, deleted = "hide") => {
|
|
|
59
60
|
};
|
|
60
61
|
});
|
|
61
62
|
if (!found) throw notFound(`No ${entry.name} with this key`);
|
|
62
|
-
return
|
|
63
|
+
return {
|
|
64
|
+
...found,
|
|
65
|
+
record: (await presentFiles(access, [found.record]))[0]
|
|
66
|
+
};
|
|
63
67
|
};
|
|
64
68
|
//#endregion
|
|
65
69
|
export { fetchReadable, fieldModes, readRecord };
|
package/dist/request-access.js
CHANGED
|
@@ -3,15 +3,19 @@ import { checkedFilter } from "./filter-input.js";
|
|
|
3
3
|
import { tenantScope } from "./tenant.js";
|
|
4
4
|
import { checkFilter, resolveAccess, restrictModel } from "@protobase/schema";
|
|
5
5
|
//#region src/request-access.ts
|
|
6
|
+
const organizationOf = (session) => session.organization ? { organization: session.organization } : {};
|
|
6
7
|
const withoutSensitive = (model) => ({
|
|
7
8
|
...model,
|
|
8
9
|
fields: Object.fromEntries(Object.entries(model.fields).filter(([, field]) => !field.sensitive))
|
|
9
10
|
});
|
|
10
11
|
const requestAccess = async (deps, entry, session) => {
|
|
11
|
-
const resolved = await resolveAccess(entry.source, {
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
12
|
+
const resolved = await resolveAccess(entry.source, {
|
|
13
|
+
user: {
|
|
14
|
+
id: session.user.id,
|
|
15
|
+
roles: session.user.roles
|
|
16
|
+
},
|
|
17
|
+
...organizationOf(session)
|
|
18
|
+
}, deps.accessOptions);
|
|
15
19
|
const readable = restrictModel(entry.model, resolved);
|
|
16
20
|
return {
|
|
17
21
|
entry,
|
|
@@ -86,6 +90,7 @@ const recordDecision = async (access, operation, record, input) => {
|
|
|
86
90
|
id: session.user.id,
|
|
87
91
|
roles: session.user.roles
|
|
88
92
|
},
|
|
93
|
+
...organizationOf(session),
|
|
89
94
|
model: full,
|
|
90
95
|
operation,
|
|
91
96
|
record
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { PageDefinition } from "@protobase/layout";
|
|
2
|
-
import { AccessSource, UserMenuSource, ViewModel } from "@protobase/schema";
|
|
2
|
+
import { AccessSource, FileProcessor, UserMenuSource, ViewModel } from "@protobase/schema";
|
|
3
3
|
import { z } from "zod";
|
|
4
4
|
//#region src/resource-source.d.ts
|
|
5
5
|
type FieldSource = {
|
|
@@ -7,6 +7,9 @@ type FieldSource = {
|
|
|
7
7
|
meta: {
|
|
8
8
|
readOnly: boolean;
|
|
9
9
|
nullable: boolean;
|
|
10
|
+
file?: {
|
|
11
|
+
derive?: Record<string, FileProcessor>;
|
|
12
|
+
};
|
|
10
13
|
};
|
|
11
14
|
};
|
|
12
15
|
/** What the server needs from a resource builder; every `resource(...)` chain satisfies it. */
|
package/dist/routes/batch.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { runWrite } from "../write-pipeline.js";
|
|
2
|
-
import { facets, numericHistogram, timeSeries } from "../aggregate-service.js";
|
|
3
2
|
import { listRecords } from "../list-service.js";
|
|
3
|
+
import { facets, numericHistogram, timeSeries } from "../aggregate-service.js";
|
|
4
|
+
import { uploadFile } from "../files/upload.js";
|
|
4
5
|
import { returnPreference } from "./preferences.js";
|
|
5
6
|
import { resolveTarget, unknownAction } from "./target.js";
|
|
6
7
|
import { Hono } from "hono";
|
|
@@ -31,6 +32,7 @@ const collectionRoutes = (deps) => {
|
|
|
31
32
|
app.post("/:target", async (c) => {
|
|
32
33
|
const { entry, action } = resolveTarget(deps, c.req.param("target"));
|
|
33
34
|
const session = c.get("session");
|
|
35
|
+
if (action === "upload") return c.json(await uploadFile(deps, entry, session, c.req.raw), 201);
|
|
34
36
|
const body = await c.req.json();
|
|
35
37
|
if (action === "search") {
|
|
36
38
|
const { body: page, warning } = await listRecords(deps, entry, session, body, searchFilterLength);
|
package/dist/routes/record.js
CHANGED
|
@@ -3,13 +3,13 @@ import { keyFromPath } from "../key.js";
|
|
|
3
3
|
import { pickFields } from "../rows.js";
|
|
4
4
|
import { readRecord } from "../read-service.js";
|
|
5
5
|
import { runWrite } from "../write-pipeline.js";
|
|
6
|
+
import { auditOrigin } from "../audit/origin.js";
|
|
6
7
|
import { deletedRows, selectedFields } from "../params.js";
|
|
7
8
|
import { returnPreference } from "./preferences.js";
|
|
8
9
|
import { resolveTarget } from "./target.js";
|
|
9
10
|
import { revealField } from "../reveal-service.js";
|
|
10
11
|
import { Hono } from "hono";
|
|
11
12
|
//#region src/routes/record.ts
|
|
12
|
-
const headerValue = (name, value) => value === void 0 ? {} : { [name]: value };
|
|
13
13
|
const recordRoutes = (deps) => {
|
|
14
14
|
const app = new Hono();
|
|
15
15
|
app.get("/:resource/:key", async (c) => {
|
|
@@ -31,10 +31,7 @@ const recordRoutes = (deps) => {
|
|
|
31
31
|
const revealed = await revealField(deps, entry, c.get("session"), {
|
|
32
32
|
key: keyFromPath(c, entry, ":reveal"),
|
|
33
33
|
field: typeof body === "object" && body !== null ? body.field : void 0,
|
|
34
|
-
origin:
|
|
35
|
-
...headerValue("userAgent", c.req.header("user-agent")),
|
|
36
|
-
...headerValue("forwardedFor", c.req.header("x-forwarded-for"))
|
|
37
|
-
}
|
|
34
|
+
origin: auditOrigin(c.req.raw.headers)
|
|
38
35
|
});
|
|
39
36
|
return c.json(revealed, 200, { "Cache-Control": "no-store" });
|
|
40
37
|
};
|
package/dist/transactions.js
CHANGED
|
@@ -1,8 +1,15 @@
|
|
|
1
1
|
import { sql } from "kysely";
|
|
2
2
|
//#region src/transactions.ts
|
|
3
|
-
/**
|
|
4
|
-
|
|
5
|
-
|
|
3
|
+
/**
|
|
4
|
+
* One transaction whose statements Postgres cancels after `timeoutMs` (SET LOCAL statement_timeout), with `settings`
|
|
5
|
+
* set for it as well, all in one statement.
|
|
6
|
+
*/
|
|
7
|
+
const writeTransaction = (db, timeoutMs, run, settings = {}) => db.transaction().execute(async (trx) => {
|
|
8
|
+
const all = Object.entries({
|
|
9
|
+
statement_timeout: String(timeoutMs),
|
|
10
|
+
...settings
|
|
11
|
+
});
|
|
12
|
+
await sql`select ${sql.join(all.map(([name, value]) => sql`set_config(${name}, ${value}, true)`))}`.execute(trx);
|
|
6
13
|
return run(trx);
|
|
7
14
|
});
|
|
8
15
|
/** Same, and Postgres refuses any write inside it. */
|
package/dist/types.d.ts
CHANGED
|
@@ -8,9 +8,14 @@ export type User = {
|
|
|
8
8
|
id: string | number;
|
|
9
9
|
roles: string[];
|
|
10
10
|
};
|
|
11
|
+
/** The organization a request works in, with organizations on: the same value as `tenant`. */
|
|
12
|
+
export type SessionOrganization = {
|
|
13
|
+
id: TenantValue;
|
|
14
|
+
};
|
|
11
15
|
export type Session = {
|
|
12
16
|
user: User;
|
|
13
17
|
tenant?: TenantValue;
|
|
18
|
+
organization?: SessionOrganization;
|
|
14
19
|
};
|
|
15
20
|
/** Resolves the caller of a request, or throws a 401 problem (see `unauthorized`). */
|
|
16
21
|
export type Authenticator = (request: Request) => Promise<Session> | Session;
|
|
@@ -32,8 +37,13 @@ export type WriteEvent = {
|
|
|
32
37
|
};
|
|
33
38
|
/** Extension point of the write pipeline: a hook that throws rolls the write back. */
|
|
34
39
|
export type PipelineHook = (event: WriteEvent, trx: Db) => Promise<void>;
|
|
40
|
+
/** Where a request came from, as its headers say; proxies set `forwardedFor`, so it is only as trustworthy as they are. */
|
|
41
|
+
export type AuditOrigin = {
|
|
42
|
+
userAgent?: string;
|
|
43
|
+
forwardedFor?: string;
|
|
44
|
+
};
|
|
35
45
|
/** What happened, by whom, to which value. It never carries the value itself. */
|
|
36
|
-
export type
|
|
46
|
+
export type FieldRevealedEvent = {
|
|
37
47
|
type: 'field.revealed';
|
|
38
48
|
/** ISO 8601, when the server answered. */
|
|
39
49
|
at: string;
|
|
@@ -47,12 +57,22 @@ export type AuditEvent = {
|
|
|
47
57
|
/** The record's key as its URL has it (composite keys joined with a comma). */
|
|
48
58
|
recordKey: string;
|
|
49
59
|
field: string;
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
60
|
+
origin: AuditOrigin;
|
|
61
|
+
};
|
|
62
|
+
/** Someone with a global role started working in an organization they are not a member of. */
|
|
63
|
+
export type OrganizationEnteredEvent = {
|
|
64
|
+
type: 'organization.entered';
|
|
65
|
+
/** ISO 8601, when the server answered. */
|
|
66
|
+
at: string;
|
|
67
|
+
/** Their global roles, which let them in. */
|
|
68
|
+
actor: {
|
|
69
|
+
id: string | number;
|
|
70
|
+
roles: string[];
|
|
54
71
|
};
|
|
72
|
+
organization: TenantValue;
|
|
73
|
+
origin: AuditOrigin;
|
|
55
74
|
};
|
|
75
|
+
export type AuditEvent = FieldRevealedEvent | OrganizationEnteredEvent;
|
|
56
76
|
/**
|
|
57
77
|
* Where audit events go. `publish` resolves once the event is accepted; when it rejects, the request that caused the
|
|
58
78
|
* event fails, so nothing is revealed without its event. A real queue replaces the console one through
|
package/dist/write-pipeline.js
CHANGED
|
@@ -3,6 +3,8 @@ import { deleteRecord, fetchRecord, insertRecord, restoreRecord } from "./record
|
|
|
3
3
|
import { tenantScope } from "./tenant.js";
|
|
4
4
|
import { requestAccess, requireOperation, targetFilter, writableFields } from "./request-access.js";
|
|
5
5
|
import { writeTransaction } from "./transactions.js";
|
|
6
|
+
import { attachFiles } from "./files/attach.js";
|
|
7
|
+
import { actorSettings } from "./changes/actor.js";
|
|
6
8
|
import { validateBody } from "./validate-body.js";
|
|
7
9
|
import { assertWritten, checkPrecondition, decide, denied, firstKeyField, missing, notifier, shown } from "./write-shared.js";
|
|
8
10
|
import { applyUpdate, planUpdate } from "./write-update.js";
|
|
@@ -22,7 +24,7 @@ const create = async (trx, deps, access, body) => {
|
|
|
22
24
|
const tenantValues = full.tenant && scope ? { [full.tenant]: scope.tenantValue } : {};
|
|
23
25
|
const preset = typeof body === "object" && body !== null && !Array.isArray(body) && writableFields(access, "create").includes(full.owner ?? "") ? withOwner(access, body) : body;
|
|
24
26
|
const row = {
|
|
25
|
-
...withOwner(access, validateBody(access, "create", preset, tenantValues)),
|
|
27
|
+
...await attachFiles(access, withOwner(access, validateBody(access, "create", preset, tenantValues))),
|
|
26
28
|
...tenantValues
|
|
27
29
|
};
|
|
28
30
|
const filter = await decide(access, "create", void 0, row);
|
|
@@ -88,7 +90,7 @@ const writeIn = async (trx, deps, access, request) => {
|
|
|
88
90
|
const runWrite = async (deps, entry, session, request) => {
|
|
89
91
|
const access = await requestAccess(deps, entry, session);
|
|
90
92
|
requireOperation(access, request.operation === "undelete" ? "update" : request.operation);
|
|
91
|
-
return writeTransaction(deps.db, deps.statementTimeoutMs, (trx) => writeIn(trx, deps, access, request));
|
|
93
|
+
return writeTransaction(deps.db, deps.statementTimeoutMs, (trx) => writeIn(trx, deps, access, request), actorSettings(deps, session));
|
|
92
94
|
};
|
|
93
95
|
//#endregion
|
|
94
96
|
export { runWrite, writeIn };
|
package/dist/write-shared.js
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import { forbidden, notFound, preconditionFailed, preconditionRequired } from "./problem.js";
|
|
2
|
+
import { presentFiles } from "./files/present.js";
|
|
2
3
|
import { ifMatchSatisfied } from "./etag.js";
|
|
3
4
|
import { pickFields } from "./rows.js";
|
|
4
5
|
import { fetchRecord } from "./records.js";
|
|
5
6
|
import { tenantScope } from "./tenant.js";
|
|
6
7
|
import { andFilters, recordDecision } from "./request-access.js";
|
|
8
|
+
import { scheduleRemovedFiles } from "./files/attach.js";
|
|
7
9
|
//#region src/write-shared.ts
|
|
8
10
|
const missing = (entry) => notFound(`No ${entry.name} with this key`);
|
|
9
11
|
const checkPrecondition = (current, ifMatch, required) => {
|
|
@@ -30,9 +32,9 @@ const assertWritten = async (trx, access, written, operation, ...filters) => {
|
|
|
30
32
|
fields: [firstKeyField(access)]
|
|
31
33
|
})) throw denied(access.entry, `leave in that state (${operation})`);
|
|
32
34
|
};
|
|
33
|
-
/** The caller sees the readable fields only; hooks get the whole record. */
|
|
34
|
-
const shown = (access, written) => ({
|
|
35
|
-
record: pickFields(written.record, Object.keys(access.model.fields)),
|
|
35
|
+
/** The caller sees the readable fields only, files as objects with their URL; hooks get the whole record. */
|
|
36
|
+
const shown = async (access, written) => ({
|
|
37
|
+
record: (await presentFiles(access, [pickFields(written.record, Object.keys(access.model.fields))]))[0],
|
|
36
38
|
etag: written.etag
|
|
37
39
|
});
|
|
38
40
|
/** Runs the pipeline hooks for one write, inside the transaction. */
|
|
@@ -47,6 +49,7 @@ const notifier = (deps, access) => async (trx, kind, before, after) => {
|
|
|
47
49
|
...after && { after }
|
|
48
50
|
};
|
|
49
51
|
for (const hook of deps.hooks) await hook(event, trx);
|
|
52
|
+
await scheduleRemovedFiles(access, kind, before, after);
|
|
50
53
|
};
|
|
51
54
|
//#endregion
|
|
52
55
|
export { assertWritten, checkPrecondition, decide, denied, firstKeyField, keyOf, missing, notifier, shown };
|
package/dist/write-update.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { fetchRecord, updateRecord } from "./records.js";
|
|
2
2
|
import { tenantScope } from "./tenant.js";
|
|
3
3
|
import { targetFilter } from "./request-access.js";
|
|
4
|
+
import { attachFiles } from "./files/attach.js";
|
|
4
5
|
import { validateBody } from "./validate-body.js";
|
|
5
6
|
import { assertWritten, checkPrecondition, decide, denied, firstKeyField, missing, notifier } from "./write-shared.js";
|
|
6
7
|
//#region src/write-update.ts
|
|
@@ -20,7 +21,7 @@ const planUpdate = async (trx, access, key, body, ifMatch, required = true) => {
|
|
|
20
21
|
});
|
|
21
22
|
if (!current) throw missing(entry);
|
|
22
23
|
checkPrecondition(current, ifMatch, required);
|
|
23
|
-
const values = validateBody(access, "update", body, current.record);
|
|
24
|
+
const values = await attachFiles(access, validateBody(access, "update", body, current.record), current.record);
|
|
24
25
|
const filter = Object.keys(values).length === 0 ? void 0 : await decide(access, "update", current.record, values);
|
|
25
26
|
if (filter && !await fetchRecord(trx, entry, key, tenant, {
|
|
26
27
|
filter,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@protobase/server",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.1",
|
|
4
4
|
"description": "The Protobase REST API as a Hono app, with Better Auth sign-in.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"repository": {
|
|
@@ -30,13 +30,15 @@
|
|
|
30
30
|
},
|
|
31
31
|
"dependencies": {
|
|
32
32
|
"@better-auth/passkey": "^1.7.7",
|
|
33
|
-
"@protobase/layout": "0.
|
|
34
|
-
"@protobase/query": "0.
|
|
35
|
-
"@protobase/schema": "0.
|
|
33
|
+
"@protobase/layout": "0.8.1",
|
|
34
|
+
"@protobase/query": "0.8.1",
|
|
35
|
+
"@protobase/schema": "0.8.1",
|
|
36
36
|
"@scalar/hono-api-reference": "^0.12.10",
|
|
37
37
|
"@simplewebauthn/server": "^13.3.1",
|
|
38
38
|
"better-auth": "^1.7.7",
|
|
39
|
+
"file-type": "^22.1.1",
|
|
39
40
|
"hono": "^4.13.13",
|
|
41
|
+
"image-size": "^2.0.4",
|
|
40
42
|
"jose": "^6.2.12",
|
|
41
43
|
"kysely": "^0.29.6",
|
|
42
44
|
"nodemailer": "^10.0.16",
|