@bevel-software/platform-core-backend 0.23.0 → 0.25.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 (238) hide show
  1. package/dist/core/create-core-server.d.ts.map +1 -1
  2. package/dist/core/create-core-server.js +17 -3
  3. package/dist/core/create-core-server.js.map +1 -1
  4. package/dist/core/create-core-services.d.ts +3 -0
  5. package/dist/core/create-core-services.d.ts.map +1 -1
  6. package/dist/core/create-core-services.js +25 -1
  7. package/dist/core/create-core-services.js.map +1 -1
  8. package/dist/core/lifecycle.d.ts +12 -0
  9. package/dist/core/lifecycle.d.ts.map +1 -1
  10. package/dist/core/lifecycle.js +10 -0
  11. package/dist/core/lifecycle.js.map +1 -1
  12. package/dist/core-config.d.ts +16 -0
  13. package/dist/core-config.d.ts.map +1 -1
  14. package/dist/core-config.js +17 -0
  15. package/dist/core-config.js.map +1 -1
  16. package/dist/index.d.ts +3 -2
  17. package/dist/index.d.ts.map +1 -1
  18. package/dist/index.js +13 -2
  19. package/dist/index.js.map +1 -1
  20. package/dist/modules/access/access.routes.d.ts.map +1 -1
  21. package/dist/modules/access/access.routes.js +4 -1
  22. package/dist/modules/access/access.routes.js.map +1 -1
  23. package/dist/modules/access/directory-sync-bot.d.ts.map +1 -1
  24. package/dist/modules/access/directory-sync-bot.js +7 -3
  25. package/dist/modules/access/directory-sync-bot.js.map +1 -1
  26. package/dist/modules/agent-instructions/agent-instructions.routes.d.ts +8 -1
  27. package/dist/modules/agent-instructions/agent-instructions.routes.d.ts.map +1 -1
  28. package/dist/modules/agent-instructions/agent-instructions.routes.js +8 -2
  29. package/dist/modules/agent-instructions/agent-instructions.routes.js.map +1 -1
  30. package/dist/modules/agent-instructions/compose.d.ts +38 -6
  31. package/dist/modules/agent-instructions/compose.d.ts.map +1 -1
  32. package/dist/modules/agent-instructions/compose.js +39 -6
  33. package/dist/modules/agent-instructions/compose.js.map +1 -1
  34. package/dist/modules/agent-instructions/index.d.ts +2 -1
  35. package/dist/modules/agent-instructions/index.d.ts.map +1 -1
  36. package/dist/modules/agent-instructions/index.js +2 -1
  37. package/dist/modules/agent-instructions/index.js.map +1 -1
  38. package/dist/modules/agent-instructions/shared-file-rules.d.ts +115 -0
  39. package/dist/modules/agent-instructions/shared-file-rules.d.ts.map +1 -0
  40. package/dist/modules/agent-instructions/shared-file-rules.js +272 -0
  41. package/dist/modules/agent-instructions/shared-file-rules.js.map +1 -0
  42. package/dist/modules/audit/agent-audit.service.d.ts.map +1 -1
  43. package/dist/modules/audit/agent-audit.service.js +4 -2
  44. package/dist/modules/audit/agent-audit.service.js.map +1 -1
  45. package/dist/modules/auth/account-erasure.service.d.ts.map +1 -1
  46. package/dist/modules/auth/account-erasure.service.js +57 -16
  47. package/dist/modules/auth/account-erasure.service.js.map +1 -1
  48. package/dist/modules/auth/auth.service.d.ts.map +1 -1
  49. package/dist/modules/auth/auth.service.js +25 -15
  50. package/dist/modules/auth/auth.service.js.map +1 -1
  51. package/dist/modules/code-mode/code-mode.tool.d.ts +20 -2
  52. package/dist/modules/code-mode/code-mode.tool.d.ts.map +1 -1
  53. package/dist/modules/code-mode/code-mode.tool.js +66 -35
  54. package/dist/modules/code-mode/code-mode.tool.js.map +1 -1
  55. package/dist/modules/database/connection.d.ts +16 -0
  56. package/dist/modules/database/connection.d.ts.map +1 -1
  57. package/dist/modules/database/connection.js +117 -0
  58. package/dist/modules/database/connection.js.map +1 -1
  59. package/dist/modules/database/core-schema.d.ts +296 -96
  60. package/dist/modules/database/core-schema.d.ts.map +1 -1
  61. package/dist/modules/database/core-schema.js +81 -31
  62. package/dist/modules/database/core-schema.js.map +1 -1
  63. package/dist/modules/database/migrate.d.ts +95 -1
  64. package/dist/modules/database/migrate.d.ts.map +1 -1
  65. package/dist/modules/database/migrate.js +390 -2
  66. package/dist/modules/database/migrate.js.map +1 -1
  67. package/dist/modules/kb-fs/locking-filesystem.d.ts +17 -0
  68. package/dist/modules/kb-fs/locking-filesystem.d.ts.map +1 -1
  69. package/dist/modules/kb-fs/locking-filesystem.js +29 -0
  70. package/dist/modules/kb-fs/locking-filesystem.js.map +1 -1
  71. package/dist/modules/mcp/mcp.service.d.ts +8 -0
  72. package/dist/modules/mcp/mcp.service.d.ts.map +1 -1
  73. package/dist/modules/mcp/mcp.service.js +38 -8
  74. package/dist/modules/mcp/mcp.service.js.map +1 -1
  75. package/dist/modules/plugins/join-request-records.store.d.ts.map +1 -1
  76. package/dist/modules/plugins/join-request-records.store.js +7 -4
  77. package/dist/modules/plugins/join-request-records.store.js.map +1 -1
  78. package/dist/modules/tool-auth/external-api-key.service.d.ts.map +1 -1
  79. package/dist/modules/tool-auth/external-api-key.service.js +8 -2
  80. package/dist/modules/tool-auth/external-api-key.service.js.map +1 -1
  81. package/dist/modules/tool-manuals/tool-manuals.service.d.ts.map +1 -1
  82. package/dist/modules/tool-manuals/tool-manuals.service.js +30 -0
  83. package/dist/modules/tool-manuals/tool-manuals.service.js.map +1 -1
  84. package/dist/modules/tool-manuals/tool-manuals.tools.d.ts.map +1 -1
  85. package/dist/modules/tool-manuals/tool-manuals.tools.js +42 -31
  86. package/dist/modules/tool-manuals/tool-manuals.tools.js.map +1 -1
  87. package/dist/modules/tool-registry/description-length.d.ts +80 -0
  88. package/dist/modules/tool-registry/description-length.d.ts.map +1 -0
  89. package/dist/modules/tool-registry/description-length.js +108 -0
  90. package/dist/modules/tool-registry/description-length.js.map +1 -0
  91. package/dist/modules/workflow/git/git.service.d.ts +25 -0
  92. package/dist/modules/workflow/git/git.service.d.ts.map +1 -1
  93. package/dist/modules/workflow/git/git.service.js +40 -1
  94. package/dist/modules/workflow/git/git.service.js.map +1 -1
  95. package/dist/modules/workflow/pending-commits.service.d.ts.map +1 -1
  96. package/dist/modules/workflow/pending-commits.service.js +5 -1
  97. package/dist/modules/workflow/pending-commits.service.js.map +1 -1
  98. package/dist/modules/workflow/recovery-bot.d.ts.map +1 -1
  99. package/dist/modules/workflow/recovery-bot.js +7 -3
  100. package/dist/modules/workflow/recovery-bot.js.map +1 -1
  101. package/dist/modules/workflow/review-workflow/review-workflow.service.d.ts.map +1 -1
  102. package/dist/modules/workflow/review-workflow/review-workflow.service.js +12 -3
  103. package/dist/modules/workflow/review-workflow/review-workflow.service.js.map +1 -1
  104. package/dist/modules/workflow/workflow.service.d.ts.map +1 -1
  105. package/dist/modules/workflow/workflow.service.js +4 -1
  106. package/dist/modules/workflow/workflow.service.js.map +1 -1
  107. package/dist/modules/workspace/agent-upload.routes.d.ts +77 -0
  108. package/dist/modules/workspace/agent-upload.routes.d.ts.map +1 -0
  109. package/dist/modules/workspace/agent-upload.routes.js +210 -0
  110. package/dist/modules/workspace/agent-upload.routes.js.map +1 -0
  111. package/dist/modules/workspace/agent-upload.store.d.ts +284 -0
  112. package/dist/modules/workspace/agent-upload.store.d.ts.map +1 -0
  113. package/dist/modules/workspace/agent-upload.store.js +553 -0
  114. package/dist/modules/workspace/agent-upload.store.js.map +1 -0
  115. package/dist/modules/workspace/startup/steps/seed-tree.d.ts.map +1 -1
  116. package/dist/modules/workspace/startup/steps/seed-tree.js +3 -3
  117. package/dist/modules/workspace/startup/steps/seed-tree.js.map +1 -1
  118. package/dist/modules/workspace/startup/steps/template-source.d.ts +40 -0
  119. package/dist/modules/workspace/startup/steps/template-source.d.ts.map +1 -1
  120. package/dist/modules/workspace/startup/steps/template-source.js +46 -4
  121. package/dist/modules/workspace/startup/steps/template-source.js.map +1 -1
  122. package/dist/modules/workspace/upload-limits.d.ts +13 -0
  123. package/dist/modules/workspace/upload-limits.d.ts.map +1 -0
  124. package/dist/modules/workspace/upload-limits.js +13 -0
  125. package/dist/modules/workspace/upload-limits.js.map +1 -0
  126. package/dist/modules/workspace/workspace.routes.d.ts.map +1 -1
  127. package/dist/modules/workspace/workspace.routes.js +1 -1
  128. package/dist/modules/workspace/workspace.routes.js.map +1 -1
  129. package/dist/modules/workspace/workspace.service.d.ts +13 -0
  130. package/dist/modules/workspace/workspace.service.d.ts.map +1 -1
  131. package/dist/modules/workspace/workspace.service.js +61 -33
  132. package/dist/modules/workspace/workspace.service.js.map +1 -1
  133. package/dist/modules/workspace/workspace.tools.d.ts +11 -9
  134. package/dist/modules/workspace/workspace.tools.d.ts.map +1 -1
  135. package/dist/modules/workspace/workspace.tools.js +570 -134
  136. package/dist/modules/workspace/workspace.tools.js.map +1 -1
  137. package/dist/modules/workspace/write-denial.d.ts +0 -6
  138. package/dist/modules/workspace/write-denial.d.ts.map +1 -1
  139. package/dist/modules/workspace/write-denial.js +0 -6
  140. package/dist/modules/workspace/write-denial.js.map +1 -1
  141. package/dist/modules/workspace/zip-entry-rules.d.ts +114 -0
  142. package/dist/modules/workspace/zip-entry-rules.d.ts.map +1 -0
  143. package/dist/modules/workspace/zip-entry-rules.js +154 -0
  144. package/dist/modules/workspace/zip-entry-rules.js.map +1 -0
  145. package/dist/shared/column-crypto.d.ts +194 -0
  146. package/dist/shared/column-crypto.d.ts.map +1 -0
  147. package/dist/shared/column-crypto.js +144 -0
  148. package/dist/shared/column-crypto.js.map +1 -0
  149. package/dist/shared/token-crypto.d.ts.map +1 -1
  150. package/dist/shared/token-crypto.js +25 -1
  151. package/dist/shared/token-crypto.js.map +1 -1
  152. package/dist/tenancy/static-tenant-source.d.ts.map +1 -1
  153. package/dist/tenancy/static-tenant-source.js +1 -0
  154. package/dist/tenancy/static-tenant-source.js.map +1 -1
  155. package/dist/tenancy/tenant-secrets.d.ts +5 -1
  156. package/dist/tenancy/tenant-secrets.d.ts.map +1 -1
  157. package/dist/tenancy/tenant-secrets.js +4 -0
  158. package/dist/tenancy/tenant-secrets.js.map +1 -1
  159. package/kb-template/AGENTS.md +42 -0
  160. package/migrations/0016_pii_encryption.sql +20 -0
  161. package/migrations/meta/0016_snapshot.json +2327 -0
  162. package/migrations/meta/_journal.json +7 -0
  163. package/package.json +3 -3
  164. package/src/core/__tests__/lifecycle.test.ts +71 -4
  165. package/src/core/create-core-server.ts +21 -3
  166. package/src/core/create-core-services.ts +27 -1
  167. package/src/core/lifecycle.ts +19 -0
  168. package/src/core-config.ts +18 -0
  169. package/src/index.ts +25 -0
  170. package/src/modules/access/__tests__/users-db-double.ts +21 -12
  171. package/src/modules/access/access.routes.ts +4 -1
  172. package/src/modules/access/directory-sync-bot.ts +7 -3
  173. package/src/modules/agent-instructions/__tests__/agent-instructions.route.test.ts +8 -4
  174. package/src/modules/agent-instructions/__tests__/compose.test.ts +19 -10
  175. package/src/modules/agent-instructions/__tests__/shared-file-rules.test.ts +238 -0
  176. package/src/modules/agent-instructions/agent-instructions.routes.ts +12 -2
  177. package/src/modules/agent-instructions/compose.ts +50 -7
  178. package/src/modules/agent-instructions/index.ts +12 -0
  179. package/src/modules/agent-instructions/shared-file-rules.ts +314 -0
  180. package/src/modules/audit/agent-audit.service.ts +4 -2
  181. package/src/modules/auth/__tests__/account-deactivation.test.ts +2 -1
  182. package/src/modules/auth/__tests__/account-erasure.approval-gate.test.ts +6 -2
  183. package/src/modules/auth/__tests__/account.routes.test.ts +6 -3
  184. package/src/modules/auth/__tests__/auth.service.test.ts +3 -1
  185. package/src/modules/auth/account-erasure.service.ts +69 -18
  186. package/src/modules/auth/auth.service.ts +33 -23
  187. package/src/modules/code-mode/__tests__/chain-runtime.e2e.test.ts +335 -0
  188. package/src/modules/code-mode/__tests__/code-mode.tool.test.ts +46 -2
  189. package/src/modules/code-mode/code-mode.tool.ts +80 -34
  190. package/src/modules/database/__tests__/connection.test.ts +12 -0
  191. package/src/modules/database/__tests__/pii-backfill.pg.test.ts +780 -0
  192. package/src/modules/database/connection.ts +117 -0
  193. package/src/modules/database/core-schema.ts +81 -31
  194. package/src/modules/database/migrate.ts +540 -2
  195. package/src/modules/kb-fs/__tests__/locking-filesystem.test.ts +93 -0
  196. package/src/modules/kb-fs/locking-filesystem.ts +37 -0
  197. package/src/modules/mcp/__tests__/mcp.e2e.test.ts +4 -3
  198. package/src/modules/mcp/__tests__/mcp.service.test.ts +47 -7
  199. package/src/modules/mcp/mcp.service.ts +46 -7
  200. package/src/modules/plugins/join-request-records.store.ts +7 -4
  201. package/src/modules/tool-auth/external-api-key.service.ts +8 -2
  202. package/src/modules/tool-manuals/__tests__/tool-manuals.service.test.ts +151 -0
  203. package/src/modules/tool-manuals/tool-manuals.service.ts +37 -0
  204. package/src/modules/tool-manuals/tool-manuals.tools.ts +44 -31
  205. package/src/modules/tool-registry/__tests__/tool-description-length.test.ts +378 -0
  206. package/src/modules/tool-registry/description-length.ts +111 -0
  207. package/src/modules/workflow/git/__tests__/git.service.prFetchFailure.test.ts +170 -0
  208. package/src/modules/workflow/git/git.service.ts +47 -1
  209. package/src/modules/workflow/pending-commits.service.ts +5 -1
  210. package/src/modules/workflow/recovery-bot.ts +7 -3
  211. package/src/modules/workflow/review-workflow/__tests__/carry-approvals-forward.test.ts +4 -0
  212. package/src/modules/workflow/review-workflow/__tests__/erase-approver.test.ts +25 -3
  213. package/src/modules/workflow/review-workflow/review-workflow.service.ts +12 -3
  214. package/src/modules/workflow/workflow.service.ts +4 -1
  215. package/src/modules/workspace/__tests__/agent-uploads.test.ts +1604 -0
  216. package/src/modules/workspace/__tests__/escape-sequences.routes.test.ts +24 -14
  217. package/src/modules/workspace/__tests__/workspace.service.any-workspace-credentials.test.ts +156 -0
  218. package/src/modules/workspace/__tests__/workspace.service.replaced-repository.test.ts +77 -1
  219. package/src/modules/workspace/__tests__/workspace.service.test.ts +57 -0
  220. package/src/modules/workspace/__tests__/workspace.tools.agents-file.test.ts +39 -36
  221. package/src/modules/workspace/__tests__/workspace.tools.test.ts +196 -46
  222. package/src/modules/workspace/agent-upload.routes.ts +214 -0
  223. package/src/modules/workspace/agent-upload.store.ts +668 -0
  224. package/src/modules/workspace/startup/steps/__tests__/steps.test.ts +9 -9
  225. package/src/modules/workspace/startup/steps/seed-tree.ts +3 -6
  226. package/src/modules/workspace/startup/steps/template-source.ts +53 -5
  227. package/src/modules/workspace/upload-limits.ts +12 -0
  228. package/src/modules/workspace/workspace.routes.ts +1 -2
  229. package/src/modules/workspace/workspace.service.ts +63 -37
  230. package/src/modules/workspace/workspace.tools.ts +647 -148
  231. package/src/modules/workspace/write-denial.ts +0 -8
  232. package/src/modules/workspace/zip-entry-rules.ts +173 -0
  233. package/src/shared/__tests__/column-crypto.test.ts +217 -0
  234. package/src/shared/column-crypto.ts +218 -0
  235. package/src/shared/token-crypto.ts +28 -1
  236. package/src/tenancy/__tests__/static-tenant-source.test.ts +4 -0
  237. package/src/tenancy/static-tenant-source.ts +1 -0
  238. package/src/tenancy/tenant-secrets.ts +5 -1
