@protobase/server 0.33.4 → 0.34.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 (80) 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 -363
  7. package/dist/better-auth/create-auth.js +39 -20
  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/new-account-password.d.ts +6 -0
  22. package/dist/better-auth/new-account-password.js +6 -3
  23. package/dist/better-auth/origins.d.ts +7 -0
  24. package/dist/better-auth/origins.js +8 -1
  25. package/dist/better-auth/password-reset.js +4 -3
  26. package/dist/better-auth/policy-plugin.js +1 -24
  27. package/dist/better-auth/sign-in-enforcement.js +6 -1
  28. package/dist/better-auth/sign-in-setup.js +38 -0
  29. package/dist/better-auth/sign-up/sign-up-plugin.js +1 -1
  30. package/dist/better-auth/snake-case-names.js +102 -1
  31. package/dist/better-auth/staff-checks.js +9 -2
  32. package/dist/better-auth/staff-plugin.js +9 -2
  33. package/dist/better-auth/status-route.js +2 -1
  34. package/dist/better-auth/token-claims.js +19 -0
  35. package/dist/better-auth/users.d.ts +4 -1
  36. package/dist/better-auth/users.js +6 -1
  37. package/dist/check-roles.js +1 -1
  38. package/dist/create-admin.js +8 -0
  39. package/dist/deps.d.ts +1 -0
  40. package/dist/encryption/apply.d.ts +40 -0
  41. package/dist/encryption/apply.js +99 -0
  42. package/dist/encryption/binding.d.ts +2 -0
  43. package/dist/encryption/binding.js +53 -0
  44. package/dist/encryption/capture-off.js +12 -0
  45. package/dist/encryption/cascading-keys.d.ts +9 -0
  46. package/dist/encryption/cascading-keys.js +21 -0
  47. package/dist/encryption/envelope-open.d.ts +5 -0
  48. package/dist/encryption/envelope-open.js +57 -0
  49. package/dist/encryption/envelope-seal.js +38 -0
  50. package/dist/encryption/keyring.js +60 -0
  51. package/dist/encryption/mask-stored.js +16 -0
  52. package/dist/encryption/rebind-check.js +28 -0
  53. package/dist/encryption/remnants.js +53 -0
  54. package/dist/encryption/runtime.d.ts +1 -0
  55. package/dist/encryption/runtime.js +18 -0
  56. package/dist/encryption/scan.d.ts +4 -0
  57. package/dist/encryption/scan.js +61 -0
  58. package/dist/encryption/seal-values.js +30 -0
  59. package/dist/encryption/shared-columns.js +19 -0
  60. package/dist/encryption/status.d.ts +39 -0
  61. package/dist/encryption/status.js +92 -0
  62. package/dist/encryption/stored-value.js +16 -0
  63. package/dist/encryption/tools.d.ts +26 -0
  64. package/dist/encryption/tools.js +34 -0
  65. package/dist/forms/check-forms.js +1 -1
  66. package/dist/gotrue/user-routes.js +6 -0
  67. package/dist/index.d.ts +10 -5
  68. package/dist/index.js +4 -2
  69. package/dist/records.js +3 -3
  70. package/dist/request-access.js +9 -5
  71. package/dist/rest/encrypted-columns.js +37 -0
  72. package/dist/rest/table-routes.js +25 -13
  73. package/dist/reveal-service.js +17 -3
  74. package/dist/types.d.ts +5 -0
  75. package/dist/validate-body.js +4 -2
  76. package/dist/webhooks/subscriber-view.js +14 -1
  77. package/dist/write-pipeline.js +14 -2
  78. package/dist/write-shared.js +7 -3
  79. package/dist/write-update.js +12 -2
  80. package/package.json +11 -7
