dpdp-erasure-cli 1.0.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 (155) hide show
  1. package/.env.example +55 -0
  2. package/Dockerfile +33 -0
  3. package/compliance.worker.yaml +64 -0
  4. package/package.json +41 -0
  5. package/src/constants/index.ts +1 -0
  6. package/src/errors/fail.ts +110 -0
  7. package/src/errors/index.ts +4 -0
  8. package/src/errors/inferer.ts +166 -0
  9. package/src/errors/registry.ts +122 -0
  10. package/src/errors/types.ts +65 -0
  11. package/src/errors/worker.ts +161 -0
  12. package/src/index.ts +328 -0
  13. package/src/lib/crypto/digest.ts +22 -0
  14. package/src/lib/crypto/encoding.ts +78 -0
  15. package/src/lib/crypto/index.ts +2 -0
  16. package/src/lib/index.ts +1 -0
  17. package/src/modules/bootstrap/index.ts +2 -0
  18. package/src/modules/bootstrap/integrity.ts +38 -0
  19. package/src/modules/bootstrap/preflight.ts +296 -0
  20. package/src/modules/cli/check-integrity.ts +48 -0
  21. package/src/modules/cli/dry-run.ts +90 -0
  22. package/src/modules/cli/graph.ts +87 -0
  23. package/src/modules/cli/index.ts +184 -0
  24. package/src/modules/cli/init.ts +115 -0
  25. package/src/modules/cli/inspect.ts +86 -0
  26. package/src/modules/cli/introspector.ts +117 -0
  27. package/src/modules/cli/keygen.ts +38 -0
  28. package/src/modules/cli/scan.ts +126 -0
  29. package/src/modules/cli/sign.ts +50 -0
  30. package/src/modules/cli/ui.ts +61 -0
  31. package/src/modules/cli/verify-schema.ts +31 -0
  32. package/src/modules/cli/verify.ts +85 -0
  33. package/src/modules/config/compatibility.ts +271 -0
  34. package/src/modules/config/index.ts +4 -0
  35. package/src/modules/config/reader.ts +149 -0
  36. package/src/modules/config/signature.ts +69 -0
  37. package/src/modules/config/validation.ts +658 -0
  38. package/src/modules/crypto/aes.ts +158 -0
  39. package/src/modules/crypto/envelope.ts +48 -0
  40. package/src/modules/crypto/hmac.ts +60 -0
  41. package/src/modules/crypto/index.ts +3 -0
  42. package/src/modules/db/drift.ts +36 -0
  43. package/src/modules/db/graph.ts +203 -0
  44. package/src/modules/db/index.ts +4 -0
  45. package/src/modules/db/migrations.ts +254 -0
  46. package/src/modules/db/sql-debug.ts +61 -0
  47. package/src/modules/engine/blob/index.ts +3 -0
  48. package/src/modules/engine/blob/s3.ts +455 -0
  49. package/src/modules/engine/blob/store.ts +236 -0
  50. package/src/modules/engine/blob/types.ts +44 -0
  51. package/src/modules/engine/helpers/identity.ts +47 -0
  52. package/src/modules/engine/helpers/index.ts +4 -0
  53. package/src/modules/engine/helpers/outbox.ts +118 -0
  54. package/src/modules/engine/helpers/runtime.ts +115 -0
  55. package/src/modules/engine/helpers/types.ts +61 -0
  56. package/src/modules/engine/index.ts +6 -0
  57. package/src/modules/engine/notifier/config.ts +147 -0
  58. package/src/modules/engine/notifier/dispatcher.ts +300 -0
  59. package/src/modules/engine/notifier/index.ts +3 -0
  60. package/src/modules/engine/notifier/payload.ts +51 -0
  61. package/src/modules/engine/notifier/reservation.ts +153 -0
  62. package/src/modules/engine/notifier/types.ts +38 -0
  63. package/src/modules/engine/shredder.ts +254 -0
  64. package/src/modules/engine/types.ts +146 -0
  65. package/src/modules/engine/vault/compiled-targets.ts +562 -0
  66. package/src/modules/engine/vault/context.ts +254 -0
  67. package/src/modules/engine/vault/dry-run.ts +94 -0
  68. package/src/modules/engine/vault/execution.ts +485 -0
  69. package/src/modules/engine/vault/index.ts +3 -0
  70. package/src/modules/engine/vault/purge.ts +82 -0
  71. package/src/modules/engine/vault/retention.ts +124 -0
  72. package/src/modules/engine/vault/satellite-mutation.ts +193 -0
  73. package/src/modules/engine/vault/satellite.ts +103 -0
  74. package/src/modules/engine/vault/shadow.ts +36 -0
  75. package/src/modules/engine/vault/static-plan.ts +116 -0
  76. package/src/modules/engine/vault/store.ts +34 -0
  77. package/src/modules/engine/vault/vault.ts +84 -0
  78. package/src/modules/introspector/classifier.ts +502 -0
  79. package/src/modules/introspector/dag.ts +276 -0
  80. package/src/modules/introspector/index.ts +7 -0
  81. package/src/modules/introspector/naming.ts +75 -0
  82. package/src/modules/introspector/report.ts +153 -0
  83. package/src/modules/introspector/run.ts +123 -0
  84. package/src/modules/introspector/s3-sampler.ts +227 -0
  85. package/src/modules/introspector/types.ts +131 -0
  86. package/src/modules/introspector/yaml.ts +101 -0
  87. package/src/modules/network/api/control-plane.ts +275 -0
  88. package/src/modules/network/api/index.ts +1 -0
  89. package/src/modules/network/api/validation.ts +71 -0
  90. package/src/modules/network/index.ts +4 -0
  91. package/src/modules/network/object-store/aws/client.ts +444 -0
  92. package/src/modules/network/object-store/aws/credentials.ts +271 -0
  93. package/src/modules/network/object-store/aws/index.ts +2 -0
  94. package/src/modules/network/object-store/aws/sigv4.ts +190 -0
  95. package/src/modules/network/object-store/aws/type.ts +6 -0
  96. package/src/modules/network/object-store/index.ts +1 -0
  97. package/src/modules/network/outbox/dispatcher.ts +183 -0
  98. package/src/modules/network/outbox/index.ts +3 -0
  99. package/src/modules/network/outbox/process.ts +133 -0
  100. package/src/modules/network/outbox/shared.ts +56 -0
  101. package/src/modules/network/outbox/store.ts +346 -0
  102. package/src/modules/network/outbox/types.ts +54 -0
  103. package/src/modules/network/request-signing.ts +61 -0
  104. package/src/modules/worker/index.ts +2 -0
  105. package/src/modules/worker/tasks.ts +58 -0
  106. package/src/modules/worker/types.ts +89 -0
  107. package/src/modules/worker/worker.ts +243 -0
  108. package/src/secrets/index.ts +4 -0
  109. package/src/secrets/kms/index.ts +2 -0
  110. package/src/secrets/kms/signature.ts +82 -0
  111. package/src/secrets/kms/validation.ts +64 -0
  112. package/src/secrets/reader.ts +42 -0
  113. package/src/secrets/repository/crypto.ts +89 -0
  114. package/src/secrets/repository/index.ts +2 -0
  115. package/src/secrets/repository/methods.ts +37 -0
  116. package/src/secrets/resolvers.ts +247 -0
  117. package/src/secrets/signature.ts +78 -0
  118. package/src/types/index.ts +1 -0
  119. package/src/types/types.ts +23 -0
  120. package/src/utils/identifiers.ts +48 -0
  121. package/src/utils/index.ts +3 -0
  122. package/src/utils/json.ts +35 -0
  123. package/src/utils/logger.ts +161 -0
  124. package/src/validation/zod.ts +70 -0
  125. package/tests/adversarial.test.ts +464 -0
  126. package/tests/blob-s3.test.ts +216 -0
  127. package/tests/config.test.ts +395 -0
  128. package/tests/control-plane-client.test.ts +108 -0
  129. package/tests/crypto.test.ts +106 -0
  130. package/tests/errors.test.ts +69 -0
  131. package/tests/fetch-dispatcher.test.ts +213 -0
  132. package/tests/graph.test.ts +84 -0
  133. package/tests/helpers/index.ts +101 -0
  134. package/tests/index-preflight.test.ts +168 -0
  135. package/tests/introspector-classifier.test.ts +62 -0
  136. package/tests/introspector-report.test.ts +85 -0
  137. package/tests/introspector.test.ts +394 -0
  138. package/tests/kms.test.ts +124 -0
  139. package/tests/logger.test.ts +61 -0
  140. package/tests/notifier.test.ts +303 -0
  141. package/tests/outbox.test.ts +478 -0
  142. package/tests/purge-policy.test.ts +124 -0
  143. package/tests/retention.test.ts +103 -0
  144. package/tests/s3-client.test.ts +110 -0
  145. package/tests/satellite.test.ts +119 -0
  146. package/tests/schema-compatibility.test.ts +237 -0
  147. package/tests/schema-integrity.test.ts +64 -0
  148. package/tests/shredder.test.ts +163 -0
  149. package/tests/vault.compiled-targets.test.ts +243 -0
  150. package/tests/vault.replica.test.ts +59 -0
  151. package/tests/vault.test.ts +279 -0
  152. package/tests/worker.retry.test.ts +291 -0
  153. package/tests/worker.test.ts +200 -0
  154. package/tsconfig.json +19 -0
  155. package/vitest.config.ts +13 -0