@@ -2,6 +2,7 @@ import { sql } from 'drizzle-orm';
2
2
  import { drizzle } from 'drizzle-orm/node-postgres';
3
3
  import pg from 'pg';
4
4
  import * as schema from './schema.js';
5
+ import { PII_CIPHERTEXT_PREFIX, PiiParam, derivePiiKeys, type PiiKeys } from '../../shared/column-crypto.js';
5
6
 
6
7
  /**
7
8
  * One database, one schema per knowledge base.
@@ -74,6 +75,83 @@ export interface DbOptions {
74
75
  max?: number;
75
76
  /** How long an idle connection is kept before the pool closes it. Default: `pg`'s. */
76
77
  idleTimeoutMillis?: number;
78
+ /**
79
+ * The key this handle seals personal data with: the `SECRETS_ENC_KEY` of
80
+ * the knowledge base whose rows it holds (its own derived one, for a
81
+ * tenant). Without it the handle reads sealed values as their blobs and
82
+ * refuses to write one — right for a handle that only touches tables with
83
+ * no personal data, and a loud failure for one that was meant to have it.
84
+ */
85
+ piiKey?: string;
86
+ }
87
+
88
+ type QueryFn = (this: pg.Client, config?: unknown, values?: unknown, callback?: unknown) => unknown;
89
+ type ResultCallback = (err: unknown, result: unknown) => void;
90
+
91
+ /**
92
+ * The `pg` client of a handle that holds `keys`: the one place personal data
93
+ * changes form. Going in, every value a personal-data column marked (see
94
+ * `column-crypto.ts`) is replaced by its ciphertext or its blind index.
95
+ * Coming out, every sealed value of a result is opened — by its prefix, not
96
+ * by its column, so raw SQL through the handle reads plaintext too; a
97
+ * statement that needs what is STORED asks for it as bytes
98
+ * (`convert_to(col, 'UTF8')`).
99
+ *
100
+ * At the client, because that is what the handle owns: the pool builds every
101
+ * connection from this class, a transaction's included, so there is no path
102
+ * from this handle to the database that goes round it. Results that are
103
+ * streamed to a `Submittable` (a cursor) are not opened; nothing here uses one.
104
+ */
105
+ function keyedClient(keys: PiiKeys | null): typeof pg.Client {
106
+ const stored = (value: unknown): unknown => {
107
+ if (!(value instanceof PiiParam)) return value;
108
+ return keys ? value.stored(keys) : value.toPostgres();
109
+ };
110
+ const openCell = (value: unknown): unknown =>
111
+ typeof value === 'string' && value.startsWith(PII_CIPHERTEXT_PREFIX) ? keys!.read(value) : value;
112
+ const openOne = (result: unknown): void => {
113
+ const rows = (result as { rows?: unknown } | null)?.rows;
114
+ if (!Array.isArray(rows)) return;
115
+ for (const row of rows) {
116
+ if (Array.isArray(row)) {
117
+ for (let i = 0; i < row.length; i++) row[i] = openCell(row[i]);
118
+ } else if (row && typeof row === 'object') {
119
+ const record = row as Record<string, unknown>;
120
+ for (const column of Object.keys(record)) record[column] = openCell(record[column]);
121
+ }
122
+ }
123
+ };
124
+ const open = <T,>(result: T): T => {
125
+ if (!keys) return result;
126
+ // Several statements in one query string come back as several results.
127
+ if (Array.isArray(result)) result.forEach(openOne);
128
+ else openOne(result);
129
+ return result;
130
+ };
131
+
132
+ const base = pg.Client.prototype.query as unknown as QueryFn;
133
+ class KeyedClient extends pg.Client {}
134
+ const query: QueryFn = function (config, values, callback) {
135
+ if (typeof values === 'function') {
136
+ callback = values;
137
+ values = undefined;
138
+ }
139
+ if (Array.isArray(values)) {
140
+ values = values.map(stored);
141
+ } else if (config && typeof config === 'object' && Array.isArray((config as { values?: unknown }).values)) {
142
+ const withValues = config as { values: unknown[]; submit?: unknown };
143
+ if (typeof withValues.submit === 'function') withValues.values = withValues.values.map(stored);
144
+ else config = { ...withValues, values: withValues.values.map(stored) };
145
+ }
146
+ if (typeof callback === 'function') {
147
+ const done = callback as ResultCallback;
148
+ return base.call(this, config, values, (err: unknown, result: unknown) => done(err, err ? result : open(result)));
149
+ }
150
+ const pending = base.call(this, config, values);
151
+ return pending instanceof Promise ? pending.then(open) : pending;
152
+ };
153
+ Object.defineProperty(KeyedClient.prototype, 'query', { value: query, writable: true, configurable: true });
154
+ return KeyedClient;
77
155
  }
