@protobase/server 0.33.3 → 0.34.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (87) hide show
  1. package/dist/assistant/query-tools.js +1 -1
  2. package/dist/auth/jwt.d.ts +15 -1
  3. package/dist/auth/jwt.js +41 -5
  4. package/dist/better-auth/authenticator.d.ts +16 -1
  5. package/dist/better-auth/authenticator.js +41 -24
  6. package/dist/better-auth/create-auth.d.ts +3063 -364
  7. package/dist/better-auth/create-auth.js +36 -19
  8. package/dist/better-auth/mcp/connected-apps.d.ts +13 -0
  9. package/dist/better-auth/mcp/connected-apps.js +86 -0
  10. package/dist/better-auth/mcp/consent-check.js +38 -0
  11. package/dist/better-auth/mcp/consent-reference.js +16 -0
  12. package/dist/better-auth/mcp/discovery-routes.js +19 -0
  13. package/dist/better-auth/mcp/grant-reference.js +25 -0
  14. package/dist/better-auth/mcp/mcp-plugins.d.ts +13 -0
  15. package/dist/better-auth/mcp/mcp-plugins.js +107 -0
  16. package/dist/better-auth/mcp/mcp-token-claims.js +39 -0
  17. package/dist/better-auth/mcp/request-defaults.js +57 -0
  18. package/dist/better-auth/mcp/revoke-grants.js +75 -0
  19. package/dist/better-auth/mcp/setup-gate.js +38 -0
  20. package/dist/better-auth/mcp/signed-query-guard.js +29 -0
  21. package/dist/better-auth/origins.d.ts +7 -0
  22. package/dist/better-auth/origins.js +8 -1
  23. package/dist/better-auth/password-reset.js +4 -3
  24. package/dist/better-auth/policy-plugin.js +1 -24
  25. package/dist/better-auth/session-token.js +26 -0
  26. package/dist/better-auth/sign-in-enforcement.js +6 -1
  27. package/dist/better-auth/sign-in-setup.js +38 -0
  28. package/dist/better-auth/snake-case-names.js +102 -1
  29. package/dist/better-auth/staff-checks.js +9 -2
  30. package/dist/better-auth/staff-plugin.js +9 -2
  31. package/dist/better-auth/status-route.js +2 -1
  32. package/dist/better-auth/token-claims.js +19 -0
  33. package/dist/better-auth/users.d.ts +4 -1
  34. package/dist/better-auth/users.js +6 -1
  35. package/dist/check-roles.js +1 -1
  36. package/dist/create-admin.js +22 -2
  37. package/dist/deps.d.ts +1 -0
  38. package/dist/encryption/apply.d.ts +40 -0
  39. package/dist/encryption/apply.js +99 -0
  40. package/dist/encryption/binding.d.ts +2 -0
  41. package/dist/encryption/binding.js +53 -0
  42. package/dist/encryption/capture-off.js +12 -0
  43. package/dist/encryption/cascading-keys.d.ts +9 -0
  44. package/dist/encryption/cascading-keys.js +21 -0
  45. package/dist/encryption/envelope-open.d.ts +5 -0
  46. package/dist/encryption/envelope-open.js +57 -0
  47. package/dist/encryption/envelope-seal.js +38 -0
  48. package/dist/encryption/keyring.js +60 -0
  49. package/dist/encryption/mask-stored.js +16 -0
  50. package/dist/encryption/rebind-check.js +28 -0
  51. package/dist/encryption/remnants.js +53 -0
  52. package/dist/encryption/runtime.d.ts +1 -0
  53. package/dist/encryption/runtime.js +18 -0
  54. package/dist/encryption/scan.d.ts +4 -0
  55. package/dist/encryption/scan.js +61 -0
  56. package/dist/encryption/seal-values.js +30 -0
  57. package/dist/encryption/shared-columns.js +19 -0
  58. package/dist/encryption/status.d.ts +39 -0
  59. package/dist/encryption/status.js +92 -0
  60. package/dist/encryption/stored-value.js +16 -0
  61. package/dist/encryption/tools.d.ts +26 -0
  62. package/dist/encryption/tools.js +34 -0
  63. package/dist/forms/check-forms.js +1 -1
  64. package/dist/functions/accept-socket.js +32 -0
  65. package/dist/functions/define-function.d.ts +7 -0
  66. package/dist/functions/function-socket.d.ts +18 -0
  67. package/dist/functions/function-socket.js +81 -0
  68. package/dist/functions/routes.js +33 -8
  69. package/dist/functions/same-caller.js +9 -0
  70. package/dist/functions/socket-recheck.js +33 -0
  71. package/dist/functions/upgrade-caller.js +25 -0
  72. package/dist/gotrue/user-routes.js +6 -0
  73. package/dist/index.d.ts +10 -5
  74. package/dist/index.js +3 -2
  75. package/dist/records.js +3 -3
  76. package/dist/request-access.js +9 -5
  77. package/dist/rest/encrypted-columns.js +37 -0
  78. package/dist/rest/table-routes.js +25 -13
  79. package/dist/reveal-service.js +17 -3
  80. package/dist/types.d.ts +5 -0
  81. package/dist/validate-body.js +4 -2
  82. package/dist/webhooks/attempt.js +5 -1
  83. package/dist/webhooks/subscriber-view.js +14 -1
  84. package/dist/write-pipeline.js +14 -2
  85. package/dist/write-shared.js +7 -3
  86. package/dist/write-update.js +12 -2
  87. package/package.json +11 -7