@@ -0,0 +1,464 @@
1
+ import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
2
+ import { mkdtemp, rm, writeFile } from "node:fs/promises";
3
+ import { dirname, join } from "node:path";
4
+ import { tmpdir } from "node:os";
5
+
6
+ const getDependencyGraphMock = vi.hoisted(() => vi.fn<() => Promise<unknown[]>>(() => Promise.resolve([])));
7
+ vi.mock("@modules/db/graph", () => ({
8
+ getDependencyGraph: getDependencyGraphMock,
9
+ }));
10
+
11
+ import {
12
+ TEST_SECRETS,
13
+ createTestSql,
14
+ dropSchemas,
15
+ insertUser,
16
+ uniqueSchema,
17
+ } from "./helpers";
18
+ import { readWorkerConfig } from "@modules/config";
19
+ import { runMigrations } from "@modules/db";
20
+ import { vaultUser } from "@modules/engine";
21
+ import type { Sql } from "@/types";
22
+ import { decryptGCM, unwrapKey } from "@modules/crypto";
23
+ import { processOutbox } from "@modules/network";
24
+ import { calculateRetryDelayMs } from "@modules/network/outbox/shared";
25
+
26
+
27
+ const masterKeyHex = "42".repeat(32);
28
+ const hmacKeyBase64 = Buffer.from(new Uint8Array(32).fill(0x24)).toString("base64");
29
+
30
+ async function writeTempYaml(contents: string): Promise<string> {
31
+ const directory = await mkdtemp(join(tmpdir(), "adversarial-config-"));
32
+ const path = join(directory, "compliance.worker.yml");
33
+ await writeFile(path, contents, "utf8");
34
+ return path;
35
+ }
36
+
37
+ async function deleteTempYaml(path: string) {
38
+ await rm(path, { force: true });
39
+ await rm(dirname(path), { recursive: true, force: true });
40
+ }
41
+
42
+ function buildVaultOptions(appSchema: string, engineSchema: string, now?: Date) {
43
+ return {
44
+ appSchema,
45
+ engineSchema,
46
+ now,
47
+ rootTable: "users",
48
+ rootIdColumn: "id",
49
+ rootPiiColumns: {
50
+ email: "HMAC" as const,
51
+ full_name: "STATIC_MASK" as const,
52
+ },
53
+ satelliteTargets: [],
54
+ compiledTargets: [
55
+ { table: `${appSchema}.users`, pii_columns: ["email", "full_name"] },
56
+ {
57
+ table: `${appSchema}.orders`,
58
+ parent: `${appSchema}.users`,
59
+ join: `${appSchema}.users.id = ${appSchema}.orders.user_id`,
60
+ pii_columns: [],
61
+ },
62
+ ],
63
+ };
64
+ }
65
+
66
+ describe("Adversarial Worker Suite", () => {
67
+ let sql: Sql;
68
+ const schemasToDrop: string[] = [];
69
+ const configPathsToDelete: string[] = [];
70
+
71
+ beforeAll(() => {
72
+ sql = createTestSql();
73
+ });
74
+
75
+ afterAll(async () => {
76
+ for (const path of configPathsToDelete.splice(0, configPathsToDelete.length)) {
77
+ await deleteTempYaml(path);
78
+ }
79
+ await dropSchemas(sql, ...schemasToDrop);
80
+ await sql.end();
81
+ });
82
+
83
+ it("Vector 1: fails closed on toxic config and quoted identifier injection attempts", async () => {
84
+ const nullRetentionPath = await writeTempYaml(`
85
+ version: "1.0"
86
+ database:
87
+ app_schema: tenant_app
88
+ engine_schema: tenant_engine
89
+ compliance_policy:
90
+ default_retention_years: null
91
+ notice_window_hours: 48
92
+ retention_rules:
93
+ - rule_name: RBI_KYC
94
+ legal_citation: "RBI KYC Directions, 2016, Sec 38"
95
+ if_has_data_in:
96
+ - kyc_documents
97
+ retention_years: 5
98
+ graph:
99
+ root_table: users
100
+ root_id_column: id
101
+ max_depth: 32
102
+ root_pii_columns:
103
+ email: HMAC
104
+ satellite_targets:
105
+ - table: marketing_leads
106
+ lookup_column: email
107
+ action: redact
108
+ masking_rules:
109
+ email: HMAC
110
+ outbox:
111
+ batch_size: 10
112
+ lease_seconds: 60
113
+ max_attempts: 10
114
+ base_backoff_ms: 1000
115
+ security:
116
+ notification_lease_seconds: 120
117
+ master_key_env: DPDP_MASTER_KEY
118
+ hmac_key_env: DPDP_HMAC_KEY
119
+ integrity:
120
+ expected_schema_hash: "${"1".repeat(64)}"
121
+ legal_attestation:
122
+ dpo_identifier: "dpo-name@client.com"
123
+ configuration_version: "v1.2.0"
124
+ legal_review_date: "2026-04-20"
125
+ acknowledgment: "I confirm this configuration accurately reflects our obligations."
126
+ `);
127
+ configPathsToDelete.push(nullRetentionPath);
128
+
129
+ await expect(
130
+ readWorkerConfig(
131
+ {
132
+ DPDP_MASTER_KEY: masterKeyHex,
133
+ DPDP_HMAC_KEY: `base64:${hmacKeyBase64}`,
134
+ },
135
+ nullRetentionPath
136
+ )
137
+ ).rejects.toThrow(/default_retention_years/i);
138
+
139
+ const injectionPath = await writeTempYaml(`
140
+ version: "1.0"
141
+ database:
142
+ app_schema: tenant_app
143
+ engine_schema: tenant_engine
144
+ compliance_policy:
145
+ default_retention_years: 0
146
+ notice_window_hours: 48
147
+ retention_rules:
148
+ - rule_name: RBI_KYC
149
+ legal_citation: "RBI KYC Directions, 2016, Sec 38"
150
+ if_has_data_in:
151
+ - kyc_documents
152
+ retention_years: 5
153
+ graph:
154
+ root_table: "users; DROP TABLE clients;--"
155
+ root_id_column: id
156
+ max_depth: 32
157
+ root_pii_columns:
158
+ email: HMAC
159
+ satellite_targets:
160
+ - table: marketing_leads
161
+ lookup_column: email
162
+ action: redact
163
+ masking_rules:
164
+ email: HMAC
165
+ outbox:
166
+ batch_size: 10
167
+ lease_seconds: 60
168
+ max_attempts: 10
169
+ base_backoff_ms: 1000
170
+ security:
171
+ notification_lease_seconds: 120
172
+ master_key_env: DPDP_MASTER_KEY
173
+ hmac_key_env: DPDP_HMAC_KEY
174
+ integrity:
175
+ expected_schema_hash: "${"1".repeat(64)}"
176
+ legal_attestation:
177
+ dpo_identifier: "dpo-name@client.com"
178
+ configuration_version: "v1.2.0"
179
+ legal_review_date: "2026-04-20"
180
+ acknowledgment: "I confirm this configuration accurately reflects our obligations."
181
+ `);
182
+ configPathsToDelete.push(injectionPath);
183
+
184
+ await expect(
185
+ readWorkerConfig(
186
+ {
187
+ DPDP_MASTER_KEY: masterKeyHex,
188
+ DPDP_HMAC_KEY: `base64:${hmacKeyBase64}`,
189
+ },
190
+ injectionPath
191
+ )
192
+ ).rejects.toThrow(/invalid graph root table/i);
193
+
194
+ const injectionSchema = uniqueSchema("adversarial_identifier");
195
+ schemasToDrop.push(injectionSchema);
196
+ await dropSchemas(sql, injectionSchema);
197
+ await sql`CREATE SCHEMA ${sql(injectionSchema)}`;
198
+ await sql`CREATE TABLE ${sql(injectionSchema)}.clients (id SERIAL PRIMARY KEY)`;
199
+
200
+ await expect(
201
+ sql`
202
+ SELECT 1
203
+ FROM ${sql(injectionSchema)}.${sql("users; DROP TABLE clients;--")}
204
+ `
205
+ ).rejects.toThrow();
206
+
207
+ const [tableCheck] = await sql<{ regclass: string | null }[]>`
208
+ SELECT to_regclass(${`${injectionSchema}.clients`}) AS regclass
209
+ `;
210
+ expect(tableCheck?.regclass).toBe(`${injectionSchema}.clients`);
211
+ });
212
+
213
+ it("Vector 2: prevents TOCTOU partial mutation under static-plan execution and concurrent FK insert", async () => {
214
+ const appSchema = uniqueSchema("adversarial_toctou_app");
215
+ const engineSchema = uniqueSchema("adversarial_toctou_engine");
216
+ schemasToDrop.push(appSchema, engineSchema);
217
+
218
+ await dropSchemas(sql, appSchema, engineSchema);
219
+ await sql`CREATE SCHEMA ${sql(appSchema)}`;
220
+ await sql`CREATE TABLE ${sql(appSchema)}.users (id SERIAL PRIMARY KEY, email TEXT NOT NULL, full_name TEXT NOT NULL)`;
221
+ await sql`
222
+ CREATE TABLE ${sql(appSchema)}.orders (
223
+ id SERIAL PRIMARY KEY,
224
+ user_id INTEGER REFERENCES ${sql(appSchema)}.users(id),
225
+ amount NUMERIC NOT NULL
226
+ )
227
+ `;
228
+ await sql.unsafe(`
229
+ CREATE FUNCTION "${appSchema}".slow_user_update()
230
+ RETURNS trigger
231
+ LANGUAGE plpgsql
232
+ AS $$
233
+ BEGIN
234
+ PERFORM pg_sleep(1);
235
+ RETURN NEW;
236
+ END;
237
+ $$;
238
+ CREATE TRIGGER slow_user_update
239
+ BEFORE UPDATE ON "${appSchema}".users
240
+ FOR EACH ROW EXECUTE FUNCTION "${appSchema}".slow_user_update();
241
+ `);
242
+ await runMigrations(sql, engineSchema);
243
+
244
+ const userId = await insertUser(sql, appSchema, "race@example.com", "Race User");
245
+ getDependencyGraphMock.mockClear();
246
+ getDependencyGraphMock.mockResolvedValue([]);
247
+
248
+ const vaultPromise = vaultUser(sql, userId, TEST_SECRETS, buildVaultOptions(appSchema, engineSchema, new Date()));
249
+ await new Promise((resolve) => setTimeout(resolve, 120));
250
+
251
+ const insertPromise = sql`
252
+ INSERT INTO ${sql(appSchema)}.orders (user_id, amount)
253
+ VALUES (${userId}, 10.5)
254
+ RETURNING id
255
+ `;
256
+ const earlyOutcome = await Promise.race([
257
+ insertPromise.then(() => "done"),
258
+ new Promise<"blocked">((resolve) => setTimeout(() => resolve("blocked"), 200)),
259
+ ]);
260
+
261
+ expect(earlyOutcome).toBe("blocked");
262
+ const vaultResult = await vaultPromise;
263
+ expect(vaultResult.action).toBe("vaulted");
264
+ expect(getDependencyGraphMock).not.toHaveBeenCalled();
265
+
266
+ const insertOutcome = await insertPromise.then(
267
+ () => "inserted",
268
+ () => "failed"
269
+ );
270
+ expect(["inserted", "failed"]).toContain(insertOutcome);
271
+
272
+ const [vaultRow] = await sql`
273
+ SELECT user_uuid_hash
274
+ FROM ${sql(engineSchema)}.pii_vault
275
+ WHERE root_schema = ${appSchema}
276
+ AND root_table = 'users'
277
+ AND root_id = ${userId.toString()}
278
+ `;
279
+ expect(vaultRow?.user_uuid_hash).toBe(vaultResult.userHash);
280
+ });
281
+
282
+ it("Vector 3: terminates cyclic traversal and trips depth circuit breaker at >32", async () => {
283
+ const { getDependencyGraph } = await vi.importActual<typeof import("@modules/db/graph")>("@modules/db/graph");
284
+ const cycleSchema = uniqueSchema("adversarial_cycle");
285
+ schemasToDrop.push(cycleSchema);
286
+
287
+ await dropSchemas(sql, cycleSchema);
288
+ await sql`CREATE SCHEMA ${sql(cycleSchema)}`;
289
+ await sql`CREATE TABLE ${sql(cycleSchema)}.circ_a (id SERIAL PRIMARY KEY)`;
290
+ await sql`
291
+ CREATE TABLE ${sql(cycleSchema)}.circ_b (
292
+ id SERIAL PRIMARY KEY,
293
+ a_id INTEGER REFERENCES ${sql(cycleSchema)}.circ_a(id)
294
+ )
295
+ `;
296
+ await sql`ALTER TABLE ${sql(cycleSchema)}.circ_a ADD COLUMN b_id INTEGER REFERENCES ${sql(cycleSchema)}.circ_b(id)`;
297
+
298
+ const cyclicGraph = await getDependencyGraph(sql, cycleSchema, "circ_a", { maxDepth: 32 });
299
+ const circBRows = cyclicGraph.filter((row) => row.table_name === `${cycleSchema}.circ_b`);
300
+ expect(circBRows).toHaveLength(1);
301
+
302
+ const deepSchema = uniqueSchema("adversarial_depth");
303
+ schemasToDrop.push(deepSchema);
304
+ await dropSchemas(sql, deepSchema);
305
+ await sql`CREATE SCHEMA ${sql(deepSchema)}`;
306
+ await sql`CREATE TABLE ${sql(deepSchema)}.users (id SERIAL PRIMARY KEY)`;
307
+
308
+ let parentTable = "users";
309
+ for (let depth = 1; depth <= 33; depth += 1) {
310
+ const table = `level_${depth}`;
311
+ await sql`
312
+ CREATE TABLE ${sql(deepSchema)}.${sql(table)} (
313
+ id SERIAL PRIMARY KEY,
314
+ parent_id INTEGER REFERENCES ${sql(deepSchema)}.${sql(parentTable)}(id)
315
+ )
316
+ `;
317
+ parentTable = table;
318
+ }
319
+
320
+ await expect(getDependencyGraph(sql, deepSchema, "users", { maxDepth: 32 })).rejects.toThrow(/safety limit/i);
321
+ });
322
+
323
+ it("Vector 4: rejects corrupted AES-GCM auth tags and halts decryption", async () => {
324
+ const appSchema = uniqueSchema("adversarial_crypto_app");
325
+ const engineSchema = uniqueSchema("adversarial_crypto_engine");
326
+ schemasToDrop.push(appSchema, engineSchema);
327
+
328
+ await dropSchemas(sql, appSchema, engineSchema);
329
+ await sql`CREATE SCHEMA ${sql(appSchema)}`;
330
+ await sql`CREATE TABLE ${sql(appSchema)}.users (id SERIAL PRIMARY KEY, email TEXT NOT NULL, full_name TEXT NOT NULL)`;
331
+ await sql`
332
+ CREATE TABLE ${sql(appSchema)}.orders (
333
+ id SERIAL PRIMARY KEY,
334
+ user_id INTEGER REFERENCES ${sql(appSchema)}.users(id),
335
+ amount NUMERIC NOT NULL
336
+ )
337
+ `;
338
+ await runMigrations(sql, engineSchema);
339
+
340
+ getDependencyGraphMock.mockClear();
341
+ getDependencyGraphMock.mockResolvedValue([
342
+ {
343
+ table_schema: appSchema,
344
+ table_name: `${appSchema}.orders`,
345
+ column_name: "user_id",
346
+ parent_table: `${appSchema}.users`,
347
+ depth: 1,
348
+ },
349
+ ]);
350
+
351
+ const userId = await insertUser(sql, appSchema, "cipher@example.com", "Cipher User");
352
+ const result = await vaultUser(sql, userId, TEST_SECRETS, buildVaultOptions(appSchema, engineSchema, new Date()));
353
+ expect(result.action).toBe("vaulted");
354
+
355
+ const [vaultRow] = await sql`
356
+ SELECT encrypted_pii
357
+ FROM ${sql(engineSchema)}.pii_vault
358
+ WHERE user_uuid_hash = ${result.userHash}
359
+ `;
360
+ const [keyRow] = await sql`
361
+ SELECT encrypted_dek
362
+ FROM ${sql(engineSchema)}.user_keys
363
+ WHERE user_uuid_hash = ${result.userHash}
364
+ `;
365
+
366
+ const wrappedDek = new Uint8Array(keyRow!.encrypted_dek);
367
+ const dek = await unwrapKey(wrappedDek, TEST_SECRETS.kek);
368
+ const payload = vaultRow!.encrypted_pii as { data: string };
369
+ const encryptedBytes = new Uint8Array(Buffer.from(payload.data, "base64"));
370
+ if (encryptedBytes.length === 0) {
371
+ throw new Error("Encrypted payload unexpectedly empty.");
372
+ }
373
+ const tagByteIndex = encryptedBytes.length - 1;
374
+ encryptedBytes[tagByteIndex] = encryptedBytes[tagByteIndex]! ^ 0xff;
375
+
376
+ await expect(decryptGCM(encryptedBytes, dek)).rejects.toThrow();
377
+ });
378
+
379
+ it("Vector 5: retries with exponential backoff and dead-letters after 10 consecutive 500s", async () => {
380
+ const engineSchema = uniqueSchema("adversarial_outbox_engine");
381
+ schemasToDrop.push(engineSchema);
382
+
383
+ await dropSchemas(sql, engineSchema);
384
+ await runMigrations(sql, engineSchema);
385
+
386
+ await sql`
387
+ INSERT INTO ${sql(engineSchema)}.outbox (
388
+ idempotency_key,
389
+ user_uuid_hash,
390
+ event_type,
391
+ payload,
392
+ previous_hash,
393
+ current_hash,
394
+ status,
395
+ attempt_count,
396
+ next_attempt_at,
397
+ created_at,
398
+ updated_at
399
+ )
400
+ VALUES (
401
+ 'adversarial:event:1',
402
+ 'user-hash-1',
403
+ 'USER_VAULTED',
404
+ ${sql.json({ rootId: "1" })},
405
+ 'GENESIS',
406
+ 'hash-1',
407
+ 'pending',
408
+ 0,
409
+ ${new Date("2026-04-18T00:00:00.000Z")},
410
+ NOW(),
411
+ NOW()
412
+ )
413
+ `;
414
+
415
+ let now = new Date("2026-04-18T00:00:00.000Z");
416
+ let deliveryCalls = 0;
417
+ const baseBackoffMs = 250;
418
+
419
+ for (let attempt = 1; attempt <= 10; attempt += 1) {
420
+ const result = await processOutbox(
421
+ sql,
422
+ async () => {
423
+ deliveryCalls += 1;
424
+ throw new Error("Brain API responded with HTTP 500.");
425
+ },
426
+ {
427
+ engineSchema,
428
+ batchSize: 1,
429
+ maxAttempts: 10,
430
+ baseBackoffMs,
431
+ now,
432
+ }
433
+ );
434
+
435
+ const [row] = await sql<{
436
+ status: "pending" | "dead_letter";
437
+ attempt_count: number;
438
+ next_attempt_at: Date;
439
+ last_error: string | null;
440
+ }[]>`
441
+ SELECT status, attempt_count, next_attempt_at, last_error
442
+ FROM ${sql(engineSchema)}.outbox
443
+ WHERE idempotency_key = 'adversarial:event:1'
444
+ `;
445
+
446
+ expect(result.failed).toBe(1);
447
+ expect(row?.attempt_count).toBe(attempt);
448
+ expect(row?.last_error).toContain("HTTP 500");
449
+
450
+ if (attempt < 10) {
451
+ expect(result.deadLettered).toBe(0);
452
+ expect(row?.status).toBe("pending");
453
+ const expectedDelay = calculateRetryDelayMs(attempt, baseBackoffMs);
454
+ expect(new Date(row!.next_attempt_at).getTime()).toBe(now.getTime() + expectedDelay);
455
+ now = new Date(now.getTime() + expectedDelay);
456
+ } else {
457
+ expect(result.deadLettered).toBe(1);
458
+ expect(row?.status).toBe("dead_letter");
459
+ }
460
+ }
461
+
462
+ expect(deliveryCalls).toBe(10);
463
+ });
464
+ });
@@ -0,0 +1,216 @@
1
+ import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
2
+ import type { Sql } from "@/types";
3
+ import { shredUser } from "@modules/engine";
4
+ import { vaultUser } from "@modules/engine";
5
+ import { type S3Client } from "@modules/network";
6
+ import {
7
+ TEST_SECRETS,
8
+ createTestSql,
9
+ dropSchemas,
10
+ insertUser,
11
+ prepareWorkerSchemas,
12
+ uniqueSchema,
13
+ } from "./helpers";
14
+
15
+ describe("S3 blob compliance provider", () => {
16
+ let sql: Sql;
17
+ const schemasToDrop: string[] = [];
18
+
19
+ beforeAll(() => {
20
+ sql = createTestSql();
21
+ });
22
+
23
+ afterAll(async () => {
24
+ await dropSchemas(sql, ...schemasToDrop);
25
+ await sql.end();
26
+ });
27
+
28
+ async function prepare() {
29
+ const appSchema = uniqueSchema("blob_app");
30
+ const engineSchema = uniqueSchema("blob_engine");
31
+ schemasToDrop.push(appSchema, engineSchema);
32
+ await prepareWorkerSchemas(sql, appSchema, engineSchema, { withDependencies: true });
33
+ await sql`ALTER TABLE ${sql(appSchema)}.users ADD COLUMN kyc_document_url TEXT`;
34
+ const userId = await insertUser(sql, appSchema, "blob@example.com", "Blob User");
35
+ await sql`
36
+ UPDATE ${sql(appSchema)}.users
37
+ SET kyc_document_url = 's3://kyc-bucket/kyc/john-doe-aadhar.pdf?versionId=v1'
38
+ WHERE id = ${userId}
39
+ `;
40
+
41
+ return { appSchema, engineSchema, userId };
42
+ }
43
+
44
+ function mockS3Client() {
45
+ const client: S3Client = {
46
+ headObject: vi.fn(async (input) => ({
47
+ bucket: input.bucket,
48
+ key: input.key,
49
+ versionId: input.versionId ?? "v1",
50
+ eTag: "etag-v1",
51
+ })),
52
+ putObjectLegalHold: vi.fn(async () => undefined),
53
+ listObjectVersions: vi.fn(async () => [
54
+ { key: "kyc/john-doe-aadhar.pdf", versionId: "v1", eTag: "etag-v1", isDeleteMarker: false },
55
+ { key: "kyc/john-doe-aadhar.pdf", versionId: "v2", eTag: "etag-v2", isDeleteMarker: false },
56
+ { key: "kyc/john-doe-aadhar.pdf", versionId: "delete-marker", eTag: null, isDeleteMarker: true },
57
+ ]),
58
+ deleteObjectVersion: vi.fn(async (input) => ({
59
+ key: input.key,
60
+ versionId: input.versionId ?? null,
61
+ deleteMarker: false,
62
+ status: 204,
63
+ })),
64
+ putObject: vi.fn(async (input) => ({
65
+ key: input.key,
66
+ versionId: "sanitized-v1",
67
+ eTag: "sanitized-etag",
68
+ status: 200,
69
+ })),
70
+ };
71
+
72
+ return client;
73
+ }
74
+
75
+ it("applies legal hold, masks the DB URL, and stores raw object coordinates only in the worker schema", async () => {
76
+ const { appSchema, engineSchema, userId } = await prepare();
77
+ const s3Client = mockS3Client();
78
+
79
+ const result = await vaultUser(sql, userId, TEST_SECRETS, {
80
+ appSchema,
81
+ engineSchema,
82
+ rootTable: "users",
83
+ rootIdColumn: "id",
84
+ rootPiiColumns: {
85
+ email: "HMAC",
86
+ full_name: "STATIC_MASK",
87
+ },
88
+ satelliteTargets: [],
89
+ blobTargets: [
90
+ {
91
+ table: "users",
92
+ column: "kyc_document_url",
93
+ provider: "aws_s3",
94
+ region: "ap-south-1",
95
+ action: "versioned_hard_delete",
96
+ retention_mode: "governance",
97
+ expected_bucket_owner: "123456789012",
98
+ require_version_id: true,
99
+ },
100
+ ],
101
+ s3Client,
102
+ now: new Date("2026-01-10T00:00:00.000Z"),
103
+ });
104
+
105
+ expect(result.action).toBe("vaulted");
106
+ expect(result.blobProtectionCount).toBe(1);
107
+ expect(s3Client.putObjectLegalHold).toHaveBeenCalledWith(expect.objectContaining({
108
+ bucket: "kyc-bucket",
109
+ key: "kyc/john-doe-aadhar.pdf",
110
+ versionId: "v1",
111
+ expectedBucketOwner: "123456789012",
112
+ status: "ON",
113
+ }));
114
+
115
+ const [user] = await sql<{ kyc_document_url: string }[]>`
116
+ SELECT kyc_document_url
117
+ FROM ${sql(appSchema)}.users
118
+ WHERE id = ${userId}
119
+ `;
120
+ expect(user?.kyc_document_url).toMatch(/^[0-9a-f]{64}$/);
121
+
122
+ const [blobRow] = await sql`
123
+ SELECT *
124
+ FROM ${sql(engineSchema)}.blob_objects
125
+ WHERE user_uuid_hash = ${result.userHash}
126
+ `;
127
+ expect(blobRow?.bucket).toBe("kyc-bucket");
128
+ expect(blobRow?.object_key).toBe("kyc/john-doe-aadhar.pdf");
129
+ expect(blobRow?.version_id).toBe("v1");
130
+ expect(blobRow?.expected_bucket_owner).toBe("123456789012");
131
+
132
+ const [outboxRow] = await sql<{ payload: { blob_protections: unknown[] } }[]>`
133
+ SELECT payload
134
+ FROM ${sql(engineSchema)}.outbox
135
+ WHERE event_type = 'USER_VAULTED'
136
+ `;
137
+ expect(JSON.stringify(outboxRow?.payload)).not.toContain("john-doe-aadhar");
138
+ expect(outboxRow?.payload.blob_protections).toHaveLength(1);
139
+ });
140
+
141
+ it("removes legal hold and deletes every S3 version during shredding", async () => {
142
+ const { appSchema, engineSchema, userId } = await prepare();
143
+ const s3Client = mockS3Client();
144
+ const now = new Date("2026-01-10T00:00:00.000Z");
145
+
146
+ const vaultResult = await vaultUser(sql, userId, TEST_SECRETS, {
147
+ appSchema,
148
+ engineSchema,
149
+ rootTable: "users",
150
+ rootIdColumn: "id",
151
+ rootPiiColumns: {
152
+ email: "HMAC",
153
+ full_name: "STATIC_MASK",
154
+ },
155
+ satelliteTargets: [],
156
+ blobTargets: [
157
+ {
158
+ table: "users",
159
+ column: "kyc_document_url",
160
+ provider: "aws_s3",
161
+ region: "ap-south-1",
162
+ action: "versioned_hard_delete",
163
+ retention_mode: "governance",
164
+ expected_bucket_owner: "123456789012",
165
+ require_version_id: true,
166
+ },
167
+ ],
168
+ s3Client,
169
+ now,
170
+ });
171
+
172
+ await sql`
173
+ UPDATE ${sql(engineSchema)}.pii_vault
174
+ SET notification_sent_at = ${now},
175
+ retention_expiry = ${now}
176
+ WHERE user_uuid_hash = ${vaultResult.userHash}
177
+ `;
178
+
179
+ const shredResult = await shredUser(sql, userId, {
180
+ appSchema,
181
+ engineSchema,
182
+ rootTable: "users",
183
+ now,
184
+ hmacKey: TEST_SECRETS.hmacKey,
185
+ s3Client,
186
+ });
187
+
188
+ expect(shredResult.action).toBe("shredded");
189
+ expect(shredResult.blobReceiptCount).toBe(1);
190
+ expect(s3Client.putObjectLegalHold).toHaveBeenCalledWith(expect.objectContaining({
191
+ versionId: "v1",
192
+ expectedBucketOwner: "123456789012",
193
+ status: "OFF",
194
+ }));
195
+ expect(s3Client.deleteObjectVersion).toHaveBeenCalledTimes(3);
196
+ expect(s3Client.deleteObjectVersion).toHaveBeenCalledWith(expect.objectContaining({ versionId: "v1", expectedBucketOwner: "123456789012" }));
197
+ expect(s3Client.deleteObjectVersion).toHaveBeenCalledWith(expect.objectContaining({ versionId: "v2", expectedBucketOwner: "123456789012" }));
198
+ expect(s3Client.deleteObjectVersion).toHaveBeenCalledWith(expect.objectContaining({ versionId: "delete-marker", expectedBucketOwner: "123456789012" }));
199
+
200
+ const [blobRow] = await sql<{ shred_status: string; shred_receipt: { deletedVersionIdHashes: string[] } }[]>`
201
+ SELECT shred_status, shred_receipt
202
+ FROM ${sql(engineSchema)}.blob_objects
203
+ WHERE user_uuid_hash = ${vaultResult.userHash}
204
+ `;
205
+ expect(blobRow?.shred_status).toBe("purged");
206
+ expect(blobRow?.shred_receipt.deletedVersionIdHashes).toHaveLength(3);
207
+
208
+ const [outboxRow] = await sql<{ payload: { blob_receipts: unknown[] } }[]>`
209
+ SELECT payload
210
+ FROM ${sql(engineSchema)}.outbox
211
+ WHERE event_type = 'SHRED_SUCCESS'
212
+ `;
213
+ expect(JSON.stringify(outboxRow?.payload)).not.toContain("john-doe-aadhar");
214
+ expect(outboxRow?.payload.blob_receipts).toHaveLength(1);
215
+ });
216
+ });