@protobase/server 0.7.0 → 0.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (114) hide show
  1. package/dist/assistant/conversations.d.ts +1 -1
  2. package/dist/assistant/tools.d.ts +1 -1
  3. package/dist/assistant/turn.d.ts +2 -2
  4. package/dist/audit/origin.js +12 -0
  5. package/dist/auth/jwt.d.ts +2 -0
  6. package/dist/auth/jwt.js +5 -3
  7. package/dist/batch/reorder.js +1 -1
  8. package/dist/batch/run-batch.js +2 -1
  9. package/dist/better-auth/authenticator.d.ts +7 -3
  10. package/dist/better-auth/authenticator.js +24 -3
  11. package/dist/better-auth/create-auth.d.ts +619 -81
  12. package/dist/better-auth/create-auth.js +64 -27
  13. package/dist/better-auth/organizations/accept-invitation.js +80 -0
  14. package/dist/better-auth/organizations/active-organization.js +12 -0
  15. package/dist/better-auth/organizations/admin-routes.js +83 -0
  16. package/dist/better-auth/organizations/app-roles.js +43 -0
  17. package/dist/better-auth/organizations/host.d.ts +53 -0
  18. package/dist/better-auth/organizations/host.js +251 -0
  19. package/dist/better-auth/organizations/invitation-endpoints.js +133 -0
  20. package/dist/better-auth/organizations/invitation-mail.js +17 -0
  21. package/dist/better-auth/organizations/invitation-token.js +27 -0
  22. package/dist/better-auth/organizations/member-endpoints.js +182 -0
  23. package/dist/better-auth/organizations/options.d.ts +48 -0
  24. package/dist/better-auth/organizations/options.js +23 -0
  25. package/dist/better-auth/organizations/organization-plugin.js +128 -0
  26. package/dist/better-auth/organizations/protobase-plugin.js +21 -0
  27. package/dist/better-auth/organizations/role-definitions.d.ts +15 -0
  28. package/dist/better-auth/organizations/role-definitions.js +56 -0
  29. package/dist/better-auth/organizations/session-organization.js +68 -0
  30. package/dist/better-auth/organizations/store.d.ts +19 -0
  31. package/dist/better-auth/organizations/store.js +73 -0
  32. package/dist/better-auth/staff-checks.js +5 -1
  33. package/dist/better-auth/staff-sign-in-endpoints.js +1 -1
  34. package/dist/better-auth/status-route.js +14 -3
  35. package/dist/better-auth/users.d.ts +2 -1
  36. package/dist/better-auth/users.js +4 -1
  37. package/dist/changes/actor.js +12 -0
  38. package/dist/changes/capture-plan.js +62 -0
  39. package/dist/changes/capture-schema.d.ts +18 -0
  40. package/dist/changes/capture-schema.js +65 -0
  41. package/dist/changes/capture-sql.js +108 -0
  42. package/dist/changes/coalesce.d.ts +38 -0
  43. package/dist/changes/coalesce.js +69 -0
  44. package/dist/changes/events-options.d.ts +10 -0
  45. package/dist/changes/events-options.js +10 -0
  46. package/dist/changes/match-events.d.ts +9 -0
  47. package/dist/changes/match-events.js +55 -0
  48. package/dist/changes/resource-change.d.ts +27 -0
  49. package/dist/changes/resource-change.js +43 -0
  50. package/dist/create-admin.d.ts +11 -2
  51. package/dist/create-admin.js +33 -2
  52. package/dist/deps.d.ts +1 -0
  53. package/dist/files/attach.js +80 -0
  54. package/dist/files/cleanup-runner.d.ts +8 -0
  55. package/dist/files/cleanup-runner.js +58 -0
  56. package/dist/files/cleanup-schedule.d.ts +28 -0
  57. package/dist/files/cleanup-schedule.js +48 -0
  58. package/dist/files/detect.js +51 -0
  59. package/dist/files/download-url.js +43 -0
  60. package/dist/files/download.js +90 -0
  61. package/dist/files/duration.js +35 -0
  62. package/dist/files/file-cleanup.d.ts +18 -0
  63. package/dist/files/file-cleanup.js +17 -0
  64. package/dist/files/local-files.d.ts +17 -0
  65. package/dist/files/local-files.js +101 -0
  66. package/dist/files/media-types.js +99 -0
  67. package/dist/files/options.d.ts +15 -0
  68. package/dist/files/present.d.ts +15 -0
  69. package/dist/files/present.js +59 -0
  70. package/dist/files/processors.d.ts +15 -0
  71. package/dist/files/processors.js +39 -0
  72. package/dist/files/providers.js +70 -0
  73. package/dist/files/routes.js +26 -0
  74. package/dist/files/runtime.d.ts +3 -0
  75. package/dist/files/runtime.js +37 -0
  76. package/dist/files/signer.js +32 -0
  77. package/dist/files/store.d.ts +37 -0
  78. package/dist/files/ticket.js +87 -0
  79. package/dist/files/upload-body.js +85 -0
  80. package/dist/files/upload.js +124 -0
  81. package/dist/files/uri.js +47 -0
  82. package/dist/functions/define-function.d.ts +71 -0
  83. package/dist/functions/define-function.js +36 -0
  84. package/dist/functions/function-app.d.ts +17 -0
  85. package/dist/functions/function-app.js +19 -0
  86. package/dist/functions/read-json.d.ts +35 -0
  87. package/dist/functions/read-json.js +24 -0
  88. package/dist/functions/records.d.ts +43 -0
  89. package/dist/functions/records.js +31 -0
  90. package/dist/functions/require-env.d.ts +7 -0
  91. package/dist/functions/require-env.js +12 -0
  92. package/dist/functions/routes.js +60 -0
  93. package/dist/functions/verify-signature.d.ts +32 -0
  94. package/dist/functions/verify-signature.js +49 -0
  95. package/dist/index.d.ts +26 -3
  96. package/dist/index.js +17 -3
  97. package/dist/list-service.js +4 -2
  98. package/dist/openapi/resource-paths.js +78 -1
  99. package/dist/openapi/schemas.js +28 -3
  100. package/dist/project-config.d.ts +10 -1
  101. package/dist/project-config.js +41 -3
  102. package/dist/read-service.js +5 -1
  103. package/dist/request-access.d.ts +5 -0
  104. package/dist/request-access.js +9 -4
  105. package/dist/resource-source.d.ts +4 -1
  106. package/dist/routes/batch.js +1 -1
  107. package/dist/routes/collection.js +3 -1
  108. package/dist/routes/record.js +2 -5
  109. package/dist/transactions.js +10 -3
  110. package/dist/types.d.ts +25 -5
  111. package/dist/write-pipeline.js +4 -2
  112. package/dist/write-shared.js +6 -3
  113. package/dist/write-update.js +2 -1
  114. package/package.json +6 -4
