@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
@@ -0,0 +1,780 @@
1
+ import { afterAll, afterEach, describe, expect, it, vi } from 'vitest';
2
+ import { randomBytes } from 'node:crypto';
3
+ import pg from 'pg';
4
+ import { eq, inArray, sql } from 'drizzle-orm';
5
+ import { migrate } from 'drizzle-orm/node-postgres/migrator';
6
+ import { closeDb, createDb, type Database } from '../connection.js';
7
+ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
8
+ import { tmpdir } from 'node:os';
9
+ import path from 'node:path';
10
+ import { runCoreMigrations, runEnterpriseMigrations, runPiiBackfill, type PiiBackfillSpec, type PiiBlindIndex } from '../migrate.js';
11
+ import { prFileApprovals, users } from '../core-schema.js';
12
+ import { coreMigrationsDir } from '../../../assets.js';
13
+ import { PII_CIPHERTEXT_PREFIX, derivePiiKeys, isEncryptedBlob } from '../../../shared/column-crypto.js';
14
+
15
+ /**
16
+ * Personal data in a real Postgres: what a database handle stores under its
17
+ * key, and the backfill that seals what an older version left in clear. The
18
+ * backfill rewrites every personal-data row of a deployment once, with no
19
+ * second chance, and deletes rows on the way (the duplicate collapse) — so
20
+ * what it does is pinned here through the one entry point a start uses,
21
+ * `runCoreMigrations`, and read back over a plain connection that holds no
22
+ * key, which sees what a dump would.
23
+ *
24
+ * Runs where `TEST_DATABASE_URL` names a server this role may create
25
+ * databases on (CI carries one as a service container); skipped elsewhere.
26
+ * Every test works in a scratch database of its own and drops it: the
27
+ * database the URL names is only connected to, never written.
28
+ */
29
+ const ADMIN_URL = process.env.TEST_DATABASE_URL;
30
+ const TIMEOUT = 120_000;
31
+ const KEY = randomBytes(32).toString('base64');
32
+ const keys = derivePiiKeys(KEY);
33
+
34
+ const TABLES = [
35
+ 'users',
36
+ 'pr_file_approvals',
37
+ 'pr_merge_log',
38
+ 'pr_comments',
39
+ 'change_requests',
40
+ 'pending_commits',
41
+ 'file_locks',
42
+ 'plugin_join_requests',
43
+ ] as const;
44
+
45
+ type Row = Record<string, unknown>;
46
+ type Query = (text: string) => Promise<Row[]>;
47
+
48
+ const created: string[] = [];
49
+ const open: Database[] = [];
50
+ const plain: pg.Pool[] = [];
51
+ /** Temporary folders a test made (an overlay's migration history); removed in `afterAll`. */
52
+ const folders: string[] = [];
53
+
54
+ async function withAdmin<T>(fn: (admin: pg.Client) => Promise<T>): Promise<T> {
55
+ const admin = new pg.Client({ connectionString: ADMIN_URL });
56
+ await admin.connect();
57
+ try {
58
+ return await fn(admin);
59
+ } finally {
60
+ await admin.end();
61
+ }
62
+ }
63
+
64
+ /** A scratch database of this test's own; dropped in `afterAll`. */
65
+ async function scratchDatabase(): Promise<string> {
66
+ const name = `hexis_pii_${randomBytes(6).toString('hex')}`;
67
+ await withAdmin((admin) => admin.query(`create database ${name}`));
68
+ created.push(name);
69
+ const url = new URL(ADMIN_URL!);
70
+ url.pathname = `/${name}`;
71
+ return url.toString();
72
+ }
73
+
74
+ /** The application's handle on a schema, holding `key`. */
75
+ function handle(url: string, opts: { schema?: string; key?: string | null } = {}): Database {
76
+ const piiKey = opts.key === null ? undefined : (opts.key ?? KEY);
77
+ const db = createDb(url, { ...(opts.schema ? { schema: opts.schema } : {}), ...(piiKey ? { piiKey } : {}) });
78
+ open.push(db);
79
+ return db;
80
+ }
81
+
82
+ /** What is STORED, read over a connection that holds no key — as a dump would show it. */
83
+ function stored(url: string, schema?: string): Query {
84
+ const pool = new pg.Pool({ connectionString: url, ...(schema ? { options: `-c search_path=${schema}` } : {}) });
85
+ plain.push(pool);
86
+ return async (text) => (await pool.query(text)).rows as Row[];
87
+ }
88
+
89
+ /**
90
+ * A schema as an upgrade finds it: the SQL history applied — migration
91
+ * 0016's nullable blind-index columns included — and nothing sealed yet. That
92
+ * is the state between the history and the backfill, and the one rows of an
93
+ * older version can be planted in.
94
+ */
95
+ async function beforeTheBackfill(
96
+ url: string,
97
+ opts: { schema?: string; key?: string } = {},
98
+ ): Promise<{ db: Database; q: Query }> {
99
+ const db = handle(url, opts);
100
+ if (opts.schema) await db.execute(sql.raw(`create schema if not exists "${opts.schema}"`));
101
+ await migrate(db, {
102
+ migrationsFolder: coreMigrationsDir(),
103
+ migrationsTable: '__drizzle_migrations_core',
104
+ migrationsSchema: opts.schema,
105
+ });
106
+ return { db, q: stored(url, opts.schema) };
107
+ }
108
+
109
+ /** Rows as an older version wrote them: plaintext, no blind index. */
110
+ async function plantLegacyRows(q: Query): Promise<void> {
111
+ await q(`insert into users (email, name, avatar_url) values
112
+ ('Ada@Example.com', 'Ada', null),
113
+ ('bo@example.com', 'Bo', 'https://img.example.com/bo.png')`);
114
+ await q(`insert into change_requests
115
+ (source_branch, target_branch, title, author_email, author_name, apply_failure_reason, apply_failed_by_name, apply_failed_at)
116
+ values ('d', 'main', 'A title', 'ada@example.com', 'Ada', 'bo@example.com is not an approver', 'Bo', now())`);
117
+ await q(`insert into pr_merge_log (pr_number, triggered_by_email, triggered_by_name, head_sha_at_merge, merge_method, succeeded, error)
118
+ values (1, 'ada@example.com', 'Ada', 'abc', 'merge', true, null),
119
+ (1, 'bo@example.com', 'Bo', 'abc', 'merge', false, 'Bo has no push right')`);
120
+ await q(`insert into pr_comments (pr_number, author_email, author_name, head_sha, body)
121
+ values (1, 'bo@example.com', 'Bo', 'abc', 'Looks right to me')`);
122
+ await q(`insert into pending_commits (workspace_id, branch, path, author_email, author_name, last_error)
123
+ values ('w', 'main', 'a.md', 'ada@example.com', 'Ada', 'rejected for Ada')`);
124
+ await q(`insert into file_locks (workspace_id, branch, path, holder_user_id, holder_name, expires_at)
125
+ select 'w', 'main', 'a.md', id, 'Ada', now() + interval '1 hour' from users where email = 'Ada@Example.com'`);
126
+ // One person's approval and join request, twice, under two spellings of
127
+ // the address: distinct rows to the old unique index, one row to the new.
128
+ await q(`insert into pr_file_approvals (pr_number, path, approver_email, approver_name, head_sha, approved_at)
129
+ values (1, 'a.md', 'ada@example.com', 'Ada', 'abc', now() - interval '2 hours'),
130
+ (1, 'a.md', 'ADA@example.com', 'Ada later', 'abc', now() - interval '1 hour')`);
131
+ await q(`insert into plugin_join_requests (requester_email, requester_name, plugin_key, failure_reason, created_at)
132
+ values ('ada@example.com', 'Ada', 'gtm', 'git refused Ada', now() - interval '2 hours'),
133
+ ('Ada@example.com', 'Ada later', 'gtm', null, now() - interval '1 hour')`);
134
+ }
135
+
136
+ /** Every row of every table the backfill touches, as stored. */
137
+ async function everything(q: Query): Promise<string> {
138
+ const out: Record<string, unknown> = {};
139
+ for (const table of TABLES) {
140
+ out[table] = (await q(`select to_jsonb(t) as row from "${table}" t order by to_jsonb(t)::text`)).map((r) => r.row);
141
+ }
142
+ return JSON.stringify(out);
143
+ }
144
+
145
+ /** {@link everything} with the blobs masked: random base64 spells a planted name by chance often enough. */
146
+ const everythingReadable = async (q: Query) => (await everything(q)).replace(/"pii:v1:[^"]*"/g, '"sealed"');
147
+
148
+ const opened = (value: unknown) => keys.read(String(value));
149
+ const sealed = (value: unknown) => typeof value === 'string' && isEncryptedBlob(value);
150
+
151
+ describe.skipIf(!ADMIN_URL)('personal data, on a real Postgres', () => {
152
+ afterEach(async () => {
153
+ await Promise.all(open.splice(0).map((db) => closeDb(db)));
154
+ await Promise.all(plain.splice(0).map((pool) => pool.end()));
155
+ });
156
+
157
+ // With the tests' own timeout: every test leaves a scratch database, and
158
+ // dropping them one after the other outlasts the default for a hook.
159
+ afterAll(async () => {
160
+ await withAdmin(async (admin) => {
161
+ for (const name of created) await admin.query(`drop database if exists ${name} with (force)`);
162
+ });
163
+ for (const folder of folders) rmSync(folder, { recursive: true, force: true });
164
+ }, TIMEOUT);
165
+
166
+ describe('what a handle stores', () => {
167
+ it(
168
+ 'seals and indexes what the application writes, and reads it back, a transaction included',
169
+ async () => {
170
+ const url = await scratchDatabase();
171
+ const db = handle(url);
172
+ const q = stored(url);
173
+ await runCoreMigrations(db, coreMigrationsDir());
174
+
175
+ await db.insert(users).values({ email: 'Ada@Example.com', emailBidx: 'Ada@Example.com', name: 'Ada' });
176
+ await db.transaction(async (tx) => {
177
+ await tx.insert(users).values({ email: 'bo@example.com', emailBidx: 'bo@example.com', name: 'Bo', avatarUrl: '' });
178
+ // Inside the transaction the handle's key is the one at work too.
179
+ const [bo] = await tx.select().from(users).where(eq(users.emailBidx, ' BO@example.com'));
180
+ expect(bo).toMatchObject({ email: 'bo@example.com', name: 'Bo', avatarUrl: '' });
181
+ });
182
+
183
+ const rows = await q(`select email, email_bidx, name, avatar_url from users order by created_at`);
184
+ expect(rows.every((r) => sealed(r.email) && sealed(r.name))).toBe(true);
185
+ expect(rows.map((r) => r.email_bidx)).toEqual([keys.index('ada@example.com'), keys.index('bo@example.com')]);
186
+ // NULL stays NULL and the empty string stays itself.
187
+ expect(rows.map((r) => r.avatar_url)).toEqual([null, '']);
188
+
189
+ const found = await db.select().from(users).where(inArray(users.emailBidx, ['ADA@example.com', 'nobody@x.co']));
190
+ expect(found).toHaveLength(1);
191
+ expect(found[0]).toMatchObject({ email: 'Ada@Example.com', name: 'Ada', avatarUrl: null });
192
+
193
+ await db.update(users).set({ name: 'Ada L.' }).where(eq(users.emailBidx, 'ada@example.com'));
194
+ const [renamed] = await q(`select name from users where email_bidx = '${keys.index('ada@example.com')}'`);
195
+ expect(sealed(renamed!.name)).toBe(true);
196
+ expect(opened(renamed!.name)).toBe('Ada L.');
197
+
198
+ // A copied row carries the address its index is made from; the stored
199
+ // index read back beside it is refused as a value to write.
200
+ await db.insert(prFileApprovals).values({
201
+ prNumber: 1, path: 'a.md', approverEmail: 'ada@example.com', approverEmailBidx: 'ada@example.com', approverName: 'Ada', headSha: 'abc',
202
+ });
203
+ const [approval] = await db.select().from(prFileApprovals);
204
+ expect(approval!.approverEmailBidx).toBe(keys.index('ada@example.com'));
205
+ await expect(async () => {
206
+ await db.insert(prFileApprovals).values({ ...approval!, id: undefined, headSha: 'def' });
207
+ }).rejects.toThrow(/stored index/);
208
+
209
+ // Raw SQL through the handle reads plaintext as well; the stored form
210
+ // is there for whoever asks for the bytes.
211
+ const [viaSql] = (
212
+ await db.execute(sql`select email, convert_to(email, 'UTF8') as kept from users where email_bidx = ${keys.index('bo@example.com')}`)
213
+ ).rows as Array<{ email: string; kept: Buffer }>;
214
+ expect(viaSql!.email).toBe('bo@example.com');
215
+ expect(sealed(viaSql!.kept.toString('utf8'))).toBe(true);
216
+ },
217
+ TIMEOUT,
218
+ );
219
+
220
+ it(
221
+ 'a handle that holds no key writes no personal data, and is refused a start',
222
+ async () => {
223
+ const url = await scratchDatabase();
224
+ await runCoreMigrations(handle(url), coreMigrationsDir());
225
+ const keyless = handle(url, { key: null });
226
+ const q = stored(url);
227
+
228
+ // drizzle reports the statement that failed; the reason is its cause.
229
+ const refused = { cause: { message: expect.stringMatching(/holds no key/) } };
230
+ await expect(
231
+ keyless.insert(users).values({ email: 'ada@example.com', emailBidx: 'ada@example.com', name: 'Ada' }),
232
+ ).rejects.toMatchObject(refused);
233
+ await expect(
234
+ keyless.select().from(users).where(eq(users.emailBidx, 'ada@example.com')),
235
+ ).rejects.toMatchObject(refused);
236
+ expect(await q(`select 1 from users`)).toHaveLength(0);
237
+ await expect(runCoreMigrations(keyless, coreMigrationsDir())).rejects.toThrow(/holds no personal-data key/);
238
+ // "No key" is the key left out. A blank or malformed one was meant to
239
+ // be a key, and no handle is built with it.
240
+ expect(() => createDb(url, { piiKey: '' })).toThrow(/SECRETS_ENC_KEY/);
241
+ expect(() => createDb(url, { piiKey: 'not-a-key' })).toThrow(/SECRETS_ENC_KEY/);
242
+ },
243
+ TIMEOUT,
244
+ );
245
+
246
+ it(
247
+ 'two knowledge bases in one database are sealed under their own keys',
248
+ async () => {
249
+ const url = await scratchDatabase();
250
+ const acmeKey = randomBytes(32).toString('base64');
251
+ const zetaKey = randomBytes(32).toString('base64');
252
+ const acme = handle(url, { schema: 't_acme', key: acmeKey });
253
+ const zeta = handle(url, { schema: 't_zeta', key: zetaKey });
254
+ for (const [db, schema] of [[acme, 't_acme'], [zeta, 't_zeta']] as const) {
255
+ await db.execute(sql.raw(`create schema if not exists "${schema}"`));
256
+ await runCoreMigrations(db, coreMigrationsDir());
257
+ await db.insert(users).values({ email: 'ada@example.com', emailBidx: 'ada@example.com', name: `Ada of ${schema}` });
258
+ }
259
+
260
+ // The same address, a different index in each: knowing one tenant's
261
+ // says nothing about the other's.
262
+ const q = stored(url);
263
+ const [a] = await q(`select email, email_bidx from t_acme.users`);
264
+ const [z] = await q(`select email, email_bidx from t_zeta.users`);
265
+ expect(a!.email_bidx).toBe(derivePiiKeys(acmeKey).index('ada@example.com'));
266
+ expect(z!.email_bidx).toBe(derivePiiKeys(zetaKey).index('ada@example.com'));
267
+ expect(a!.email_bidx).not.toBe(z!.email_bidx);
268
+
269
+ // Each reads its own, in the same process, at the same time.
270
+ const [[mine], [theirs]] = await Promise.all([
271
+ acme.select().from(users).where(eq(users.emailBidx, 'ada@example.com')),
272
+ zeta.select().from(users).where(eq(users.emailBidx, 'ada@example.com')),
273
+ ]);
274
+ expect(mine!.name).toBe('Ada of t_acme');
275
+ expect(theirs!.name).toBe('Ada of t_zeta');
276
+
277
+ // And neither opens the other's, even handed the row.
278
+ const [crossed] = (await acme.execute(sql`select name from t_zeta.users`)).rows as Array<{ name: string }>;
279
+ expect(sealed(crossed!.name)).toBe(true);
280
+ // A tenant's dump opens with the tenant's key alone.
281
+ expect(derivePiiKeys(zetaKey).read(String(z!.email))).toBe('ada@example.com');
282
+ },
283
+ TIMEOUT,
284
+ );
285
+ });
286
+
287
+ describe('the backfill at start', () => {
288
+ it(
289
+ 'seals what an older version wrote, indexes it, and moves the unique constraints',
290
+ async () => {
291
+ const { db, q } = await beforeTheBackfill(await scratchDatabase());
292
+ await plantLegacyRows(q);
293
+
294
+ await runCoreMigrations(db, coreMigrationsDir());
295
+
296
+ // users: ciphertext with the spelling kept, the index over the canonical
297
+ // address, and a NULL left a NULL.
298
+ const people = await q(`select email, email_bidx, name, avatar_url from users`);
299
+ expect(people.every((u) => sealed(u.email) && sealed(u.name))).toBe(true);
300
+ const ada = people.find((u) => opened(u.email) === 'Ada@Example.com')!;
301
+ const bo = people.find((u) => opened(u.email) === 'bo@example.com')!;
302
+ expect(ada.email_bidx).toBe(keys.index('ada@example.com'));
303
+ expect(ada.avatar_url).toBeNull();
304
+ expect(opened(bo.avatar_url)).toBe('https://img.example.com/bo.png');
305
+ expect(sealed(bo.avatar_url)).toBe(true);
306
+
307
+ // change_requests: the text, the author, the recorded refusal; the
308
+ // body the database defaulted to '' stays ''. The NAME on the refusal
309
+ // goes: an older version kept no address beside it, so nothing could
310
+ // ever find it again for the person it names.
311
+ const [cr] = await q(`select * from change_requests`);
312
+ expect([cr!.title, cr!.author_email, cr!.author_name, cr!.apply_failure_reason].every(sealed)).toBe(true);
313
+ expect(cr!.apply_failed_by_name).toBeNull();
314
+ expect(cr!.apply_failed_by_email_bidx).toBeNull();
315
+ expect(cr!.apply_failed_at).not.toBeNull();
316
+ expect(cr!.body).toBe('');
317
+ expect(opened(cr!.apply_failure_reason)).toBe('bo@example.com is not an approver');
318
+ expect(cr!.author_email_bidx).toBe(keys.index('ada@example.com'));
319
+
320
+ const log = await q(`select * from pr_merge_log`);
321
+ expect(log.every((r) => sealed(r.triggered_by_email) && sealed(r.triggered_by_name))).toBe(true);
322
+ expect(log.map((r) => (r.error === null ? null : opened(r.error))).sort()).toEqual(['Bo has no push right', null]);
323
+ expect(log.every((r) => r.error === null || sealed(r.error))).toBe(true);
324
+ expect(log.every((r) => r.triggered_by_email_bidx === keys.index(opened(r.triggered_by_email)))).toBe(true);
325
+
326
+ const [comment] = await q(`select * from pr_comments`);
327
+ expect([comment!.author_email, comment!.author_name, comment!.body].every(sealed)).toBe(true);
328
+ expect(comment!.author_email_bidx).toBe(keys.index('bo@example.com'));
329
+
330
+ const [queued] = await q(`select * from pending_commits`);
331
+ expect([queued!.author_email, queued!.author_name, queued!.last_error].every(sealed)).toBe(true);
332
+ expect(queued!.author_email_bidx).toBe(keys.index('ada@example.com'));
333
+
334
+ const [lock] = await q(`select * from file_locks`);
335
+ expect(sealed(lock!.holder_name)).toBe(true);
336
+ expect(opened(lock!.holder_name)).toBe('Ada');
337
+
338
+ // The two spellings were one person's: the earliest row of each stays.
339
+ const approvals = await q(`select * from pr_file_approvals`);
340
+ expect(approvals).toHaveLength(1);
341
+ expect(opened(approvals[0]!.approver_name)).toBe('Ada');
342
+ expect(approvals[0]!.approver_email_bidx).toBe(keys.index('ada@example.com'));
343
+ const requests = await q(`select * from plugin_join_requests`);
344
+ expect(requests).toHaveLength(1);
345
+ expect(opened(requests[0]!.requester_name)).toBe('Ada');
346
+ expect(opened(requests[0]!.failure_reason)).toBe('git refused Ada');
347
+ expect(requests[0]!.requester_email_bidx).toBe(keys.index('ada@example.com'));
348
+
349
+ // Nothing a dump would show names anyone.
350
+ expect(await everythingReadable(q)).not.toMatch(/example\.com|Ada|Bo /);
351
+
352
+ // Uniqueness moved from the raw columns to the blind indexes.
353
+ const indexes = (await q(`select indexname from pg_indexes where schemaname = current_schema()`)).map(
354
+ (r) => r.indexname,
355
+ );
356
+ expect(indexes).toEqual(
357
+ expect.arrayContaining([
358
+ 'users_email_bidx_unq',
359
+ 'pr_file_approvals_bidx_unq',
360
+ 'plugin_join_requests_requester_bidx_plugin_unq',
361
+ ]),
362
+ );
363
+ expect(indexes).not.toContain('users_email_unique');
364
+ expect(indexes).not.toContain('pr_file_approvals_unq');
365
+ expect(indexes).not.toContain('plugin_join_requests_requester_plugin_unq');
366
+ await expect(
367
+ q(`insert into users (email, email_bidx, name) values ('x', '${keys.index('ADA@example.com')}', 'x')`),
368
+ ).rejects.toThrow();
369
+ await expect(q(`insert into users (email, name) values ('x', 'x')`)).rejects.toThrow();
370
+
371
+ // And the application reads it back as it was written.
372
+ const [read] = await db.select().from(users).where(eq(users.emailBidx, 'ADA@EXAMPLE.COM '));
373
+ expect(read).toMatchObject({ email: 'Ada@Example.com', name: 'Ada', avatarUrl: null });
374
+ },
375
+ TIMEOUT,
376
+ );
377
+
378
+ /**
379
+ * A blob is recognised by its shape, and a title or a name is text a
380
+ * person chose. Before the first backfill nothing is sealed, so a value in
381
+ * that shape is plaintext: trusted, it stayed in clear for good, and the
382
+ * one the key check sampled refused the start under the right key.
383
+ */
384
+ it(
385
+ 'seals a value an older version stored in the very shape of a sealed one, and starts',
386
+ async () => {
387
+ const { db, q } = await beforeTheBackfill(await scratchDatabase());
388
+ await plantLegacyRows(q);
389
+ // Shaped exactly like a blob, under no key at all — and the same with
390
+ // its tag written unpadded, which the two predicates used to disagree
391
+ // about.
392
+ const exact = `pii:v1:${'A'.repeat(16)}:${'B'.repeat(22)}==:Q2xlYXI=`;
393
+ const unpadded = `pii:v1:${'A'.repeat(16)}:${'B'.repeat(22)}:Q2xlYXI=`;
394
+ await q(`update users set name = '${exact}' where email = 'bo@example.com'`);
395
+ await q(`update pr_comments set body = '${unpadded}'`);
396
+ await q(`update file_locks set holder_name = '${exact}'`);
397
+
398
+ await runCoreMigrations(db, coreMigrationsDir());
399
+
400
+ const [bo] = await q(`select name from users where email_bidx = '${keys.index('bo@example.com')}'`);
401
+ expect(bo!.name).not.toBe(exact);
402
+ expect(opened(bo!.name)).toBe(exact);
403
+ const [comment] = await q(`select body from pr_comments`);
404
+ expect(comment!.body).not.toBe(unpadded);
405
+ expect(opened(comment!.body)).toBe(unpadded);
406
+ const [lock] = await q(`select holder_name from file_locks`);
407
+ expect(opened(lock!.holder_name)).toBe(exact);
408
+ // And what the application reads back is what was typed.
409
+ const [read] = await db.select().from(users).where(eq(users.emailBidx, 'bo@example.com'));
410
+ expect(read!.name).toBe(exact);
411
+ // A second start finds nothing left to do, and is not refused.
412
+ const after = await everything(q);
413
+ await runCoreMigrations(db, coreMigrationsDir());
414
+ expect(await everything(q)).toBe(after);
415
+ },
416
+ TIMEOUT,
417
+ );
418
+
419
+ it(
420
+ 'seals a table larger than one batch, every row of it',
421
+ async () => {
422
+ const { db, q } = await beforeTheBackfill(await scratchDatabase());
423
+ await plantLegacyRows(q);
424
+ await q(`insert into pr_comments (pr_number, author_email, author_name, head_sha, body)
425
+ select n, 'ada@example.com', 'Ada', 'abc', 'comment ' || n from generate_series(2, 1300) n`);
426
+
427
+ await runCoreMigrations(db, coreMigrationsDir());
428
+
429
+ const [left] = await q(`select count(*)::int as n from pr_comments where body not like 'pii:v1:%' or author_email_bidx is null`);
430
+ expect(left!.n).toBe(0);
431
+ const [all] = await q(`select count(*)::int as n from pr_comments`);
432
+ expect(all!.n).toBe(1300);
433
+ const [one] = await q(`select body from pr_comments where pr_number = 1300`);
434
+ expect(opened(one!.body)).toBe('comment 1300');
435
+ },
436
+ TIMEOUT,
437
+ );
438
+
439
+ it(
440
+ 'keeps the name on a refusal that has its index, and takes it off one that has none',
441
+ async () => {
442
+ const { db, q } = await beforeTheBackfill(await scratchDatabase());
443
+ await plantLegacyRows(q);
444
+ await runCoreMigrations(db, coreMigrationsDir());
445
+ // Recorded on this release: the name with the index of its address.
446
+ await q(`update change_requests set apply_failed_by_name = '${keys.seal('Bo')}', apply_failed_by_email_bidx = '${keys.index('bo@example.com')}'`);
447
+ // And one an older version, still running, wrote beside it: a name alone.
448
+ await q(`insert into change_requests (source_branch, target_branch, title, author_email, author_email_bidx, author_name, apply_failure_reason, apply_failed_by_name, apply_failed_at)
449
+ values ('e', 'main', '${keys.seal('Another')}', '${keys.seal('ada@example.com')}', '${keys.index('ada@example.com')}', '${keys.seal('Ada')}', 'refused', 'Bo', now())`);
450
+
451
+ await runCoreMigrations(db, coreMigrationsDir());
452
+
453
+ const rows = await q(`select source_branch, apply_failed_by_name, apply_failure_reason from change_requests order by source_branch`);
454
+ expect(opened(rows[0]!.apply_failed_by_name)).toBe('Bo');
455
+ expect(rows[1]!.apply_failed_by_name).toBeNull();
456
+ expect(opened(rows[1]!.apply_failure_reason)).toBe('refused');
457
+ },
458
+ TIMEOUT,
459
+ );
460
+
461
+ it(
462
+ 'writes nothing on a second start',
463
+ async () => {
464
+ const { db, q } = await beforeTheBackfill(await scratchDatabase());
465
+ await plantLegacyRows(q);
466
+ await runCoreMigrations(db, coreMigrationsDir());
467
+ const after = await everything(q);
468
+
469
+ await runCoreMigrations(db, coreMigrationsDir());
470
+
471
+ expect(await everything(q)).toBe(after);
472
+ },
473
+ TIMEOUT,
474
+ );
475
+
476
+ it(
477
+ 'stops the start on two accounts with one address, names both, and writes nothing',
478
+ async () => {
479
+ const { db, q } = await beforeTheBackfill(await scratchDatabase());
480
+ await plantLegacyRows(q);
481
+ await q(`insert into users (email, name) values ('BO@example.com', 'Bo too')`);
482
+ const before = await everything(q);
483
+ const ids = (await q(`select id from users where lower(email) = 'bo@example.com'`)).map((r) => String(r.id));
484
+
485
+ const refusal = await runCoreMigrations(db, coreMigrationsDir()).then(
486
+ () => '',
487
+ (err: Error) => err.message,
488
+ );
489
+
490
+ expect(refusal).toMatch(/share the same email/);
491
+ for (const id of ids) expect(refusal).toContain(id);
492
+ // One transaction: the rows already rewritten when the conflict was
493
+ // found are rolled back with it, the duplicate collapse included.
494
+ expect(await everything(q)).toBe(before);
495
+ },
496
+ TIMEOUT,
497
+ );
498
+
499
+ it(
500
+ 'refuses a start under a key that does not open what is sealed, and changes nothing',
501
+ async () => {
502
+ const url = await scratchDatabase();
503
+ const { db, q } = await beforeTheBackfill(url);
504
+ await plantLegacyRows(q);
505
+ await runCoreMigrations(db, coreMigrationsDir());
506
+ const after = await everything(q);
507
+
508
+ const rekeyed = handle(url, { key: randomBytes(32).toString('base64') });
509
+ await expect(runCoreMigrations(rekeyed, coreMigrationsDir())).rejects.toThrow(/does not open/);
510
+ expect(await everything(q)).toBe(after);
511
+
512
+ await expect(runCoreMigrations(db, coreMigrationsDir())).resolves.toBeUndefined();
513
+ },
514
+ TIMEOUT,
515
+ );
516
+
517
+ it(
518
+ 'recomputes the index of an address an older writer replaced in clear',
519
+ async () => {
520
+ const { db, q } = await beforeTheBackfill(await scratchDatabase());
521
+ await plantLegacyRows(q);
522
+ await runCoreMigrations(db, coreMigrationsDir());
523
+ // A version from before the upgrade, still running, renames the account:
524
+ // it knows nothing of the index beside the column.
525
+ await q(`update users set email = 'Bo.New@Example.com' where email_bidx = '${keys.index('bo@example.com')}'`);
526
+
527
+ await runCoreMigrations(db, coreMigrationsDir());
528
+
529
+ const [row] = await q(`select email, email_bidx from users where email_bidx = '${keys.index('bo.new@example.com')}'`);
530
+ expect(sealed(row?.email)).toBe(true);
531
+ expect(opened(row?.email)).toBe('Bo.New@Example.com');
532
+ expect(await q(`select 1 from users where email_bidx = '${keys.index('bo@example.com')}'`)).toHaveLength(0);
533
+ },
534
+ TIMEOUT,
535
+ );
536
+
537
+ it(
538
+ "works in a tenant's schema, under the tenant's key, and leaves the default schema alone",
539
+ async () => {
540
+ const url = await scratchDatabase();
541
+ const main = await beforeTheBackfill(url);
542
+ await plantLegacyRows(main.q);
543
+ const untouched = await everything(main.q);
544
+ const tenantKey = randomBytes(32).toString('base64');
545
+ const tenantKeys = derivePiiKeys(tenantKey);
546
+ const tenant = await beforeTheBackfill(url, { schema: 't_acme', key: tenantKey });
547
+ await plantLegacyRows(tenant.q);
548
+
549
+ await runCoreMigrations(tenant.db, coreMigrationsDir());
550
+
551
+ const people = await tenant.q(`select email, email_bidx from users`);
552
+ expect(people).toHaveLength(2);
553
+ expect(
554
+ people.every((u) => sealed(u.email) && u.email_bidx === tenantKeys.index(tenantKeys.read(String(u.email)))),
555
+ ).toBe(true);
556
+ // Under the tenant's key and no other.
557
+ expect(people.every((u) => !keys.open(String(u.email)).ok)).toBe(true);
558
+ expect(await tenant.q(`select 1 from pr_file_approvals`)).toHaveLength(1);
559
+ expect(await everythingReadable(tenant.q)).not.toMatch(/example\.com/);
560
+ const indexes = (await tenant.q(`select indexname from pg_indexes where schemaname = 't_acme'`)).map((r) => r.indexname);
561
+ expect(indexes).toContain('users_email_bidx_unq');
562
+ expect(indexes).not.toContain('users_email_unique');
563
+
564
+ expect(await everything(main.q)).toBe(untouched);
565
+ },
566
+ TIMEOUT,
567
+ );
568
+ });
569
+
570
+ /**
571
+ * An overlay seals columns of its own schema with the same backfill, by
572
+ * handing `runEnterpriseMigrations` a spec. Its table here has what core's
573
+ * own do not: two indexed addresses in one row, and one of them optional.
574
+ */
575
+ describe("an overlay's own tables", () => {
576
+ const NOTES: PiiBackfillSpec = {
577
+ name: 'overlay',
578
+ tables: [
579
+ {
580
+ table: 'team_notes',
581
+ key: ['id'],
582
+ encrypted: ['author_email', 'reviewer_email', 'body'],
583
+ bidx: [
584
+ { source: 'author_email', column: 'author_email_bidx' },
585
+ { source: 'reviewer_email', column: 'reviewer_email_bidx' },
586
+ ],
587
+ },
588
+ ],
589
+ marker: { table: 'team_notes', column: 'author_email_bidx' },
590
+ finalize: ['ALTER TABLE "team_notes" ALTER COLUMN "author_email_bidx" SET NOT NULL'],
591
+ keyName: 'OVERLAY_KEY',
592
+ };
593
+ // Typed before anything was sealed, in the very shape of a sealed value.
594
+ const shapedLikeSealed = `${PII_CIPHERTEXT_PREFIX}${'A'.repeat(16)}:${'A'.repeat(22)}==:AAAA`;
595
+
596
+ /** The overlay's migration history: the table, its index columns nullable as a SQL history must add them. */
597
+ function overlayHistory(): string {
598
+ const dir = mkdtempSync(path.join(tmpdir(), 'overlay-migrations-'));
599
+ folders.push(dir);
600
+ mkdirSync(path.join(dir, 'meta'));
601
+ writeFileSync(
602
+ path.join(dir, 'meta', '_journal.json'),
603
+ JSON.stringify({ version: '7', dialect: 'postgresql', entries: [{ idx: 0, version: '7', when: 1, tag: '0000_notes', breakpoints: true }] }),
604
+ );
605
+ writeFileSync(
606
+ path.join(dir, '0000_notes.sql'),
607
+ `CREATE TABLE "team_notes" (
608
+ "id" serial PRIMARY KEY,
609
+ "author_email" text NOT NULL,
610
+ "author_email_bidx" text,
611
+ "reviewer_email" text,
612
+ "reviewer_email_bidx" text,
613
+ "body" text
614
+ );`,
615
+ );
616
+ return dir;
617
+ }
618
+
619
+ /** A database an older version of the overlay wrote to: its history applied, its rows in clear. */
620
+ async function overlayBeforeTheBackfill(key?: string): Promise<{ url: string; db: Database; q: Query; history: string }> {
621
+ const url = await scratchDatabase();
622
+ const db = handle(url, key ? { key } : {});
623
+ const history = overlayHistory();
624
+ await runEnterpriseMigrations(db, history);
625
+ const q = stored(url);
626
+ await q(`insert into team_notes (author_email, reviewer_email, body) values
627
+ ('Ada@Example.com', ' Bo@example.com', 'hello'),
628
+ ('cy@example.com', null, '${shapedLikeSealed}')`);
629
+ return { url, db, q, history };
630
+ }
631
+ const notes = (q: Query) => q(`select * from team_notes order by id`);
632
+
633
+ it(
634
+ 'are sealed right after its history, with every index it declares, and a second start changes nothing',
635
+ async () => {
636
+ const { db, q, history } = await overlayBeforeTheBackfill();
637
+
638
+ await runEnterpriseMigrations(db, history, { piiBackfill: NOTES });
639
+
640
+ const [first, second] = await notes(q);
641
+ expect([first!.author_email, first!.reviewer_email, first!.body, second!.author_email].every(sealed)).toBe(true);
642
+ expect(opened(first!.author_email)).toBe('Ada@Example.com');
643
+ // Both addresses of a row are indexed, each under its own column.
644
+ expect(first!.author_email_bidx).toBe(keys.index('ada@example.com'));
645
+ expect(first!.reviewer_email_bidx).toBe(keys.index('bo@example.com'));
646
+ expect(second!.author_email_bidx).toBe(keys.index('cy@example.com'));
647
+ // An address that is not there has no index: not the index of the
648
+ // empty string, which every such row would share.
649
+ expect(second!.reviewer_email).toBeNull();
650
+ expect(second!.reviewer_email_bidx).toBeNull();
651
+ // A first backfill believes no shape: the text typed in the shape of
652
+ // a sealed value is sealed like any other, and reads back as typed.
653
+ expect(second!.body).not.toBe(shapedLikeSealed);
654
+ expect(opened(second!.body)).toBe(shapedLikeSealed);
655
+ // The marker is closed: no later row can be without its index.
656
+ await expect(q(`insert into team_notes (author_email) values ('x@example.com')`)).rejects.toThrow(/author_email_bidx/);
657
+
658
+ const after = JSON.stringify(await notes(q));
659
+ await runEnterpriseMigrations(db, history, { piiBackfill: NOTES });
660
+ expect(JSON.stringify(await notes(q))).toBe(after);
661
+ },
662
+ TIMEOUT,
663
+ );
664
+
665
+ it(
666
+ 'are refused under a key that does not open them, named as the overlay names it',
667
+ async () => {
668
+ const { url, db, q, history } = await overlayBeforeTheBackfill();
669
+ await runEnterpriseMigrations(db, history, { piiBackfill: NOTES });
670
+ const before = JSON.stringify(await notes(q));
671
+
672
+ const rekeyed = handle(url, { key: randomBytes(32).toString('base64') });
673
+ await expect(runEnterpriseMigrations(rekeyed, history, { piiBackfill: NOTES })).rejects.toThrow(/OVERLAY_KEY does not open/);
674
+ expect(JSON.stringify(await notes(q))).toBe(before);
675
+ },
676
+ TIMEOUT,
677
+ );
678
+
679
+ it(
680
+ 'are refused under a wrong key even where the first column of a table holds nothing sealed',
681
+ async () => {
682
+ const { url, db, q, history } = await overlayBeforeTheBackfill();
683
+ await runEnterpriseMigrations(db, history, { piiBackfill: NOTES });
684
+ // Nobody has a reviewer: that column has no sealed value to try a key
685
+ // on, and it is the column this spec lists first. The authors' do.
686
+ await q(`update team_notes set reviewer_email = null, reviewer_email_bidx = null`);
687
+ const table = NOTES.tables[0]!;
688
+ const reviewerFirst: PiiBackfillSpec = {
689
+ ...NOTES,
690
+ tables: [{ ...table, encrypted: ['reviewer_email', 'author_email', 'body'], bidx: [...(table.bidx as PiiBlindIndex[])].reverse() }],
691
+ };
692
+ const before = JSON.stringify(await notes(q));
693
+
694
+ const rekeyed = handle(url, { key: randomBytes(32).toString('base64') });
695
+ await expect(runPiiBackfill(rekeyed, reviewerFirst)).rejects.toThrow(/team_notes\.author_email is sealed with a key the configured OVERLAY_KEY does not open/);
696
+ expect(JSON.stringify(await notes(q))).toBe(before);
697
+ // The right key is still let through.
698
+ await runPiiBackfill(db, reviewerFirst);
699
+ },
700
+ TIMEOUT,
701
+ );
702
+
703
+ it(
704
+ 'keep an index exactly where its source is, and equal where the sources are',
705
+ async () => {
706
+ const { db, q, history } = await overlayBeforeTheBackfill();
707
+ // An empty string is a value: two rows that hold it hold the same one.
708
+ await q(`insert into team_notes (author_email, reviewer_email) values ('di@example.com', ''), ('ed@example.com', '')`);
709
+ await runEnterpriseMigrations(db, history, { piiBackfill: NOTES });
710
+
711
+ const rows = await notes(q);
712
+ // A NULL source has no index; an empty one has the index the handle
713
+ // itself writes for an empty value, so a row is found by it whoever
714
+ // wrote it; and it stays the empty string, which seals to itself.
715
+ expect(rows.map((r) => r.reviewer_email_bidx)).toEqual([keys.index('bo@example.com'), null, keys.index(''), keys.index('')]);
716
+ expect(rows.slice(2).map((r) => r.reviewer_email)).toEqual(['', '']);
717
+
718
+ // A source emptied to NULL behind the application's back leaves its
719
+ // index answering for an address the row no longer holds. The next
720
+ // start takes it away, and touches nothing else.
721
+ await q(`update team_notes set reviewer_email = null where id = 1`);
722
+ const untouched = JSON.stringify((await notes(q)).slice(1));
723
+ await runEnterpriseMigrations(db, history, { piiBackfill: NOTES });
724
+ const after = await notes(q);
725
+ expect(after[0]).toMatchObject({ reviewer_email: null, reviewer_email_bidx: null, author_email_bidx: keys.index('ada@example.com') });
726
+ expect(JSON.stringify(after.slice(1))).toBe(untouched);
727
+ },
728
+ TIMEOUT,
729
+ );
730
+
731
+ it(
732
+ 'are left untouched by a spec whose finalize does not close its marker',
733
+ async () => {
734
+ const { db, q } = await overlayBeforeTheBackfill();
735
+ const before = JSON.stringify(await notes(q));
736
+
737
+ // Committed, the next start would take these rows for unsealed again
738
+ // and seal the sealed. So it does not commit.
739
+ await expect(runPiiBackfill(db, { ...NOTES, finalize: [] })).rejects.toThrow(/left the marker column team_notes\.author_email_bidx nullable/);
740
+ expect(JSON.stringify(await notes(q))).toBe(before);
741
+ },
742
+ TIMEOUT,
743
+ );
744
+
745
+ it(
746
+ 'are not read at all for a spec that could not be carried through',
747
+ async () => {
748
+ const { url, db, q } = await overlayBeforeTheBackfill();
749
+ const before = JSON.stringify(await notes(q));
750
+ const table = NOTES.tables[0]!;
751
+ // Everything the backfill asks the database, it asks inside the one
752
+ // transaction it opens. So "not read" is checked where it would
753
+ // happen: these refusals must come before any transaction is opened,
754
+ // which unchanged rows alone would not show.
755
+ const keyless = handle(url, { key: null });
756
+ const opened = [vi.spyOn(db, 'transaction'), vi.spyOn(db, 'execute'), vi.spyOn(keyless, 'transaction'), vi.spyOn(keyless, 'execute')];
757
+
758
+ // A marker that is no index of the spec's tables.
759
+ await expect(runPiiBackfill(db, { ...NOTES, marker: { table: 'team_notes', column: 'body' } })).rejects.toThrow(/is not a blind-index column/);
760
+ // An index of a column that is not sealed: its plaintext is never read.
761
+ await expect(
762
+ runPiiBackfill(db, { ...NOTES, tables: [{ ...table, encrypted: ['reviewer_email', 'body'] }] }),
763
+ ).rejects.toThrow(/indexes "author_email", which is not one of the table's encrypted columns/);
764
+ // A handle that holds no key.
765
+ await expect(runPiiBackfill(keyless, NOTES)).rejects.toThrow(/holds no personal-data key/);
766
+ for (const spy of opened) {
767
+ expect(spy).not.toHaveBeenCalled();
768
+ spy.mockRestore();
769
+ }
770
+ expect(JSON.stringify(await notes(q))).toBe(before);
771
+
772
+ // And a database the history was never applied to: nothing says the
773
+ // rows are sealed, so nothing is assumed.
774
+ const empty = handle(await scratchDatabase());
775
+ await expect(runPiiBackfill(empty, NOTES)).rejects.toThrow(/marker column team_notes\.author_email_bidx does not exist/);
776
+ },
777
+ TIMEOUT,
778
+ );
779
+ });
780
+ });