@@ -0,0 +1,99 @@
1
+ import { bindingOf, movedBinding } from "./binding.js";
2
+ import { Sealed, sealText } from "./envelope-seal.js";
3
+ import { columnId, tableId } from "../sql-parts.js";
4
+ import { cascadingKeys } from "./cascading-keys.js";
5
+ import { captureOff } from "./capture-off.js";
6
+ import { scrubHistory, scrubOutbox } from "./remnants.js";
7
+ import { scanColumn } from "./scan.js";
8
+ import { encryptedFields, examine } from "./status.js";
9
+ import { sql } from "kysely";
10
+ //#region src/encryption/apply.ts
11
+ const rebindsField = (rebind, entry, field) => rebind !== void 0 && rebind.resource === entry.name && (rebind.field === void 0 || rebind.field === field.name) ? rebind : void 0;
12
+ const changeOf = (keyring, entry, field, row, rebind) => {
13
+ const binding = bindingOf(entry.model, field, row.record);
14
+ const seal = (plaintext, count) => ({
15
+ address: row.address,
16
+ old: row.stored,
17
+ value: Sealed.textOf(sealText(keyring.current, binding, plaintext)),
18
+ count
19
+ });
20
+ const found = examine(keyring, entry, field, row, binding);
21
+ if (found.kind === "plaintext") return seal(row.stored, "encrypted");
22
+ if (found.kind === "open") return found.kid === keyring.current.id ? void 0 : seal(found.plaintext, "reencrypted");
23
+ if (found.entry.kind !== "failed-authentication" || !rebind) return found.entry;
24
+ const moved = examine(keyring, entry, field, row, movedBinding(binding, rebind.from));
25
+ return moved.kind === "open" ? seal(moved.plaintext, "rebound") : found.entry;
26
+ };
27
+ const write = async (trx, entry, field, change) => {
28
+ const { model } = entry;
29
+ const keys = model.primaryKey.map((name, i) => sql`${columnId(model.fields[name])} = ${change.address.key[i]}`);
30
+ const tenant = model.tenant ? [sql`${columnId(model.fields[model.tenant])} is not distinct from ${change.address.tenant}`] : [];
31
+ const conditions = sql.join([
32
+ ...keys,
33
+ ...tenant,
34
+ sql`${columnId(field)} = ${change.old}`
35
+ ], sql` and `);
36
+ const result = await sql`update ${tableId(model)} set ${columnId(field)} = ${change.value} where ${conditions}`.execute(trx);
37
+ return Number(result.numAffectedRows ?? 0n) > 0;
38
+ };
39
+ const applyField = async (db, entry, field, keyring, changesSchema, rebind) => {
40
+ const applied = {
41
+ resource: entry.name,
42
+ field: field.name,
43
+ encrypted: 0,
44
+ reencrypted: 0,
45
+ rebound: 0,
46
+ skipped: 0,
47
+ unreadable: [],
48
+ historyScrubbed: 0,
49
+ outboxScrubbed: 0
50
+ };
51
+ await scanColumn(db, entry, field, async (rows, connection) => {
52
+ const changes = [];
53
+ for (const row of rows) {
54
+ const change = changeOf(keyring, entry, field, row, rebind);
55
+ if (change && "address" in change) changes.push(change);
56
+ else if (change) applied.unreadable.push(change);
57
+ }
58
+ if (changes.length === 0) return;
59
+ await captureOff(connection, changesSchema, async (trx) => {
60
+ for (const change of changes) if (await write(trx, entry, field, change)) applied[change.count]++;
61
+ else applied.skipped++;
62
+ });
63
+ });
64
+ applied.historyScrubbed = await scrubHistory(db, changesSchema, entry.model, field);
65
+ applied.outboxScrubbed = await scrubOutbox(db, changesSchema, entry.model, field);
66
+ return applied;
67
+ };
68
+ const keyIdsOf = async (db, entry, field) => {
69
+ const { rows } = await sql`select distinct split_part(${columnId(field)}, ':', 3) as kid from ${tableId(entry.model)} where ${columnId(field)} like 'pbenc:%'`.execute(db);
70
+ return rows.map(({ kid }) => kid);
71
+ };
72
+ const checkKeys = async (db, entries, keyring) => {
73
+ for (const entry of entries) for (const field of encryptedFields(entry)) {
74
+ const kids = await keyIdsOf(db, entry, field);
75
+ if (kids.length > 0 && !kids.some((kid) => keyring.byId.has(kid))) throw new Error(`${entry.name}.${field.name} holds values under keys ${kids.join(", ")} only, none of which this run has (key ${keyring.current.id}): run with the keys the app serves with`);
76
+ }
77
+ };
78
+ /**
79
+ * Brings every encrypted field's column to the current key: plaintext values are encrypted, values under a previous key
80
+ * encrypted again, and with `rebind` values sealed where they were before a rename sealed where they are now. Values that
81
+ * do not open are listed and left alone. Then the fields' old values are removed from the history and the changes not
82
+ * yet dispatched. Its writes capture no change. It refuses to run when a column holds envelopes under none of its keys.
83
+ */
84
+ const applyEncryption = async (db, entries, keyring, changesSchema, options = {}) => {
85
+ await checkKeys(db, entries, keyring);
86
+ const fields = [];
87
+ const warnings = [];
88
+ for (const entry of entries) {
89
+ for (const field of encryptedFields(entry)) fields.push(await applyField(db, entry, field, keyring, changesSchema, rebindsField(options.rebind, entry, field)));
90
+ if (encryptedFields(entry).length > 0) warnings.push(...await cascadingKeys(db, entry.model));
91
+ }
92
+ return {
93
+ kid: keyring.current.id,
94
+ fields,
95
+ warnings
96
+ };
97
+ };
98
+ //#endregion
99
+ export { applyEncryption };
@@ -0,0 +1,2 @@
1
+ import "../types.js";
2
+ import { ResourceModel } from "@protobase/schema";
@@ -0,0 +1,53 @@
1
+ import { parseTable } from "@protobase/schema/internal";
2
+ //#region src/encryption/binding.ts
3
+ const textOf = (record, name) => {
4
+ const value = record[name];
5
+ if (value === void 0) throw new Error(`Binding an encrypted value needs the record's "${name}"`);
6
+ return String(value);
7
+ };
8
+ const tableOf = (model, field) => ({
9
+ schema: model.table.schema ?? null,
10
+ table: model.table.name,
11
+ column: field.column
12
+ });
13
+ /**
14
+ * The binding of `field` in `record`. The record must be a row as `fetchRecord`, `insertRecord` and `updateRecord` return
15
+ * it, its columns selected under their field names and passed through `exposeRow`, so its key and tenant are the text the
16
+ * API shows (a lowercase uuid, a bigint as text, a timestamptz as ISO text with milliseconds).
17
+ */
18
+ const bindingOf = (model, field, record) => ({
19
+ ...tableOf(model, field),
20
+ tenant: model.tenant && record[model.tenant] !== null ? textOf(record, model.tenant) : null,
21
+ key: model.primaryKey.map((name) => textOf(record, name))
22
+ });
23
+ /** The binding of a create's first write, whose key the database has not made yet: it is bound again in the same transaction. */
24
+ const pendingBinding = (model, field) => ({
25
+ ...tableOf(model, field),
26
+ tenant: null,
27
+ key: null
28
+ });
29
+ /** Where a value was before a rename: `binding` with the table (as `.table()` wrote it) or the column that `from` names. */
30
+ const movedBinding = (binding, from) => {
31
+ const table = from.table === void 0 ? void 0 : parseTable(from.table);
32
+ return {
33
+ ...binding,
34
+ ...table && {
35
+ schema: table.schema ?? null,
36
+ table: table.name
37
+ },
38
+ ...from.column !== void 0 && { column: from.column }
39
+ };
40
+ };
41
+ /** The additional authenticated data of an envelope: JSON of an array, which no dot in a name or order of a key confuses. */
42
+ const aadOf = (kid, { schema, table, column, tenant, key }) => Buffer.from(JSON.stringify([
43
+ "pbenc",
44
+ 1,
45
+ kid,
46
+ schema,
47
+ table,
48
+ column,
49
+ tenant,
50
+ key
51
+ ]), "utf8");
52
+ //#endregion
53
+ export { aadOf, bindingOf, movedBinding, pendingBinding };
@@ -0,0 +1,12 @@
1
+ import { sql } from "kysely";
2
+ //#region src/encryption/capture-off.ts
3
+ /**
4
+ * Runs `work` in a transaction on one connection that change capture skips (`<changes>.capture` set to `off` for this
5
+ * transaction only): rewriting stored values is no change of a record, for history, events or webhooks.
6
+ */
7
+ const captureOff = (db, changesSchema, work) => db.transaction().execute(async (trx) => {
8
+ await sql`select set_config(${`${changesSchema}.capture`}, 'off', true)`.execute(trx);
9
+ return work(trx);
10
+ });
11
+ //#endregion
12
+ export { captureOff };
@@ -0,0 +1,9 @@
1
+ import { ResourceModel } from "@protobase/schema";
2
+ import { Db } from "@protobase/query";
3
+ //#region src/encryption/cascading-keys.d.ts
4
+ /** A foreign key that rewrites part of a resource's key when the record it points at changes its own. */
5
+ export type CascadingKey = {
6
+ resource: string;
7
+ constraint: string;
8
+ };
9
+ //#endregion
@@ -0,0 +1,21 @@
1
+ import { sql } from "kysely";
2
+ //#region src/encryption/cascading-keys.ts
3
+ /**
4
+ * The foreign keys of the resource's table, `on update cascade` or `set default`, over a column of its key: a key change
5
+ * of the other record rewrites this record's key, and every encrypted value of the row then no longer opens.
6
+ */
7
+ const cascadingKeys = async (db, model) => {
8
+ const columns = model.primaryKey.map((name) => model.fields[name].column);
9
+ const { rows } = await sql`select c.conname as "constraint"
10
+ from pg_constraint c
11
+ where c.contype = 'f' and c.confupdtype in ('c', 'd')
12
+ and c.conrelid = to_regclass(${model.table.schema === void 0 ? sql`quote_ident(${model.table.name})` : sql`quote_ident(${model.table.schema}) || '.' || quote_ident(${model.table.name})`})
13
+ and exists (select 1 from pg_attribute a where a.attrelid = c.conrelid and a.attnum = any(c.conkey) and a.attname = any(${columns}::text[]))
14
+ order by c.conname`.execute(db);
15
+ return rows.map(({ constraint }) => ({
16
+ resource: model.name,
17
+ constraint
18
+ }));
19
+ };
20
+ //#endregion
21
+ export { cascadingKeys };
@@ -0,0 +1,5 @@
1
+ import "./binding.js";
2
+ //#region src/encryption/envelope-open.d.ts
3
+ /** Why a stored value does not open. */
4
+ export type UnreadableKind = 'not-encrypted' | 'malformed' | 'unknown-key' | 'failed-authentication';
5
+ //#endregion
@@ -0,0 +1,57 @@
1
+ import { aadOf } from "./binding.js";
2
+ import "./envelope-seal.js";
3
+ import { createDecipheriv } from "node:crypto";
4
+ //#region src/encryption/envelope-open.ts
5
+ /** A stored value of an encrypted field that does not open. Its message never holds the value. */
6
+ var UnreadableValue = class extends Error {
7
+ kind;
8
+ kid;
9
+ constructor(kind, subject, binding, kid) {
10
+ super(`${subject.resource}.${subject.field} of record ${JSON.stringify(binding.key)} cannot be decrypted: ${kind}${kid ? ` (key ${kid})` : ""}`);
11
+ this.kind = kind;
12
+ this.kid = kid;
13
+ this.name = "UnreadableValue";
14
+ }
15
+ };
16
+ const ivLength = 12;
17
+ const tagLength = 16;
18
+ const parse = (stored) => {
19
+ if (!stored.startsWith("pbenc:")) return "not-encrypted";
20
+ const parts = stored.split(":");
21
+ if (parts.length !== 4 || parts[1] !== "1") return "malformed";
22
+ const [, , kid, payload] = parts;
23
+ if (!/^[A-Za-z0-9_-]{8}$/.test(kid)) return "malformed";
24
+ const bytes = Buffer.from(payload, "base64url");
25
+ if (bytes.toString("base64url") !== payload || bytes.length < 28) return "malformed";
26
+ return {
27
+ kid,
28
+ bytes
29
+ };
30
+ };
31
+ const decrypt = (aes, aad, bytes) => {
32
+ const decipher = createDecipheriv("aes-256-gcm", aes, bytes.subarray(0, ivLength), { authTagLength: tagLength });
33
+ decipher.setAAD(aad);
34
+ decipher.setAuthTag(bytes.subarray(bytes.length - tagLength));
35
+ const body = decipher.update(bytes.subarray(ivLength, bytes.length - tagLength));
36
+ try {
37
+ return Buffer.concat([body, decipher.final()]).toString("utf8");
38
+ } catch (error) {
39
+ if (error instanceof Error) return void 0;
40
+ throw error;
41
+ }
42
+ };
43
+ /**
44
+ * The plaintext of `stored`, sealed under `binding` with a key of `keyring`, or an `UnreadableValue` thrown with why it
45
+ * does not open. The only place, with the CLI's status and apply, that decrypts.
46
+ */
47
+ const openText = (keyring, binding, stored, subject) => {
48
+ const parsed = parse(stored);
49
+ if (typeof parsed === "string") throw new UnreadableValue(parsed, subject, binding);
50
+ const key = keyring.byId.get(parsed.kid);
51
+ if (!key) throw new UnreadableValue("unknown-key", subject, binding, parsed.kid);
52
+ const plaintext = decrypt(key.aes, aadOf(parsed.kid, binding), parsed.bytes);
53
+ if (plaintext === void 0) throw new UnreadableValue("failed-authentication", subject, binding, parsed.kid);
54
+ return plaintext;
55
+ };
56
+ //#endregion
57
+ export { UnreadableValue, openText };
@@ -0,0 +1,38 @@
1
+ import { aadOf } from "./binding.js";
2
+ import { createCipheriv, randomBytes } from "node:crypto";
3
+ //#region src/encryption/envelope-seal.ts
4
+ /** The envelope's prefix and version: `pbenc:1:<key id>:<base64url of iv, ciphertext and tag>`. */
5
+ const envelopePrefix = "pbenc:";
6
+ let seal;
7
+ /**
8
+ * A value sealed by `sealText`, the only thing the record writes accept for an encrypted field: nothing else can make
9
+ * one, so a write path that forgets to encrypt throws instead of storing plaintext.
10
+ */
11
+ var Sealed = class Sealed {
12
+ #text;
13
+ constructor(text) {
14
+ this.#text = text;
15
+ }
16
+ static {
17
+ seal = (text) => new Sealed(text);
18
+ }
19
+ /** The envelope text the column stores. */
20
+ static textOf(value) {
21
+ return value.#text;
22
+ }
23
+ };
24
+ /** Seals `plaintext` with AES-256-GCM under `key`, bound to `binding`, with a fresh random IV. */
25
+ const sealText = (key, binding, plaintext) => {
26
+ const iv = randomBytes(12);
27
+ const cipher = createCipheriv("aes-256-gcm", key.aes, iv, { authTagLength: 16 });
28
+ cipher.setAAD(aadOf(key.id, binding));
29
+ const body = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]);
30
+ const payload = Buffer.concat([
31
+ iv,
32
+ body,
33
+ cipher.getAuthTag()
34
+ ]).toString("base64url");
35
+ return seal(`${envelopePrefix}1:${key.id}:${payload}`);
36
+ };
37
+ //#endregion
38
+ export { Sealed, envelopePrefix, sealText };
@@ -0,0 +1,60 @@
1
+ import { createHmac } from "node:crypto";
2
+ //#region src/encryption/keyring.ts
3
+ const currentVariable = "PROTOBASE_ENCRYPTION_KEY";
4
+ const previousVariable = "PROTOBASE_ENCRYPTION_PREVIOUS_KEYS";
5
+ const derive = (root, label) => createHmac("sha256", root).update(label).digest();
6
+ const keyOf = (root) => ({
7
+ id: derive(root, "protobase field encryption key id").toString("base64url").slice(0, 8),
8
+ aes: derive(root, "protobase field encryption key v1")
9
+ });
10
+ const rootOf = ({ text, where }) => {
11
+ if (text === "") throw new Error(`${where} is empty`);
12
+ const bytes = Buffer.from(text, "base64");
13
+ if (bytes.toString("base64") !== text || bytes.length !== 32) throw new Error(`${where} is not 32 bytes in standard base64, such as the output of openssl rand -base64 32`);
14
+ if (bytes.every((byte) => byte === bytes[0])) throw new Error(`${where} is 32 identical bytes, not a random key`);
15
+ return bytes;
16
+ };
17
+ const entriesOf = (env) => {
18
+ const current = {
19
+ text: (env[currentVariable] ?? "").trim(),
20
+ where: currentVariable
21
+ };
22
+ const previous = env[previousVariable];
23
+ if (previous === void 0 || previous.trim() === "") return [current];
24
+ return [current, ...previous.split(",").map((text, i) => ({
25
+ text: text.trim(),
26
+ where: `${previousVariable} entry ${i + 1}`
27
+ }))];
28
+ };
29
+ const checkApart = (entries, env) => {
30
+ for (const name of ["BETTER_AUTH_SECRET", "PROTOBASE_SECRET_KEY"]) {
31
+ const secret = env[name]?.trim();
32
+ const same = secret && entries.find((entry) => entry.text === secret);
33
+ if (same) throw new Error(`${same.where} is the same as ${name}: give encryption a key of its own`);
34
+ }
35
+ };
36
+ /**
37
+ * The keys from `PROTOBASE_ENCRYPTION_KEY` and the comma separated `PROTOBASE_ENCRYPTION_PREVIOUS_KEYS`. Throws on a key
38
+ * that is not 32 random bytes in base64, a key given twice, or one that is the sign-in or API secret, naming the
39
+ * variable and the entry, never the key.
40
+ */
41
+ const encryptionKeyring = (env) => {
42
+ const entries = entriesOf(env);
43
+ checkApart(entries, env);
44
+ const byId = /* @__PURE__ */ new Map();
45
+ const seen = /* @__PURE__ */ new Set();
46
+ for (const entry of entries) {
47
+ const root = rootOf(entry);
48
+ if (seen.has(root.toString("base64"))) throw new Error(`${entry.where} is a key given before`);
49
+ seen.add(root.toString("base64"));
50
+ const key = keyOf(root);
51
+ if (byId.has(key.id)) throw new Error(`${entry.where} has the same key id as a key given before`);
52
+ byId.set(key.id, key);
53
+ }
54
+ return {
55
+ current: byId.values().next().value,
56
+ byId
57
+ };
58
+ };
59
+ //#endregion
60
+ export { encryptionKeyring };
@@ -0,0 +1,16 @@
1
+ import { encrypted } from "@protobase/schema";
2
+ //#region src/encryption/mask-stored.ts
3
+ /**
4
+ * A stored row as app code gets it: each encrypted field that holds a value as `encrypted`, never the ciphertext, so
5
+ * rules, validators and hooks know whether it is set and nothing else. An empty one stays `null`.
6
+ */
7
+ const maskEncrypted = (model, row) => {
8
+ const masked = Object.values(model.fields).filter((field) => field.encrypted && row[field.name] != null);
9
+ if (masked.length === 0) return row;
10
+ return {
11
+ ...row,
12
+ ...Object.fromEntries(masked.map((field) => [field.name, encrypted]))
13
+ };
14
+ };
15
+ //#endregion
16
+ export { maskEncrypted };
@@ -0,0 +1,28 @@
1
+ import { parseTable } from "@protobase/schema/internal";
2
+ //#region src/encryption/rebind-check.ts
3
+ const sources = (entry, rebind) => {
4
+ const table = rebind.from.table === void 0 ? entry.model.table : parseTable(rebind.from.table);
5
+ return Object.values(entry.model.fields).filter((field) => field.encrypted && (rebind.field === void 0 || field.name === rebind.field)).map((field) => ({
6
+ table,
7
+ column: rebind.from.column ?? field.column
8
+ }));
9
+ };
10
+ const sameTable = (a, b) => a.name === b.name && (a.schema === void 0 || b.schema === void 0 || a.schema === b.schema);
11
+ /**
12
+ * Why `rebind` cannot run, or `undefined`: it must name a resource with encrypted fields, an encrypted field of it, and
13
+ * where they were before; and no resource may still encrypt a field over that place, whose values it would take over.
14
+ */
15
+ const rebindProblem = (entries, rebind) => {
16
+ const entry = entries.find((candidate) => candidate.name === rebind.resource);
17
+ if (!entry) return `There is no resource "${rebind.resource}"`;
18
+ if (rebind.field !== void 0 && !entry.model.fields[rebind.field]?.encrypted) return `${rebind.resource}.${rebind.field} is not an encrypted field`;
19
+ if (!Object.values(entry.model.fields).some((field) => field.encrypted)) return `${rebind.resource} has no encrypted fields`;
20
+ if (rebind.from.table === void 0 && rebind.from.column === void 0) return "Name the table or the column the values were in before";
21
+ if (rebind.from.column !== void 0 && rebind.field === void 0) return "Name the field whose column was renamed";
22
+ for (const { table, column } of sources(entry, rebind)) {
23
+ const owner = entries.find((other) => sameTable(other.model.table, table) && Object.values(other.model.fields).some((field) => field.encrypted && field.column === column));
24
+ if (owner) return `${owner.name} still encrypts a field over ${table.schema ? `${table.schema}.` : ""}${table.name}.${column}, so its values are not ${rebind.resource}'s to take over`;
25
+ }
26
+ };
27
+ //#endregion
28
+ export { rebindProblem };
@@ -0,0 +1,53 @@
1
+ import { quoteIdent } from "../changes/capture-sql.js";
2
+ import { captureOff } from "./capture-off.js";
3
+ import { sql } from "kysely";
4
+ //#region src/encryption/remnants.ts
5
+ const history = {
6
+ table: "history",
7
+ old: "before",
8
+ new: "after",
9
+ key: (field) => field.name
10
+ };
11
+ const outbox = {
12
+ table: "outbox",
13
+ old: "old",
14
+ new: "new",
15
+ key: (field) => field.column
16
+ };
17
+ const batchSize = 500;
18
+ const tableOf = (schema, store) => `${quoteIdent(schema)}.${quoteIdent(store.table)}`;
19
+ const exists = async (db, schema, store) => {
20
+ const { rows } = await sql`select to_regclass(${tableOf(schema, store)}) is not null as found`.execute(db);
21
+ return rows[0].found;
22
+ };
23
+ const holding = (store, model, field) => sql`"resource" = ${model.name} and (${sql.id(store.old)} ? ${store.key(field)}::text or ${sql.id(store.new)} ? ${store.key(field)}::text)`;
24
+ const count = async (db, schema, store, model, field) => {
25
+ if (!await exists(db, schema, store)) return 0;
26
+ const { rows } = await sql`select count(*)::text as count from ${sql.raw(tableOf(schema, store))} where ${holding(store, model, field)}`.execute(db);
27
+ return Number(rows[0].count);
28
+ };
29
+ const scrub = async (db, schema, store, model, field) => {
30
+ if (!await exists(db, schema, store)) return 0;
31
+ const table = sql.raw(tableOf(schema, store));
32
+ const key = sql`${store.key(field)}::text`;
33
+ let total = 0;
34
+ for (;;) {
35
+ const scrubbed = await captureOff(db, schema, async (trx) => {
36
+ const result = await sql`with batch as (select "id" from ${table} where ${holding(store, model, field)} limit ${sql.raw(String(batchSize))})
37
+ update ${table} as t set ${sql.id(store.old)} = t.${sql.id(store.old)} - ${key}, ${sql.id(store.new)} = t.${sql.id(store.new)} - ${key} from batch where t."id" = batch."id"`.execute(trx);
38
+ return Number(result.numAffectedRows ?? 0n);
39
+ });
40
+ total += scrubbed;
41
+ if (scrubbed < batchSize) return total;
42
+ }
43
+ };
44
+ /** The history rows of `model` that still hold `field`, kept from before it was encrypted. */
45
+ const historyRemnants = (db, schema, model, field) => count(db, schema, history, model, field);
46
+ /** Removes `field` from the history of `model`, returning how many rows held it. */
47
+ const scrubHistory = (db, schema, model, field) => scrub(db, schema, history, model, field);
48
+ /** The changes of `model` not yet dispatched that still hold `field`'s column. */
49
+ const outboxRemnants = (db, schema, model, field) => count(db, schema, outbox, model, field);
50
+ /** Removes `field`'s column from the changes of `model` not yet dispatched, returning how many rows held it. */
51
+ const scrubOutbox = (db, schema, model, field) => scrub(db, schema, outbox, model, field);
52
+ //#endregion
53
+ export { historyRemnants, outboxRemnants, scrubHistory, scrubOutbox };
@@ -0,0 +1 @@
1
+ import { ResourceModel } from "@protobase/schema";
@@ -0,0 +1,18 @@
1
+ import { sharedColumnProblems } from "./shared-columns.js";
2
+ import { encryptionKeyring } from "./keyring.js";
3
+ //#region src/encryption/runtime.ts
4
+ const encryptedFieldNames = (models) => models.flatMap((model) => Object.values(model.fields).flatMap((field) => field.encrypted ? [`${model.name}.${field.name}`] : []));
5
+ /**
6
+ * The runtime, when a resource has an encrypted field. It fails at startup when another resource over the same column
7
+ * would write plaintext into it or bind it differently, or when the key is missing or unfit. It needs no database.
8
+ */
9
+ const createEncryptionRuntime = ({ models, env }) => {
10
+ const fields = encryptedFieldNames(models);
11
+ if (fields.length === 0) return void 0;
12
+ const problems = sharedColumnProblems(models);
13
+ if (problems.length > 0) throw new Error(problems.join("; "));
14
+ if (!env.PROTOBASE_ENCRYPTION_KEY?.trim()) throw new Error(`Encrypted fields (${fields.join(", ")}) need a key: set PROTOBASE_ENCRYPTION_KEY to 32 random bytes in base64, such as the output of openssl rand -base64 32`);
15
+ return { keyring: encryptionKeyring(env) };
16
+ };
17
+ //#endregion
18
+ export { createEncryptionRuntime };
@@ -0,0 +1,4 @@
1
+ import "../types.js";
2
+ import "../registry.js";
3
+ import "@protobase/schema";
4
+ import { Db } from "@protobase/query";
@@ -0,0 +1,61 @@
1
+ import { aliasedColumn, columnId, tableId } from "../sql-parts.js";
2
+ import { exposeRow } from "../rows.js";
3
+ import { sql } from "kysely";
4
+ //#region src/encryption/scan.ts
5
+ const batchSize = 500;
6
+ const cursor = sql.id("pb_encryption_scan");
7
+ const keyAlias = (i) => `__pb_key_${i}`;
8
+ const tenantAlias = "__pb_tenant";
9
+ /** The fields a binding needs, and the field itself. */
10
+ const scannedFields = (entry, field) => {
11
+ const { model } = entry;
12
+ return [.../* @__PURE__ */ new Set([
13
+ ...model.primaryKey,
14
+ ...model.tenant ? [model.tenant] : [],
15
+ field.name
16
+ ])].map((name) => model.fields[name]);
17
+ };
18
+ const declareScan = (entry, field) => {
19
+ const { model } = entry;
20
+ const keys = model.primaryKey.map((name, i) => sql`${columnId(model.fields[name])}::text as ${sql.id(keyAlias(i))}`);
21
+ const tenant = model.tenant ? [sql`${columnId(model.fields[model.tenant])}::text as ${sql.id(tenantAlias)}`] : [];
22
+ const columns = sql.join([
23
+ ...scannedFields(entry, field).map(aliasedColumn),
24
+ ...keys,
25
+ ...tenant
26
+ ]);
27
+ return sql`declare ${cursor} no scroll cursor with hold for select ${columns} from ${tableId(model)} where ${columnId(field)} is not null`;
28
+ };
29
+ const scanned = (entry, field, row) => ({
30
+ record: exposeRow(entry.model, row),
31
+ address: {
32
+ key: entry.model.primaryKey.map((_, i) => String(row[keyAlias(i)])),
33
+ tenant: row[tenantAlias] ?? null
34
+ },
35
+ stored: String(row[field.name])
36
+ });
37
+ /**
38
+ * Reads every row of the table whose column `field` is not null, every tenant and soft-deleted rows included, in batches
39
+ * handed to `onBatch` with the connection the scan runs on. A holdable cursor keeps the rows as they were when the scan
40
+ * began, so each comes once without an order, and the batches' writes run on the same connection in transactions of
41
+ * their own: a pool of one connection is enough. Rows written after the scan began are not in it.
42
+ */
43
+ const scanColumn = (db, entry, field, onBatch) => db.connection().execute(async (connection) => {
44
+ let declared = false;
45
+ try {
46
+ await sql`begin`.execute(connection);
47
+ await declareScan(entry, field).execute(connection);
48
+ declared = true;
49
+ await sql`commit`.execute(connection);
50
+ for (;;) {
51
+ const { rows } = await sql`fetch ${sql.raw(String(batchSize))} from ${cursor}`.execute(connection);
52
+ if (rows.length === 0) return;
53
+ await onBatch(rows.map((row) => scanned(entry, field, row)), connection);
54
+ }
55
+ } finally {
56
+ await sql`rollback`.execute(connection);
57
+ if (declared) await sql`close ${cursor}`.execute(connection);
58
+ }
59
+ });
60
+ //#endregion
61
+ export { scanColumn, scannedFields };
@@ -0,0 +1,30 @@
1
+ import { sealText } from "./envelope-seal.js";
2
+ //#region src/encryption/seal-values.ts
3
+ const encryptedIn = (model, values) => Object.keys(values).flatMap((name) => {
4
+ const field = model.fields[name];
5
+ return field?.encrypted && values[name] !== null ? [field] : [];
6
+ });
7
+ /** Whether `values` set an encrypted field to a value. */
8
+ const hasEncryptedValues = (model, values) => encryptedIn(model, values).length > 0;
9
+ /** The encrypted fields of `values` that hold a value. */
10
+ const encryptedValuesOf = (model, values) => Object.fromEntries(encryptedIn(model, values).map((field) => [field.name, values[field.name]]));
11
+ /**
12
+ * `values` with each encrypted field's value sealed under `binding(field)`. What was given is always sealed, even text
13
+ * that looks like an envelope, so a client cannot store another record's ciphertext as its own.
14
+ */
15
+ const sealValues = (runtime, model, values, binding) => {
16
+ const fields = encryptedIn(model, values);
17
+ if (fields.length === 0) return values;
18
+ if (!runtime) throw new Error(`"${model.name}" has encrypted fields but no encryption runtime`);
19
+ const sealed = fields.map((field) => {
20
+ const value = values[field.name];
21
+ if (typeof value !== "string") throw new Error(`"${field.name}" is encrypted and takes text only`);
22
+ return [field.name, sealText(runtime.keyring.current, binding(field), value)];
23
+ });
24
+ return {
25
+ ...values,
26
+ ...Object.fromEntries(sealed)
27
+ };
28
+ };
29
+ //#endregion
30
+ export { encryptedValuesOf, hasEncryptedValues, sealValues };
@@ -0,0 +1,19 @@
1
+ //#region src/encryption/shared-columns.ts
2
+ /** Whether two resources are over one table: the same name, and the same schema or either one naming none. */
3
+ const sameTable = (a, b) => a.table.name === b.table.name && (a.table.schema === void 0 || b.table.schema === void 0 || a.table.schema === b.table.schema);
4
+ const columnOf = (model, name) => name === void 0 ? null : model.fields[name]?.column ?? name;
5
+ const bindsAlike = (a, b) => (a.table.schema ?? null) === (b.table.schema ?? null) && columnOf(a, a.tenant) === columnOf(b, b.tenant) && JSON.stringify(a.primaryKey.map((name) => columnOf(a, name))) === JSON.stringify(b.primaryKey.map((name) => columnOf(b, name)));
6
+ const isOver = (field, column) => field.column === column || (field.relation?.columns.includes(column) ?? false);
7
+ const ownProblems = (model, field) => Object.values(model.fields).filter((other) => other !== field && isOver(other, field.column)).map((other) => `${model.name}.${other.name} is over ${model.table.name}.${field.column}, which ${model.name}.${field.name} encrypts: leave the column out of it`);
8
+ /**
9
+ * Every other field over the column of an encrypted field that would write plaintext into it or bind its values
10
+ * differently: in its own resource, any; in another resource, one that is not `.encrypted()` too over the same
11
+ * `.table()`, tenant and key.
12
+ */
13
+ const sharedColumnProblems = (models) => models.flatMap((model) => Object.values(model.fields).filter((field) => field.encrypted).flatMap((field) => [...ownProblems(model, field), ...models.filter((other) => other !== model && sameTable(model, other)).flatMap((other) => {
14
+ const shared = Object.values(other.fields).find((candidate) => isOver(candidate, field.column));
15
+ if (!shared || shared.encrypted && shared.column === field.column && bindsAlike(model, other)) return [];
16
+ return [`${other.name}.${shared.name} is over ${model.table.name}.${field.column}, which ${model.name} encrypts: declare it .encrypted() too, with the same table, tenant and key, or leave the column out`];
17
+ })]));
18
+ //#endregion
19
+ export { sameTable, sharedColumnProblems };
@@ -0,0 +1,39 @@
1
+ import "../registry.js";
2
+ import "./binding.js";
3
+ import { CascadingKey } from "./cascading-keys.js";
4
+ import { UnreadableKind } from "./envelope-open.js";
5
+ import "./scan.js";
6
+ import "@protobase/schema";
7
+ import { Db } from "@protobase/query";
8
+ //#region src/encryption/status.d.ts
9
+ /** A stored value that does not open, by its record's key: never the value. */
10
+ export type UnreadableEntry = {
11
+ key: string[];
12
+ kid?: string;
13
+ kind: UnreadableKind;
14
+ };
15
+ /** What an encrypted field's column holds, and where its old plaintext remains. */
16
+ export type FieldStatus = {
17
+ resource: string;
18
+ field: string;
19
+ /** The values that open, by the id of the key they are under. */
20
+ byKey: Record<string, number>;
21
+ plaintext: number;
22
+ unreadable: UnreadableEntry[];
23
+ /** History rows and undispatched changes that still hold the field from before it was encrypted. */
24
+ history: number;
25
+ outbox: number;
26
+ };
27
+ /** Envelopes in a text field that is not encrypted, such as one whose `.encrypted()` was removed. */
28
+ export type StrayValues = {
29
+ resource: string;
30
+ field: string;
31
+ count: number;
32
+ };
33
+ export type EncryptionStatus = {
34
+ kid: string;
35
+ fields: FieldStatus[];
36
+ stray: StrayValues[];
37
+ cascading: CascadingKey[];
38
+ };
39
+ //#endregion