78
156
 
79
157
  /**
@@ -96,7 +174,11 @@ const CONNECT_TIMEOUT_MS = 30_000;
96
174
  */
97
175
  export function createDb(databaseUrl: string, opts: DbOptions = {}) {
98
176
  const dbSchema = assertSchemaName(opts.schema ?? DEFAULT_DB_SCHEMA);
177
+ // Only an ABSENT key means "this handle holds none". A blank or malformed
178
+ // one was meant to be a key, and is refused here rather than read as none.
179
+ const keys = opts.piiKey === undefined ? null : derivePiiKeys(opts.piiKey);
99
180
  const pool = new pg.Pool({
181
+ Client: keyedClient(keys),
100
182
  connectionString: databaseUrl,
101
183
  connectionTimeoutMillis: CONNECT_TIMEOUT_MS,
102
184
  max: opts.max,
@@ -109,6 +191,7 @@ export function createDb(databaseUrl: string, opts: DbOptions = {}) {
109
191
  });
110
192
  const db = drizzle(pool, { schema });
111
193
  schemas.set(db, dbSchema);
194
+ if (keys) piiKeys.set(db, { keys, of: opts.piiKey! });
112
195
  return db;
113
196
  }
114
197
 
@@ -119,7 +202,22 @@ export function dbSchemaOf(db: Pick<Database, '$client'>): string {
119
202
  return schemas.get(db as Database) ?? DEFAULT_DB_SCHEMA;
120
203
  }
121
204
 
205
+ /**
206
+ * The keys a handle seals personal data with — for the few callers that
207
+ * handle the stored form themselves (the backfill at start; an overlay's own
208
+ * SQL). Everything else never sees a key: the column types and the handle's
209
+ * connection do the work. Throws for a handle built without one.
210
+ */
211
+ export function piiKeysOf(db: Pick<Database, '$client'>): PiiKeys {
212
+ const held = piiKeys.get(db as Database);
213
+ if (!held) {
214
+ throw new Error('This database handle holds no personal-data key: build it with createDb/getDb and its piiKey.');
215
+ }
216
+ return held.keys;
217
+ }
218
+
122
219
  const schemas = new WeakMap<Database, string>();
220
+ const piiKeys = new WeakMap<Database, { keys: PiiKeys; of: string }>();
123
221
  const cache = new Map<string, Database>();
124
222
 
125
223
  const cacheKey = (databaseUrl: string, dbSchema: string) => `${dbSchema}\u0000${databaseUrl}`;
@@ -136,10 +234,29 @@ export function getDb(databaseUrl: string, opts: DbOptions = {}): Database {
136
234
  if (!db) {
137
235
  db = createDb(databaseUrl, opts);
138
236
  cache.set(key, db);
237
+ } else if (opts.piiKey !== undefined && !holdsKey(db, opts.piiKey)) {
238
+ // One schema is one knowledge base and one key. Handing back the cached
239
+ // pool would seal this caller's rows under the other caller's key.
240
+ throw new Error(`The database handle for schema "${dbSchema}" is already open with a different personal-data key.`);
139
241
  }
140
242
  return db;
141
243
  }
142
244
 
245
+ /**
246
+ * Whether `db` was opened with the key `piiKey` spells. The KEY, not its
247
+ * spelling: `SECRETS_ENC_KEY` may be written as hex or as base64, and one
248
+ * process can be handed the same 32 bytes both ways — refusing the second as
249
+ * "a different key" would refuse a caller that holds the right one. Compared
250
+ * through what the two derive, so no key material is held here to compare.
251
+ */
252
+ function holdsKey(db: Database, piiKey: string): boolean {
253
+ const held = piiKeys.get(db);
254
+ if (!held) return false;
255
+ if (held.of === piiKey) return true;
256
+ const probe = 'same-key?';
257
+ return held.keys.index(probe) === derivePiiKeys(piiKey).index(probe);
258
+ }
259
+
143
260
  /**
144
261
  * End a handle's pool and forget it, so a later {@link getDb} for the same
145
262
  * schema opens a fresh one rather than handing out a pool that was ended.
@@ -9,15 +9,32 @@
9
9
  * `enterprise-schema.ts`, which imports the FK targets (`users`,
10
10
  * `externalApiKeys`) from here. `schema.ts` re-exports both, so existing
11
11
  * imports keep working unchanged.
12
+ *
13
+ * PII columns (emails, display names, change-request/comment text) are
14
+ * `encryptedText` — AES-256-GCM ciphertext in the database, transparently
15
+ * decrypted on read (see `shared/column-crypto.ts`). Because the ciphertext is
16
+ * randomized, equality lookups and unique constraints on those columns go
17
+ * through their deterministic `*_bidx` companions (HMAC-SHA256 blind indexes,
18
+ * `blindIndexText`) — never `eq()` an encrypted column directly. A `*_bidx`
19
+ * column is written and compared WITH THE ADDRESS ITSELF; the database handle
20
+ * the statement runs on turns it into the index, under that handle's key.
12
21
  */
13
22
  import { sql } from 'drizzle-orm';
14
23
  import { boolean, check, index, integer, jsonb, pgTable, primaryKey, text, timestamp, uniqueIndex, uuid } from 'drizzle-orm/pg-core';
24
+ import { blindIndexText, encryptedText } from '../../shared/column-crypto.js';
15
25
 
16
26
  export const users = pgTable('users', {
17
27
  id: uuid('id').defaultRandom().primaryKey(),
18
- email: text('email').notNull().unique(),
19
- name: text('name').notNull(),
20
- avatarUrl: text('avatar_url'),
28
+ email: encryptedText('email').notNull(),
29
+ /**
30
+ * Blind index of the lowercased email — carries the uniqueness constraint
31
+ * and every lookup-by-email (login, upsert conflict target), which the
32
+ * randomized `email` ciphertext cannot. Write the email into it on every
33
+ * insert, and compare it with the email.
34
+ */
35
+ emailBidx: blindIndexText('email_bidx').notNull(),
36
+ name: encryptedText('name').notNull(),
37
+ avatarUrl: encryptedText('avatar_url'),
21
38
  /**
22
39
  * scrypt hash for password login (see auth/password-hash.ts). NULL for
23
40
  * accounts that only ever signed in via SSO — password login refuses them
@@ -52,7 +69,9 @@ export const users = pgTable('users', {
52
69
  deactivatedAt: timestamp('deactivated_at'),
53
70
  createdAt: timestamp('created_at').defaultNow().notNull(),
54
71
  updatedAt: timestamp('updated_at').defaultNow().notNull(),
55
- });
72
+ }, (t) => ({
73
+ emailBidxUnq: uniqueIndex('users_email_bidx_unq').on(t.emailBidx),
74
+ }));
56
75
 
57
76
  /**
58
77
  * Per-file owner approvals on a PR. A PR is mergeable-in-Bevel when every
@@ -69,15 +88,18 @@ export const prFileApprovals = pgTable('pr_file_approvals', {
69
88
  id: uuid('id').defaultRandom().primaryKey(),
70
89
  prNumber: integer('pr_number').notNull(),
71
90
  path: text('path').notNull(),
72
- approverEmail: text('approver_email').notNull(), // lowercased at insert
73
- approverName: text('approver_name').notNull(),
91
+ approverEmail: encryptedText('approver_email').notNull(), // lowercased at insert
92
+ /** Blind index of `approver_email` — the uniqueness key and eq() lookup. */
93
+ approverEmailBidx: blindIndexText('approver_email_bidx').notNull(),
94
+ approverName: encryptedText('approver_name').notNull(),
74
95
  headSha: text('head_sha').notNull(),
75
96
  approvedAt: timestamp('approved_at').defaultNow().notNull(),
76
97
  }, (t) => ({
77
98
  // Idempotency: approving the same path on the same SHA twice must be a no-op,
78
- // not a duplicate row. Unique on the (PR, path, approver, headSha) tuple.
79
- unq: uniqueIndex('pr_file_approvals_unq')
80
- .on(t.prNumber, t.path, t.approverEmail, t.headSha),
99
+ // not a duplicate row. Unique on the (PR, path, approver, headSha) tuple —
100
+ // via the blind index, because the encrypted email is randomized.
101
+ unq: uniqueIndex('pr_file_approvals_bidx_unq')
102
+ .on(t.prNumber, t.path, t.approverEmailBidx, t.headSha),
81
103
  byPr: index('pr_file_approvals_by_pr').on(t.prNumber),
82
104
  }));
83
105
 
@@ -91,12 +113,15 @@ export const prFileApprovals = pgTable('pr_file_approvals', {
91
113
  export const prMergeLog = pgTable('pr_merge_log', {
92
114
  id: uuid('id').defaultRandom().primaryKey(),
93
115
  prNumber: integer('pr_number').notNull(),
94
- triggeredByEmail: text('triggered_by_email').notNull(),
95
- triggeredByName: text('triggered_by_name').notNull(),
116
+ triggeredByEmail: encryptedText('triggered_by_email').notNull(),
117
+ /** Blind index of `triggered_by_email` — GDPR-erasure lookup. */
118
+ triggeredByEmailBidx: blindIndexText('triggered_by_email_bidx').notNull(),
119
+ triggeredByName: encryptedText('triggered_by_name').notNull(),
96
120
  headShaAtMerge: text('head_sha_at_merge').notNull(),
97
121
  mergeMethod: text('merge_method').notNull(),
98
122
  succeeded: boolean('succeeded').notNull(),
99
- error: text('error'),
123
+ // Encrypted: merge failure output can quote author identities and CR text.
124
+ error: encryptedText('error'),
100
125
  startedAt: timestamp('started_at').defaultNow().notNull(),
101
126
  completedAt: timestamp('completed_at'),
102
127
  }, (t) => ({
@@ -120,12 +145,15 @@ export const prMergeLog = pgTable('pr_merge_log', {
120
145
  export const prComments = pgTable('pr_comments', {
121
146
  id: uuid('id').defaultRandom().primaryKey(),
122
147
  prNumber: integer('pr_number').notNull(),
123
- authorEmail: text('author_email').notNull(),
124
- authorName: text('author_name').notNull(),
148
+ authorEmail: encryptedText('author_email').notNull(),
149
+ /** Blind index of `author_email` — GDPR-erasure lookup. */
150
+ authorEmailBidx: blindIndexText('author_email_bidx').notNull(),
151
+ authorName: encryptedText('author_name').notNull(),
125
152
  path: text('path'),
126
153
  line: integer('line'),
127
154
  headSha: text('head_sha').notNull(),
128
- body: text('body').notNull(),
155
+ // Encrypted: review conversation exists only in this DB (never on GitHub).
156
+ body: encryptedText('body').notNull(),
129
157
  parentId: uuid('parent_id'),
130
158
  createdAt: timestamp('created_at').defaultNow().notNull(),
131
159
  updatedAt: timestamp('updated_at'),
@@ -154,20 +182,36 @@ export const changeRequests = pgTable('change_requests', {
154
182
  number: integer('number').generatedAlwaysAsIdentity(),
155
183
  sourceBranch: text('source_branch').notNull(),
156
184
  targetBranch: text('target_branch').notNull(),
157
- title: text('title').notNull(),
158
- body: text('body').notNull().default(''),
159
- authorEmail: text('author_email').notNull(), // lowercased at insert
160
- authorName: text('author_name').notNull(),
185
+ // Encrypted: CR title/body exist only in this DB (the remote sees branches).
186
+ // The DB-level default '' bypasses toDriver, which is fine: the empty string
187
+ // is deliberately stored unencrypted (see encryptPii), so a defaulted row
188
+ // and an app-written empty body hold the identical representation.
189
+ title: encryptedText('title').notNull(),
190
+ body: encryptedText('body').notNull().default(''),
191
+ authorEmail: encryptedText('author_email').notNull(), // lowercased at insert
192
+ /** Blind index of `author_email` — GDPR-erasure lookup. */
193
+ authorEmailBidx: blindIndexText('author_email_bidx').notNull(),
194
+ authorName: encryptedText('author_name').notNull(),
161
195
  state: text('state').notNull().default('open'), // 'open' | 'merged' | 'closed'
162
196
  mergedSha: text('merged_sha'),
163
197
  // The last apply attempt that did not land (null when none, or once a gate
164
198
  // input it depended on changed). Persisted rather than only pushed to the
165
199
  // clicker so every viewer of the still-open request — its author first —
166
200
  // sees the refusal.
167
- applyFailureReason: text('apply_failure_reason'),
201
+ // Encrypted: the reason can quote identities the way the merge log's error
202
+ // does, and the name is a person's.
203
+ applyFailureReason: encryptedText('apply_failure_reason'),
168
204
  applyFailureConflicts: boolean('apply_failure_conflicts'),
169
205
  applyFailedAt: timestamp('apply_failed_at'),
170
- applyFailedByName: text('apply_failed_by_name'),
206
+ applyFailedByName: encryptedText('apply_failed_by_name'),
207
+ /**
208
+ * Blind index of the email of the person named above — what account erasure
209
+ * finds their name by. Written and cleared with the name, so a name never
210
+ * stands without it: a refusal recorded before the encryption release kept
211
+ * no address, and its name is taken off at the first start on this release
212
+ * (`clearUnindexedRefusalNames` in `migrate.ts`).
213
+ */
214
+ applyFailedByEmailBidx: blindIndexText('apply_failed_by_email_bidx'),
171
215
  /** What refused the last apply: 'gate' (approvals), 'conflicts' (git), 'error' (anything else). */
172
216
  applyFailureKind: text('apply_failure_kind'),
173
217
  createdAt: timestamp('created_at').defaultNow().notNull(),
@@ -270,7 +314,7 @@ export const fileLocks = pgTable('file_locks', {
270
314
  branch: text('branch').notNull(),
271
315
  path: text('path').notNull(),
272
316
  holderUserId: uuid('holder_user_id').notNull().references(() => users.id),
273
- holderName: text('holder_name').notNull(),
317
+ holderName: encryptedText('holder_name').notNull(),
274
318
  // How the lock was acquired. 'edit' (the default) is a normal write hold —
275
319
  // the holder is editing and the release publishes their bytes.
276
320
  // 'coordination' is a pure-mutex hold (see IWorkflowService.acquireLock)
@@ -331,8 +375,10 @@ export const pendingCommits = pgTable('pending_commits', {
331
375
  // Author attribution captured at enqueue. The denormalised name/email pair
332
376
  // lands on the eventual `git commit --author=` so the commit history shows
333
377
  // the human who triggered the save, not the worker.
334
- authorEmail: text('author_email').notNull(), // lowercased at insert
335
- authorName: text('author_name').notNull(),
378
+ authorEmail: encryptedText('author_email').notNull(), // lowercased at insert
379
+ /** Blind index of `author_email` — GDPR-erasure lookup. */
380
+ authorEmailBidx: blindIndexText('author_email_bidx').notNull(),
381
+ authorName: encryptedText('author_name').notNull(),
336
382
  queuedAt: timestamp('queued_at').defaultNow().notNull(),
337
383
  // `running` is set while the worker is mid-commit so a second worker
338
384
  // (e.g. after a restart with another instance still draining) doesn't
@@ -345,7 +391,8 @@ export const pendingCommits = pgTable('pending_commits', {
345
391
  // `needs_attention` + feedback notice instead of looping forever.
346
392
  recoveryAgentRuns: integer('recovery_agent_runs').notNull().default(0),
347
393
  lastAttemptedAt: timestamp('last_attempted_at'),
348
- lastError: text('last_error'),
394
+ // Encrypted: git/agent failure output can quote author identities and paths.
395
+ lastError: encryptedText('last_error'),
349
396
  }, (t) => ({
350
397
  // Primary read pattern: drain in queued order, scoped to a workspace.
351
398
  byWorkspaceQueued: index('pending_commits_by_workspace_queued').on(t.workspaceId, t.queuedAt),
@@ -718,13 +765,15 @@ export const githubFacadeCodes = pgTable('github_facade_codes', {
718
765
  */
719
766
  export const pluginJoinRequests = pgTable('plugin_join_requests', {
720
767
  id: uuid('id').defaultRandom().primaryKey(),
721
- requesterEmail: text('requester_email').notNull(), // lowercased at insert
768
+ requesterEmail: encryptedText('requester_email').notNull(), // lowercased at insert
769
+ /** Blind index of `requester_email` — the uniqueness key, the by-requester read and the erasure delete. */
770
+ requesterEmailBidx: blindIndexText('requester_email_bidx').notNull(),
722
771
  /** Denormalised for the commit/change-request authorship, like `file_locks.holder_name`. */
723
- requesterName: text('requester_name').notNull(),
772
+ requesterName: encryptedText('requester_name').notNull(),
724
773
  pluginKey: text('plugin_key').notNull(),
725
774
  status: text('status').notNull().default('pending'),
726
- /** What the git work said when it refused — shown to the requester verbatim. */
727
- failureReason: text('failure_reason'),
775
+ /** What the git work said when it refused — shown to the requester verbatim. Encrypted: git quotes identities. */
776
+ failureReason: encryptedText('failure_reason'),
728
777
  changeRequestNumber: integer('change_request_number'),
729
778
  /**
730
779
  * When a process took this row's git work, and the whole of the mutual
@@ -770,8 +819,9 @@ export const pluginJoinRequests = pgTable('plugin_join_requests', {
770
819
  }, (t) => ({
771
820
  // One request per person per plugin — the DB's rule, not a caller's. Also
772
821
  // the index the plugin listing's by-requester read is served from.
773
- requesterPluginUnq: uniqueIndex('plugin_join_requests_requester_plugin_unq')
774
- .on(t.requesterEmail, t.pluginKey),
822
+ // Via the blind index, because the encrypted email is randomized.
823
+ requesterPluginUnq: uniqueIndex('plugin_join_requests_requester_bidx_plugin_unq')
824
+ .on(t.requesterEmailBidx, t.pluginKey),
775
825
  // The boot sweep: every row still `pending`, without a full scan.
776
826
  byStatus: index('plugin_join_requests_by_status').on(t.status),
777
827
  statusCheck: check(