package/dist/records.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { storedValue } from "./encryption/stored-value.js";
1
2
  import { columnId, filterCondition, keyConditions, returningList, scopeConditions, tableId, whereClause } from "./sql-parts.js";
2
3
  import { etagOf } from "./etag.js";
3
4
  import { exposeRow, pickFields } from "./rows.js";
@@ -24,10 +25,9 @@ const fetchRecord = async (db, entry, key, tenant, options = {}) => {
24
25
  const query = sql`select ${returningList(entry, shape.fields)} from ${tableId(entry.model)} as t${whereClause(conditions)} limit 1${lockClause(lock)}`;
25
26
  return stored(entry, (await rowsOf(db, query))[0], shape);
26
27
  };
27
- const writeValue = (field, value) => field.type === "json" && value !== null ? JSON.stringify(value) : value;
28
28
  const insertRecord = async (db, entry, values, shape = {}) => {
29
29
  const fields = Object.keys(values).map((name) => entry.model.fields[name]);
30
- const body = fields.length === 0 ? sql`default values` : sql`(${sql.join(fields.map(columnId))}) values (${sql.join(fields.map((field) => writeValue(field, values[field.name])))})`;
30
+ const body = fields.length === 0 ? sql`default values` : sql`(${sql.join(fields.map(columnId))}) values (${sql.join(fields.map((field) => storedValue(field, values[field.name])))})`;
31
31
  const query = sql`insert into ${tableId(entry.model)} ${body} returning ${returningList(entry, shape.fields)}`;
32
32
  return await stored(entry, (await rowsOf(db, query))[0], shape);
33
33
  };