@@ -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)) if (field.readOnly && record.properties?.[field.name]) record.properties[field.name].readOnly = true;
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 };
@@ -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;
@@ -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
  };
@@ -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 found;
63
+ return {
64
+ ...found,
65
+ record: (await presentFiles(access, [found.record]))[0]
66
+ };
63
67
  };
64
68
  //#endregion
65
69
  export { fetchReadable, fieldModes, readRecord };
@@ -0,0 +1,5 @@
1
+ import "./types.js";
2
+ import "./registry.js";
3
+ import "./deps.js";
4
+ import { ResourceModel } from "@protobase/schema";
5
+ import "@protobase/query";
@@ -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, { user: {
12
- id: session.user.id,
13
- roles: session.user.roles
14
- } }, deps.accessOptions);
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. */
@@ -1,6 +1,6 @@
1
+ import { parseParams } from "../params.js";
1
2
  import { batchBody } from "../batch/schema.js";
2
3
  import { runBatch } from "../batch/run-batch.js";
3
- import { parseParams } from "../params.js";
4
4
  import { Hono } from "hono";
5
5
  //#region src/routes/batch.ts
6
6
  /**
@@ -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);
@@ -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
  };
@@ -1,8 +1,15 @@
1
1
  import { sql } from "kysely";
2
2
  //#region src/transactions.ts
3
- /** One transaction whose statements Postgres cancels after `timeoutMs` (SET LOCAL statement_timeout). */
4
- const writeTransaction = (db, timeoutMs, run) => db.transaction().execute(async (trx) => {
5
- await sql`select set_config('statement_timeout', ${String(timeoutMs)}, true)`.execute(trx);
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 AuditEvent = {
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
- /** Where the request came from, as its headers say; proxies set `forwardedFor`, so it is only as trustworthy as they are. */
51
- origin: {
52
- userAgent?: string;
53
- forwardedFor?: string;
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
@@ -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 };
@@ -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 };
@@ -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.7.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.7.0",
34
- "@protobase/query": "0.7.0",
35
- "@protobase/schema": "0.7.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",