@@ -39,7 +39,7 @@ const etagBump = (entry) => {
39
39
  const updateRecord = async (db, entry, key, tenant, values, shape = {}) => {
40
40
  const assignments = [...Object.entries(values).map(([name, value]) => {
41
41
  const field = entry.model.fields[name];
42
- return sql`${columnId(field)} = ${writeValue(field, value)}`;
42
+ return sql`${columnId(field)} = ${storedValue(field, value)}`;
43
43
  }), ...etagBump(entry)];
44
44
  const conditions = [...scopeConditions(entry.model, tenant), ...keyConditions(entry.model, key)];
45
45
  const query = sql`update ${tableId(entry.model)} as t set ${sql.join(assignments)}${whereClause(conditions)} returning ${returningList(entry, shape.fields)}`;
@@ -1,3 +1,4 @@
1
+ import { maskEncrypted } from "./encryption/mask-stored.js";
1
2
  import { forbidden, notFound, unauthorized } from "./problem.js";
2
3
  import { checkedFilter } from "./filter-input.js";
3
4
  import { checkOwnerId } from "./owner-id.js";
@@ -98,7 +99,7 @@ const trusted = (model) => ({
98
99
  ...model,
99
100
  fields: Object.fromEntries(Object.entries(model.fields).map(([name, field]) => [name, {
100
101
  ...field,
101
- filterable: true
102
+ filterable: !field.encrypted
102
103
  }]))
103
104
  });
104
105
  const asFilter = (access, expr, operation) => {
@@ -109,13 +110,16 @@ const asFilter = (access, expr, operation) => {
109
110
  };
110
111
  /**
111
112
  * The record-level rule of an operation, run with the stored record (and the input of a write). Returns a verdict, or a
112
- * filter that the caller checks in the database, so its semantics match reads exactly.
113
+ * filter that the caller checks in the database, so its semantics match reads exactly. The rule gets a set encrypted
114
+ * field of the stored record as `encrypted`, never its ciphertext; the input it gets as written.
113
115
  */
114
116
  const recordDecision = async (access, operation, record, input) => {
115
117
  const { entry, deps, full, session } = access;
116
118
  const explicit = entry.source.accessRules[operation] ?? (operation === "list" ? entry.source.accessRules.read : void 0);
117
119
  const fallback = deps.accessOptions.defaultAccess ? () => deps.accessOptions.defaultAccess === "allow" : deps.accessOptions.roles ? deps.accessOptions.roles.can(`${entry.name}.${operation === "list" ? "read" : operation}`) : () => true;
118
- const result = await (explicit ?? fallback)({
120
+ const rule = explicit ?? fallback;
121
+ const stored = record && maskEncrypted(full, record);
122
+ const result = await rule({
119
123
  user: {
120
124
  id: session.user.id,
121
125
  roles: session.user.roles
@@ -123,8 +127,8 @@ const recordDecision = async (access, operation, record, input) => {
123
127
  ...organizationOf(session),
124
128
  model: full,
125
129
  operation,
126
- record
127
- }, record, input);
130
+ record: stored
131
+ }, stored, input);
128
132
  if (typeof result === "boolean") return result;
129
133
  return asFilter(access, result, operation);
130
134
  };
@@ -0,0 +1,37 @@
1
+ import { restError } from "./rest-error.js";
2
+ import { sameTable } from "../encryption/shared-columns.js";
3
+ //#region src/rest/encrypted-columns.ts
4
+ const guardedTable = (served, models) => ({
5
+ name: served.table.name,
6
+ models: models.filter((model) => sameTable(model, served))
7
+ });
8
+ const encryptedColumns = (table) => new Set(table.models.flatMap((model) => Object.values(model.fields).filter((field) => field.encrypted).map((field) => field.column)));
9
+ const bindingColumns = (table) => table.models.filter((model) => Object.values(model.fields).some((field) => field.encrypted)).flatMap((model) => [...model.primaryKey, ...model.tenant ? [model.tenant] : []].map((name) => model.fields[name].column));
10
+ const refusal = (table, column, what) => restError(400, "encrypted-column", `Column '${column}' of '${table.name}' is encrypted: it cannot be ${what} here`, null, "Write and reveal encrypted fields through /api/v1");
11
+ const filterColumns = (filter) => filter.kind === "condition" ? [filter.field.column] : filter.args.flatMap(filterColumns);
12
+ /** Refuses filters, a sort or a join on an encrypted column, whose ciphertext no comparison matches. */
13
+ const refuseEncryptedReads = (table, filters, order, joined = []) => {
14
+ const encrypted = encryptedColumns(table);
15
+ const read = [
16
+ ...filters.flatMap(filterColumns),
17
+ ...order.map((term) => term.field.column),
18
+ ...joined
19
+ ].find((column) => encrypted.has(column));
20
+ if (read !== void 0) throw refusal(table, read, "filtered, sorted or joined on");
21
+ };
22
+ /** Refuses a write of an encrypted column, or matching an upsert's rows on one. */
23
+ const refuseEncryptedWrite = (table, columns) => {
24
+ const encrypted = encryptedColumns(table);
25
+ const written = columns.find((column) => encrypted.has(column));
26
+ if (written !== void 0) throw refusal(table, written, "written or matched on");
27
+ };
28
+ /**
29
+ * On a table with encrypted fields, refuses a write that changes the key or tenant of a row, which its encrypted values
30
+ * are bound to: an update's `columns`, or an upsert's beyond the `kept` ones it matches rows on and so writes unchanged.
31
+ */
32
+ const refuseRebinding = (table, columns, kept = []) => {
33
+ const rebound = bindingColumns(table).find((column) => columns.includes(column) && !kept.includes(column));
34
+ if (rebound !== void 0) throw restError(400, "encrypted-column", `Column '${rebound}' of '${table.name}' cannot change here: its encrypted fields are bound to it`, null, "Change it through /api/v1");
35
+ };
36
+ //#endregion
37
+ export { guardedTable, refuseEncryptedReads, refuseEncryptedWrite, refuseRebinding };
@@ -7,6 +7,7 @@ import { parseQuery } from "./parse-query.js";
7
7
  import { embedsOf, resolveShape } from "./embed-shape.js";
8
8
  import { readRows } from "./read-rows.js";
9
9
  import { asCaller, queryParams, readBody } from "./rest-context.js";
10
+ import { guardedTable, refuseEncryptedReads, refuseEncryptedWrite, refuseRebinding } from "./encrypted-columns.js";
10
11
  import { scanRefusal, uncoveredReads } from "./rest-scan.js";
11
12
  import { deleteRows, insertRows, updateRows } from "./write-rows.js";
12
13
  import { Hono } from "hono";
@@ -35,27 +36,32 @@ const shapeOf = async (deps, foreignKeys, entry, query) => {
35
36
  embedded: query.embedded
36
37
  }, entry.model.table.name, query.select);
37
38
  };
39
+ const guarded = (deps, entry) => guardedTable(entry.model, deps.registry.entries.map((one) => one.model));
38
40
  const targetOf = (entry) => ({
39
41
  table: tableId(entry.model),
40
42
  alias: entry.model.table.name
41
43
  });
44
+ /** What a request reads of the table and of every embedded one: its filters, its sort and the columns it joins on. */
45
+ const readsOf = (deps, entry, filters, order, shape) => [{
46
+ entry,
47
+ filters,
48
+ order,
49
+ joined: []
50
+ }, ...embedsOf(shape).map((embed) => ({
51
+ entry: tableEntry(deps, embed.table),
52
+ filters: embed.params.filters,
53
+ order: embed.params.order,
54
+ joined: embed.relationship.join.map((pair) => pair.target)
55
+ }))];
42
56
  /**
43
- * Refuses filters and a sort no index serves, as the scan guard does on `/api/v1`, on the table and on every embedded
44
- * one, whose join columns need an index too; in `warn` mode, a warning header instead.
57
+ * Refuses filters, a sort or a join on an encrypted column; then filters and a sort no index serves, as the scan guard
58
+ * does on `/api/v1`, on the table and on every embedded one, whose join columns need an index too; in `warn` mode, a
59
+ * warning header instead.
45
60
  */
46
61
  const scanCheck = async (deps, entry, filters, order, shape = []) => {
62
+ const reads = readsOf(deps, entry, filters, order, shape);
63
+ for (const read of reads) refuseEncryptedReads(guarded(deps, read.entry), read.filters, read.order, read.joined);
47
64
  if (deps.guard.mode === "off") return {};
48
- const reads = [{
49
- entry,
50
- filters,
51
- order,
52
- joined: []
53
- }, ...embedsOf(shape).map((embed) => ({
54
- entry: tableEntry(deps, embed.table),
55
- filters: embed.params.filters,
56
- order: embed.params.order,
57
- joined: embed.relationship.join.map((pair) => pair.target)
58
- }))];
59
65
  for (const read of reads) {
60
66
  const [first] = uncoveredReads(await deps.guard.indexesOf(read.entry), read.filters, read.order, read.joined);
61
67
  if (!first) continue;
@@ -130,6 +136,9 @@ const insert = async (deps, foreignKeys, c) => {
130
136
  if (columns.length === 0 && rows.length > 1) throw restError(400, "PGRST102", "Several rows without any column cannot be inserted at once");
131
137
  if (rows.length === 0 && object) checkSingle(0);
132
138
  const conflict = query.onConflict ?? entry.model.primaryKey.map((name) => entry.model.fields[name].column);
139
+ const table = guarded(deps, entry);
140
+ refuseEncryptedWrite(table, prefer.resolution ? [...columns, ...conflict] : columns);
141
+ if (prefer.resolution === "merge-duplicates") refuseRebinding(table, columns, conflict);
133
142
  const written = rows.length === 0 ? {
134
143
  total: 0,
135
144
  ...prefer.return === "representation" && { body: "[]" }
@@ -154,6 +163,9 @@ const update = async (deps, foreignKeys, c) => {
154
163
  const object = wantsObject(c.req.raw);
155
164
  const body = await readBody(c);
156
165
  if (!isRecord(body)) throw restError(400, "PGRST102", "Empty or invalid json");
166
+ const table = guarded(deps, entry);
167
+ refuseEncryptedWrite(table, Object.keys(body));
168
+ refuseRebinding(table, Object.keys(body));
157
169
  const shape = await shapeOf(deps, foreignKeys, entry, query);
158
170
  const warning = await scanCheck(deps, entry, query.filters, [], shape);
159
171
  const written = Object.keys(body).length === 0 ? {
@@ -1,10 +1,21 @@
1
1
  import { badRequest, notFound } from "./problem.js";
2
2
  import { tenantScope } from "./tenant.js";
3
3
  import { requestAccess, requireOperation } from "./request-access.js";
4
+ import { bindingOf } from "./encryption/binding.js";
4
5
  import { fetchReadable } from "./fetch-readable.js";
5
6
  import { readTransaction } from "./transactions.js";
7
+ import { openText } from "./encryption/envelope-open.js";
6
8
  import { encodeKey } from "@protobase/schema/internal";
7
9
  //#region src/reveal-service.ts
10
+ const revealedValue = (deps, model, field, record) => {
11
+ const stored = record[field.name] ?? null;
12
+ if (!field.encrypted || stored === null) return stored;
13
+ if (!deps.encryption) throw new Error(`"${model.name}" has encrypted fields but no encryption runtime`);
14
+ return openText(deps.encryption.keyring, bindingOf(model, field, record), String(stored), {
15
+ resource: model.name,
16
+ field: field.name
17
+ });
18
+ };
8
19
  /**
9
20
  * The value of one sensitive field of one record, for a caller who may read the record and the field: the same tenant,
10
21
  * row filter and record-level rule as a get. The reveal is published to the audit queue before the value is returned;
@@ -17,9 +28,12 @@ const revealField = async (deps, entry, session, request) => {
17
28
  const field = Object.hasOwn(access.readable.fields, name) ? access.readable.fields[name] : void 0;
18
29
  if (!field) throw badRequest("invalid-parameter", `Unknown field "${name}"`);
19
30
  if (!field.sensitive) throw badRequest("invalid-parameter", `"${name}" is not sensitive; it comes with the record`);
20
- const keyFields = access.full.primaryKey.map((part) => access.full.fields[part]);
21
- const found = await readTransaction(deps.db, deps.statementTimeoutMs, (trx) => fetchReadable(trx, access, request.key, "hide", { fields: [...keyFields, access.full.fields[name]] }));
31
+ const { full } = access;
32
+ const tenantField = full.fields[name].encrypted && full.tenant && !full.primaryKey.includes(full.tenant) ? [full.tenant] : [];
33
+ const keyFields = [...full.primaryKey, ...tenantField].map((part) => full.fields[part]);
34
+ const found = await readTransaction(deps.db, deps.statementTimeoutMs, (trx) => fetchReadable(trx, access, request.key, "hide", { fields: [...keyFields, full.fields[name]] }));
22
35
  if (!found) throw notFound(`No ${entry.name} with this key`);
36
+ const value = revealedValue(deps, access.full, full.fields[name], found.record);
23
37
  const tenant = tenantScope(entry, session)?.tenantValue;
24
38
  await deps.audit.publish({
25
39
  type: "field.revealed",
@@ -36,7 +50,7 @@ const revealField = async (deps, entry, session, request) => {
36
50
  });
37
51
  return {
38
52
  field: name,
39
- value: found.record[name] ?? null
53
+ value
40
54
  };
41
55
  };
42
56
  //#endregion
package/dist/types.d.ts CHANGED
@@ -19,6 +19,11 @@ export type Session = {
19
19
  tenant?: TenantValue;
20
20
  organization?: SessionOrganization;
21
21
  anonymous?: true;
22
+ /** The outside app acting as the user, such as an MCP client: its OAuth client id and the scopes its token carries. */
23
+ client?: {
24
+ id: string;
25
+ scopes: string[];
26
+ };
22
27
  };
23
28
  /**
24
29
  * Resolves the caller of a request, or throws a 401 problem (see `unauthorized`). `userIdType` declares the type of
@@ -1,3 +1,4 @@
1
+ import { maskEncrypted } from "./encryption/mask-stored.js";
1
2
  import { badRequest } from "./problem.js";
2
3
  import { writableFields } from "./request-access.js";
3
4
  //#region src/validate-body.ts
@@ -31,7 +32,8 @@ const requiredOnCreate = (field, schema) => !(field.default && "db" in field.def
31
32
  * for a text field that may not be null counts as not given: a create fills in its default or reports it required,
32
33
  * an update reports it required. Only the values written are checked, so a stored '' still reads.
33
34
  * Returns only the values to write: on create the defaults are filled in, on update only the provided fields.
34
- * `current` is the stored record an update merges into for `.validate()` rules.
35
+ * `current` is the stored record an update merges into for `.validate()` rules, which get a set encrypted field of it as
36
+ * `encrypted`: they see an encrypted value as text only when this write gives it.
35
37
  */
36
38
  const validateBody = (access, mode, body, current = {}) => {
37
39
  if (!isObject(body)) throw badRequest("invalid-record", "The request body must be a JSON object");
@@ -85,7 +87,7 @@ const validateBody = (access, mode, body, current = {}) => {
85
87
  }
86
88
  if (errors.length > 0) throw fail(errors);
87
89
  const merged = {
88
- ...current,
90
+ ...maskEncrypted(full, current),
89
91
  ...values
90
92
  };
91
93
  const issues = entry.source.validators.flatMap((validate) => validate(merged) ?? []);
@@ -47,10 +47,14 @@ const callFunction = async (name, subscription, event, options, context, signal)
47
47
  session,
48
48
  records: context.records(session)
49
49
  };
50
+ const acceptWebSocket = () => {
51
+ throw new Error(`The function "${name}" accepted a WebSocket, but a webhook delivery is no upgrade`);
52
+ };
50
53
  const functionContext = {
51
54
  name,
52
55
  db: context.db,
53
- caller: async () => caller
56
+ caller: async () => caller,
57
+ acceptWebSocket
54
58
  };
55
59
  return invokeFunction(name, fn, await request("http://protobase.internal/", event, options, signal), fn.options.public ? functionContext : {
56
60
  ...functionContext,
@@ -40,7 +40,20 @@ const subscriptionEvent = (change, event, { fields, thin = false } = {}) => ({
40
40
  transaction: change.transaction
41
41
  }
42
42
  });
43
- const mayRead = async (access, record) => {
43
+ const withUnknownEncrypted = (model, record) => {
44
+ const fields = Object.values(model.fields).filter((field) => field.encrypted);
45
+ if (fields.length === 0) return record;
46
+ const unknown = { ...record };
47
+ for (const field of fields) Object.defineProperty(unknown, field.name, {
48
+ enumerable: true,
49
+ get: () => {
50
+ throw new Error(`a subscription's access check cannot tell whether the encrypted field "${field.name}" is set`);
51
+ }
52
+ });
53
+ return unknown;
54
+ };
55
+ const mayRead = async (access, captured) => {
56
+ const record = withUnknownEncrypted(access.full, captured);
44
57
  const { resolved, session, entry } = access;
45
58
  if (!resolved.operations.read) return false;
46
59
  if (resolved.rowFilter.read && evaluateFilter(resolved.rowFilter.read, record) !== true) return false;
@@ -1,11 +1,13 @@
1
1
  import { HttpProblem, badRequest } from "./problem.js";
2
2
  import { tenantScope } from "./tenant.js";
3
3
  import { requestAccess, requireOperation, targetFilter, writableFields } from "./request-access.js";
4
- import { deleteRecord, fetchRecord, insertRecord, restoreRecord } from "./records.js";
4
+ import { bindingOf, pendingBinding } from "./encryption/binding.js";
5
+ import { deleteRecord, fetchRecord, insertRecord, restoreRecord, updateRecord } from "./records.js";
5
6
  import { writeTransaction } from "./transactions.js";
6
7
  import { blockedDelete } from "./blocked-delete.js";
7
8
  import { isDatabaseError } from "./error-handler.js";
8
9
  import { attachFiles } from "./files/attach.js";
10
+ import { encryptedValuesOf, hasEncryptedValues, sealValues } from "./encryption/seal-values.js";
9
11
  import { actorSettings, wakeDispatch } from "./changes/actor.js";
10
12
  import { validateBody } from "./validate-body.js";
11
13
  import { assertWritten, checkParents, checkPrecondition, decide, denied, firstKeyField, keyOf, missing, notifier, shown } from "./write-shared.js";
@@ -21,6 +23,14 @@ const withOwner = (access, values) => {
21
23
  [owner]: caller
22
24
  };
23
25
  };
26
+ const bindEncrypted = async (trx, deps, access, row, inserted, shape) => {
27
+ const { entry, full } = access;
28
+ if (!hasEncryptedValues(full, row)) return inserted;
29
+ const values = sealValues(deps.encryption, full, encryptedValuesOf(full, row), (field) => bindingOf(full, field, inserted.record));
30
+ const bound = await updateRecord(trx, entry, keyOf(access, inserted.record), tenantScope(entry, access.session)?.tenantValue, values, shape);
31
+ if (!bound) throw new Error(`A ${entry.name} record was created whose key the API cannot address, so its encrypted fields cannot be bound to it`);
32
+ return bound;
33
+ };
24
34
  const create = async (trx, deps, access, body) => {
25
35
  const { entry, full } = access;
26
36
  const scope = tenantScope(entry, access.session);
@@ -33,7 +43,9 @@ const create = async (trx, deps, access, body) => {
33
43
  const filter = await decide(access, "create", void 0, row);
34
44
  const parents = checkParents(trx, access, "create");
35
45
  await parents(row);
36
- const created = await insertRecord(trx, entry, row, { etagFields: Object.keys(access.model.fields) });
46
+ const shape = { etagFields: Object.keys(access.model.fields) };
47
+ const inserted = await insertRecord(trx, entry, sealValues(deps.encryption, full, row, (field) => pendingBinding(full, field)), shape);
48
+ const created = await bindEncrypted(trx, deps, access, row, inserted, shape);
37
49
  await assertWritten(trx, access, created, "create", access.resolved.rowFilter.create, filter);
38
50
  await parents(created.record);
39
51
  if (full.position) await checkRankAfter(trx, deps, access, full.position.field, [{
@@ -1,3 +1,4 @@
1
+ import { maskEncrypted } from "./encryption/mask-stored.js";
1
2
  import { forbidden, notFound, preconditionFailed, preconditionRequired } from "./problem.js";
2
3
  import { tenantScope } from "./tenant.js";
3
4
  import { andFilters, recordDecision } from "./request-access.js";
@@ -44,7 +45,10 @@ const assertWritten = async (trx, access, written, operation, ...filters) => {
44
45
  fields: [firstKeyField(access)]
45
46
  })) throw denied(access.entry, `leave in that state (${operation})`);
46
47
  };
47
- /** The caller sees the readable fields only, files as objects with their URL; hooks get the whole record. */
48
+ /**
49
+ * The caller sees the readable fields only, files as objects with their URL; hooks get the whole record, a set encrypted
50
+ * field as `encrypted`.
51
+ */
48
52
  const shown = async (access, written) => ({
49
53
  record: (await presentFiles(access, [pickFields(written.record, Object.keys(access.model.fields))]))[0],
50
54
  etag: written.etag
@@ -58,8 +62,8 @@ const notifier = (deps, access) => async (trx, kind, before, after) => {
58
62
  user: session.user,
59
63
  ...session.tenant !== void 0 && { tenant: session.tenant },
60
64
  ...session.anonymous && { anonymous: true },
61
- ...before && { before },
62
- ...after && { after }
65
+ ...before && { before: maskEncrypted(access.full, before) },
66
+ ...after && { after: maskEncrypted(access.full, after) }
63
67
  };
64
68
  for (const hook of deps.hooks) await hook(event, trx);
65
69
  await scheduleRemovedFiles(access, kind, before, after);
@@ -1,7 +1,10 @@
1
+ import { badRequest } from "./problem.js";
1
2
  import { tenantScope } from "./tenant.js";
2
3
  import { targetFilter } from "./request-access.js";
4
+ import { bindingOf } from "./encryption/binding.js";
3
5
  import { fetchRecord, updateRecord } from "./records.js";
4
6
  import { attachFiles } from "./files/attach.js";
7
+ import { sealValues } from "./encryption/seal-values.js";
5
8
  import { validateBody } from "./validate-body.js";
6
9
  import { assertWritten, checkParents, checkPrecondition, decide, denied, firstKeyField, missing, notifier } from "./write-shared.js";
7
10
  //#region src/write-update.ts
@@ -40,11 +43,18 @@ const planUpdate = async (trx, access, key, body, ifMatch, required = true) => {
40
43
  parents
41
44
  };
42
45
  };
46
+ const refuseKeyChange = (access, plan) => {
47
+ const { full, entry } = access;
48
+ if (!Object.values(full.fields).some((field) => field.encrypted)) return;
49
+ if (full.primaryKey.some((name) => Object.hasOwn(plan.values, name) && String(plan.values[name]) !== String(plan.current.record[name]))) throw badRequest("key-change-unsupported", `The key of a ${entry.name} record cannot change: its encrypted fields are bound to it`);
50
+ };
43
51
  /** Writes a planned update, checks the row as written against the filters, and runs the hooks. */
44
52
  const applyUpdate = async (trx, deps, access, key, plan) => {
45
- const { entry } = access;
53
+ const { entry, full } = access;
54
+ refuseKeyChange(access, plan);
46
55
  const tenant = tenantScope(entry, access.session)?.tenantValue;
47
- const updated = await updateRecord(trx, entry, key, tenant, plan.values, { etagFields: Object.keys(access.model.fields) });
56
+ const values = sealValues(deps.encryption, full, plan.values, (field) => bindingOf(full, field, plan.current.record));
57
+ const updated = await updateRecord(trx, entry, key, tenant, values, { etagFields: Object.keys(access.model.fields) });
48
58
  await assertWritten(trx, access, updated, "update", access.resolved.rowFilter.update, plan.filter);
49
59
  await plan.parents(updated.record);
50
60
  await notifier(deps, access)(trx, "update", plan.current.record, updated.record);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@protobase/server",
3
- "version": "0.33.3",
3
+ "version": "0.34.0",
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,18 +30,22 @@
30
30
  "access": "public"
31
31
  },
32
32
  "dependencies": {
33
- "@better-auth/passkey": "^1.7.7",
34
- "@better-auth/sso": "^1.7.7",
35
- "@protobase/layout": "0.33.3",
36
- "@protobase/query": "0.33.3",
37
- "@protobase/schema": "0.33.3",
33
+ "@better-auth/cimd": "^1.7.12",
34
+ "@better-auth/mcp": "^1.7.12",
35
+ "@better-auth/oauth-provider": "^1.7.12",
36
+ "@better-auth/passkey": "^1.7.12",
37
+ "@better-auth/sso": "^1.7.12",
38
+ "@protobase/layout": "0.34.0",
39
+ "@protobase/query": "0.34.0",
40
+ "@protobase/schema": "0.34.0",
38
41
  "@react-email/render": "^2.1.0",
39
42
  "@scalar/hono-api-reference": "^0.12.10",
40
43
  "@sentry/core": "^11.7.0",
41
44
  "@simplewebauthn/server": "^13.3.1",
42
45
  "altcha-lib": "^2.6.0",
43
46
  "bcryptjs": "^3.0.3",
44
- "better-auth": "^1.7.7",
47
+ "better-auth": "^1.7.12",
48
+ "better-call": "1.4.4",
45
49
  "cron-parser": "^5.10.1",
46
50
  "file-type": "^22.1.1",
47
51
  "hono": "^4.13.13",