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,110 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+ import { createS3Client, parseS3ObjectUrl } from "@modules/network";
3
+
4
+ describe("S3 client", () => {
5
+ it("parses S3 URLs without losing version ids", () => {
6
+ expect(parseS3ObjectUrl("s3://kyc-bucket/kyc/john-doe-aadhar.pdf?versionId=v123")).toEqual({
7
+ bucket: "kyc-bucket",
8
+ key: "kyc/john-doe-aadhar.pdf",
9
+ versionId: "v123",
10
+ });
11
+
12
+ expect(parseS3ObjectUrl("https://kyc-bucket.s3.ap-south-1.amazonaws.com/kyc/doc.pdf?versionId=v9")).toEqual({
13
+ bucket: "kyc-bucket",
14
+ key: "kyc/doc.pdf",
15
+ versionId: "v9",
16
+ });
17
+ });
18
+
19
+ it("signs native S3 requests and crawls object versions", async () => {
20
+ const calls: Array<{ url: string; method: string; authorization: string | null }> = [];
21
+ const fetchFn = vi.fn(async (input: string | URL | Request, init?: RequestInit) => {
22
+ const url = String(input);
23
+ const headers = new Headers(init?.headers);
24
+ calls.push({
25
+ url,
26
+ method: init?.method ?? "GET",
27
+ authorization: headers.get("authorization"),
28
+ });
29
+
30
+ if (url.includes("169.254.170.2")) {
31
+ return new Response(JSON.stringify({
32
+ AccessKeyId: "AKIATEST",
33
+ SecretAccessKey: "secret",
34
+ Token: "session",
35
+ }), { status: 200 });
36
+ }
37
+
38
+ if (url.includes("versions")) {
39
+ return new Response(`<?xml version="1.0" encoding="UTF-8"?>
40
+ <ListVersionsResult>
41
+ <IsTruncated>false</IsTruncated>
42
+ <Version>
43
+ <Key>kyc/doc.pdf</Key>
44
+ <VersionId>v1</VersionId>
45
+ <ETag>"etag-1"</ETag>
46
+ </Version>
47
+ <DeleteMarker>
48
+ <Key>kyc/doc.pdf</Key>
49
+ <VersionId>marker-1</VersionId>
50
+ </DeleteMarker>
51
+ </ListVersionsResult>`, { status: 200 });
52
+ }
53
+
54
+ return new Response(null, {
55
+ status: 200,
56
+ headers: {
57
+ "x-amz-version-id": "v1",
58
+ etag: "\"etag-1\"",
59
+ },
60
+ });
61
+ });
62
+
63
+ const client = createS3Client({
64
+ env: {
65
+ AWS_CONTAINER_CREDENTIALS_RELATIVE_URI: "/v2/credentials/task",
66
+ AWS_EC2_METADATA_DISABLED: "true",
67
+ },
68
+ fetchFn: fetchFn as unknown as typeof fetch,
69
+ });
70
+
71
+ const head = await client.headObject({
72
+ bucket: "kyc-bucket",
73
+ key: "kyc/doc.pdf",
74
+ region: "ap-south-1",
75
+ });
76
+ const versions = await client.listObjectVersions({
77
+ bucket: "kyc-bucket",
78
+ key: "kyc/doc.pdf",
79
+ region: "ap-south-1",
80
+ });
81
+
82
+ expect(head).toMatchObject({ versionId: "v1", eTag: "etag-1" });
83
+ expect(versions).toEqual([
84
+ { key: "kyc/doc.pdf", versionId: "v1", eTag: "etag-1", isDeleteMarker: false },
85
+ { key: "kyc/doc.pdf", versionId: "marker-1", eTag: null, isDeleteMarker: true },
86
+ ]);
87
+ expect(calls.some((call) => call.authorization?.startsWith("AWS4-HMAC-SHA256"))).toBe(true);
88
+ expect(calls.some((call) => call.url.includes("169.254.170.2/v2/credentials/task"))).toBe(true);
89
+ });
90
+
91
+ it("rejects unsafe HTTP container credential endpoints before any S3 request is sent", async () => {
92
+ const fetchFn = vi.fn(async () => new Response(null, { status: 500 }));
93
+ const client = createS3Client({
94
+ env: {
95
+ AWS_CONTAINER_CREDENTIALS_FULL_URI: "http://metadata.evil.local/creds",
96
+ AWS_EC2_METADATA_DISABLED: "true",
97
+ },
98
+ fetchFn: fetchFn as unknown as typeof fetch,
99
+ });
100
+
101
+ await expect(client.headObject({
102
+ bucket: "kyc-bucket",
103
+ key: "kyc/doc.pdf",
104
+ region: "ap-south-1",
105
+ })).rejects.toMatchObject({
106
+ code: "AWS_CREDENTIALS_URI_REJECTED",
107
+ });
108
+ expect(fetchFn).not.toHaveBeenCalled();
109
+ });
110
+ });
@@ -0,0 +1,119 @@
1
+ import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
2
+ import { redactSatelliteTable } from "@modules/engine/vault/satellite";
3
+ import { createTestSql, dropSchemas, uniqueSchema } from "./helpers";
4
+ import type { Sql } from "@/types";
5
+
6
+ describe("Satellite Table Chunking", () => {
7
+ let sql: Sql;
8
+ const schemasToDrop: string[] = [];
9
+
10
+ beforeAll(() => {
11
+ sql = createTestSql();
12
+ });
13
+
14
+ afterAll(async () => {
15
+ await dropSchemas(sql, ...schemasToDrop);
16
+ await sql.end();
17
+ });
18
+
19
+ async function prepare() {
20
+ const schema = uniqueSchema("satellite_app");
21
+ schemasToDrop.push(schema);
22
+
23
+ await dropSchemas(sql, schema);
24
+ await sql`CREATE SCHEMA ${sql(schema)}`;
25
+ await sql`
26
+ CREATE TABLE ${sql(schema)}.orders (
27
+ id SERIAL PRIMARY KEY,
28
+ user_ref TEXT NOT NULL,
29
+ amount NUMERIC NOT NULL
30
+ )
31
+ `;
32
+
33
+ return { schema };
34
+ }
35
+
36
+ it("redacts matching satellite rows in batches until exhaustion", async () => {
37
+ const { schema } = await prepare();
38
+
39
+ for (let index = 0; index < 5; index += 1) {
40
+ await sql`
41
+ INSERT INTO ${sql(schema)}.orders (user_ref, amount)
42
+ VALUES ('legacy-user', ${index + 1})
43
+ `;
44
+ }
45
+
46
+ await sql`
47
+ INSERT INTO ${sql(schema)}.orders (user_ref, amount)
48
+ VALUES ('other-user', 99)
49
+ `;
50
+
51
+ const redacted = await sql.begin((tx) =>
52
+ redactSatelliteTable(tx, `${schema}.orders`, "user_ref", "legacy-user", "hmac-user", 2)
53
+ );
54
+
55
+ expect(redacted).toBe(5);
56
+
57
+ const rows = await sql`
58
+ SELECT user_ref
59
+ FROM ${sql(schema)}.orders
60
+ ORDER BY id ASC
61
+ `;
62
+
63
+ expect(rows.map((row) => row.user_ref)).toEqual([
64
+ "hmac-user",
65
+ "hmac-user",
66
+ "hmac-user",
67
+ "hmac-user",
68
+ "hmac-user",
69
+ "other-user",
70
+ ]);
71
+ });
72
+
73
+ it("returns zero when no rows match the lookup value", async () => {
74
+ const { schema } = await prepare();
75
+
76
+ const redacted = await sql.begin((tx) =>
77
+ redactSatelliteTable(tx, `${schema}.orders`, "user_ref", "missing-user", "hmac-user", 100)
78
+ );
79
+
80
+ expect(redacted).toBe(0);
81
+ });
82
+
83
+ it("yields to the Bun event loop between satellite batches", async () => {
84
+ const { schema } = await prepare();
85
+
86
+ for (let index = 0; index < 3; index += 1) {
87
+ await sql`
88
+ INSERT INTO ${sql(schema)}.orders (user_ref, amount)
89
+ VALUES ('yield-user', ${index + 1})
90
+ `;
91
+ }
92
+
93
+ const runtime = globalThis as typeof globalThis & {
94
+ Bun?: { sleep?: (ms: number) => Promise<void> };
95
+ };
96
+ const originalBun = runtime.Bun;
97
+ const originalSleep = runtime.Bun?.sleep;
98
+ const sleepMock = vi.fn(async () => { });
99
+ runtime.Bun = {
100
+ ...(runtime.Bun ?? {}),
101
+ sleep: sleepMock,
102
+ };
103
+ try {
104
+ const redacted = await sql.begin((tx) =>
105
+ redactSatelliteTable(tx, `${schema}.orders`, "user_ref", "yield-user", "hmac-user", 1)
106
+ );
107
+
108
+ expect(redacted).toBe(3);
109
+ expect(sleepMock).toHaveBeenCalledTimes(3);
110
+ expect(sleepMock).toHaveBeenCalledWith(0);
111
+ } finally {
112
+ if (originalBun) {
113
+ runtime.Bun = { ...originalBun, sleep: originalSleep };
114
+ } else {
115
+ Reflect.deleteProperty(runtime, "Bun");
116
+ }
117
+ }
118
+ });
119
+ });
@@ -0,0 +1,237 @@
1
+ import { afterAll, describe, expect, it } 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
+ import { assertConfigSchemaCompatibility, readWorkerConfig } from "@modules/config";
6
+ import { createTestSql, dropSchemas, uniqueSchema } from "./helpers";
7
+ import type { Sql } from "@/types";
8
+
9
+ const masterKeyHex = "42".repeat(32);
10
+ const hmacKeyBase64 = Buffer.from(new Uint8Array(32).fill(0x24)).toString("base64");
11
+
12
+ async function writeYaml(contents: string): Promise<string> {
13
+ const directory = await mkdtemp(join(tmpdir(), "worker-compat-"));
14
+ const path = join(directory, "compliance.worker.yml");
15
+ await writeFile(path, contents, "utf8");
16
+ return path;
17
+ }
18
+
19
+ async function removeYaml(path: string) {
20
+ await rm(path, { force: true });
21
+ await rm(dirname(path), { recursive: true, force: true });
22
+ }
23
+
24
+ describe("Worker config schema compatibility", () => {
25
+ const sql: Sql = createTestSql();
26
+ const schemasToDrop: string[] = [];
27
+ const pathsToDelete: string[] = [];
28
+
29
+ afterAll(async () => {
30
+ for (const path of pathsToDelete.splice(0, pathsToDelete.length)) {
31
+ await removeYaml(path);
32
+ }
33
+ await dropSchemas(sql, ...schemasToDrop);
34
+ await sql.end();
35
+ });
36
+
37
+ async function createCompatibleSchema(schema: string, includeRootLookupColumn: boolean) {
38
+ await dropSchemas(sql, schema);
39
+ await sql`CREATE SCHEMA ${sql(schema)}`;
40
+ await sql`
41
+ CREATE TABLE ${sql(schema)}.users (
42
+ id TEXT PRIMARY KEY,
43
+ ${includeRootLookupColumn ? sql`user_identifier TEXT NOT NULL,` : sql``}
44
+ email TEXT NOT NULL,
45
+ full_name TEXT NOT NULL
46
+ )
47
+ `;
48
+ await sql`
49
+ CREATE TABLE ${sql(schema)}.marketing_leads (
50
+ id BIGSERIAL PRIMARY KEY,
51
+ email TEXT NOT NULL,
52
+ name TEXT NOT NULL
53
+ )
54
+ `;
55
+ await sql`
56
+ CREATE TABLE ${sql(schema)}.system_audit_logs (
57
+ id BIGSERIAL PRIMARY KEY,
58
+ user_identifier TEXT NOT NULL,
59
+ message TEXT NOT NULL
60
+ )
61
+ `;
62
+ await sql`
63
+ CREATE TABLE ${sql(schema)}.transactions (
64
+ id TEXT NOT NULL,
65
+ transaction_ref TEXT PRIMARY KEY,
66
+ amount NUMERIC(18,2) NOT NULL
67
+ )
68
+ `;
69
+ await sql`
70
+ CREATE TABLE ${sql(schema)}.invoices (
71
+ id TEXT NOT NULL,
72
+ invoice_ref TEXT PRIMARY KEY,
73
+ total NUMERIC(18,2) NOT NULL
74
+ )
75
+ `;
76
+ await sql`
77
+ CREATE TABLE ${sql(schema)}.kyc_documents (
78
+ id TEXT NOT NULL,
79
+ document_ref TEXT PRIMARY KEY
80
+ )
81
+ `;
82
+ }
83
+
84
+ async function loadConfig(appSchema: string, engineSchema: string) {
85
+ const path = await writeYaml(`
86
+ version: "1.0"
87
+ database:
88
+ app_schema: ${appSchema}
89
+ engine_schema: ${engineSchema}
90
+ compliance_policy:
91
+ default_retention_years: 0
92
+ notice_window_hours: 48
93
+ retention_rules:
94
+ - rule_name: PMLA_FINANCIAL
95
+ legal_citation: "Prevention of Money Laundering Act, 2002, Sec 12"
96
+ if_has_data_in:
97
+ - transactions
98
+ - invoices
99
+ retention_years: 10
100
+ - rule_name: RBI_KYC
101
+ legal_citation: "RBI KYC Directions, 2016, Sec 38"
102
+ if_has_data_in:
103
+ - kyc_documents
104
+ retention_years: 5
105
+ graph:
106
+ root_table: users
107
+ root_id_column: id
108
+ max_depth: 32
109
+ notice_email_column: email
110
+ notice_name_column: full_name
111
+ root_pii_columns:
112
+ email: HMAC
113
+ full_name: STATIC_MASK
114
+ satellite_targets:
115
+ - table: marketing_leads
116
+ lookup_column: email
117
+ action: redact
118
+ masking_rules:
119
+ email: HMAC
120
+ name: STATIC_MASK
121
+ - table: system_audit_logs
122
+ lookup_column: user_identifier
123
+ action: hard_delete
124
+ outbox:
125
+ batch_size: 10
126
+ lease_seconds: 60
127
+ max_attempts: 10
128
+ base_backoff_ms: 1000
129
+ security:
130
+ notification_lease_seconds: 120
131
+ master_key_env: DPDP_MASTER_KEY
132
+ hmac_key_env: DPDP_HMAC_KEY
133
+ integrity:
134
+ expected_schema_hash: "${"1".repeat(64)}"
135
+ legal_attestation:
136
+ dpo_identifier: "dpo-name@client.com"
137
+ configuration_version: "v1.2.0"
138
+ legal_review_date: "2026-04-20"
139
+ acknowledgment: "I confirm this configuration accurately reflects our obligations."
140
+ `);
141
+ pathsToDelete.push(path);
142
+
143
+ return await readWorkerConfig(
144
+ {
145
+ DPDP_MASTER_KEY: masterKeyHex,
146
+ DPDP_HMAC_KEY: `base64:${hmacKeyBase64}`,
147
+ },
148
+ path
149
+ );
150
+ }
151
+
152
+ it("accepts a schema that satisfies every configured root, satellite, and evidence reference", async () => {
153
+ const appSchema = uniqueSchema("compat_ok_app");
154
+ const engineSchema = uniqueSchema("compat_ok_engine");
155
+ schemasToDrop.push(appSchema, engineSchema);
156
+ await createCompatibleSchema(appSchema, true);
157
+
158
+ const config = await loadConfig(appSchema, engineSchema);
159
+ await expect(assertConfigSchemaCompatibility(sql, config)).resolves.toBeUndefined();
160
+ });
161
+
162
+ it("fails closed when a satellite lookup column is missing from the root table", async () => {
163
+ const appSchema = uniqueSchema("compat_bad_app");
164
+ const engineSchema = uniqueSchema("compat_bad_engine");
165
+ schemasToDrop.push(appSchema, engineSchema);
166
+ await createCompatibleSchema(appSchema, false);
167
+
168
+ const config = await loadConfig(appSchema, engineSchema);
169
+ await expect(assertConfigSchemaCompatibility(sql, config)).rejects.toThrow(
170
+ new RegExp(`missing root column ${appSchema}\\.users\\.user_identifier`, "i")
171
+ );
172
+ });
173
+
174
+ it("fails closed when enabled purge policy references a missing root column", async () => {
175
+ const appSchema = uniqueSchema("compat_purge_bad_app");
176
+ const engineSchema = uniqueSchema("compat_purge_bad_engine");
177
+ schemasToDrop.push(appSchema, engineSchema);
178
+ await createCompatibleSchema(appSchema, true);
179
+
180
+ const path = await writeYaml(`
181
+ version: "1.0"
182
+ database:
183
+ app_schema: ${appSchema}
184
+ engine_schema: ${engineSchema}
185
+ compliance_policy:
186
+ default_retention_years: 0
187
+ notice_window_hours: 48
188
+ retention_rules:
189
+ - rule_name: RBI_KYC
190
+ legal_citation: "RBI KYC Directions, 2016, Sec 38"
191
+ if_has_data_in:
192
+ - kyc_documents
193
+ retention_years: 5
194
+ graph:
195
+ root_table: users
196
+ root_id_column: id
197
+ max_depth: 32
198
+ root_pii_columns:
199
+ email: HMAC
200
+ full_name: STATIC_MASK
201
+ purge_policy:
202
+ enabled: true
203
+ selector:
204
+ kind: boolean_column
205
+ column: purge_eligible
206
+ value: true
207
+ outbox:
208
+ batch_size: 10
209
+ lease_seconds: 60
210
+ max_attempts: 10
211
+ base_backoff_ms: 1000
212
+ security:
213
+ notification_lease_seconds: 120
214
+ master_key_env: DPDP_MASTER_KEY
215
+ hmac_key_env: DPDP_HMAC_KEY
216
+ integrity:
217
+ expected_schema_hash: "${"1".repeat(64)}"
218
+ legal_attestation:
219
+ dpo_identifier: "dpo-name@client.com"
220
+ configuration_version: "v1.2.0"
221
+ legal_review_date: "2026-04-20"
222
+ acknowledgment: "I confirm this configuration accurately reflects our obligations."
223
+ `);
224
+ pathsToDelete.push(path);
225
+
226
+ const config = await readWorkerConfig(
227
+ {
228
+ DPDP_MASTER_KEY: masterKeyHex,
229
+ DPDP_HMAC_KEY: `base64:${hmacKeyBase64}`,
230
+ },
231
+ path
232
+ );
233
+ await expect(assertConfigSchemaCompatibility(sql, config)).rejects.toThrow(
234
+ new RegExp(`missing root column ${appSchema}\\.users\\.purge_eligible`, "i")
235
+ );
236
+ });
237
+ });
@@ -0,0 +1,64 @@
1
+ import { afterAll, beforeAll, describe, expect, it } from "vitest";
2
+ import type { Sql } from "@/types";
3
+ import { createTestSql, dropSchemas, uniqueSchema } from "./helpers";
4
+ import { detectSchemaDrift } from "@modules/db";
5
+ import { assertSchemaIntegrity } from "@modules/bootstrap";
6
+
7
+ describe("Schema Drift Detection", () => {
8
+ let sql: Sql;
9
+ const schemasToDrop: string[] = [];
10
+
11
+ beforeAll(() => {
12
+ sql = createTestSql();
13
+ });
14
+
15
+ afterAll(async () => {
16
+ await dropSchemas(sql, ...schemasToDrop);
17
+ await sql.end();
18
+ });
19
+
20
+ async function createSchema() {
21
+ const schema = uniqueSchema("drift_app");
22
+ schemasToDrop.push(schema);
23
+
24
+ await dropSchemas(sql, schema);
25
+ await sql`CREATE SCHEMA ${sql(schema)}`;
26
+ await sql`
27
+ CREATE TABLE ${sql(schema)}.users (
28
+ id SERIAL PRIMARY KEY,
29
+ email TEXT NOT NULL,
30
+ full_name TEXT NOT NULL
31
+ )
32
+ `;
33
+ await sql`
34
+ CREATE TABLE ${sql(schema)}.orders (
35
+ id SERIAL PRIMARY KEY,
36
+ user_id INTEGER NOT NULL REFERENCES ${sql(schema)}.users(id),
37
+ amount NUMERIC NOT NULL
38
+ )
39
+ `;
40
+
41
+ return schema;
42
+ }
43
+
44
+ it("returns a deterministic digest and changes when the schema changes", async () => {
45
+ const schema = await createSchema();
46
+
47
+ const first = await detectSchemaDrift(sql, schema);
48
+ const second = await detectSchemaDrift(sql, schema);
49
+ expect(second).toBe(first);
50
+
51
+ await sql`ALTER TABLE ${sql(schema)}.users ADD COLUMN phone TEXT`;
52
+
53
+ const third = await detectSchemaDrift(sql, schema);
54
+ expect(third).not.toBe(first);
55
+ });
56
+
57
+ it("fails closed when the manifest hash does not match the live schema digest", async () => {
58
+ const schema = await createSchema();
59
+ const liveHash = await detectSchemaDrift(sql, schema);
60
+
61
+ await expect(assertSchemaIntegrity(sql, schema, liveHash)).resolves.toBe(liveHash);
62
+ await expect(assertSchemaIntegrity(sql, schema, "0".repeat(64))).rejects.toThrow(/schema drift detected/i);
63
+ });
64
+ });
@@ -0,0 +1,163 @@
1
+ import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
2
+ import { dispatchPreErasureNotice, shredUser, vaultUser } from "@modules/engine";
3
+ import {
4
+ TEST_SECRETS,
5
+ createTestSql,
6
+ dropSchemas,
7
+ insertUser,
8
+ prepareWorkerSchemas,
9
+ uniqueSchema,
10
+ } from "./helpers";
11
+ import type { Sql } from "@/types";
12
+
13
+ describe("Crypto-Shredder Engine", () => {
14
+ let sql: Sql;
15
+ const schemasToDrop: string[] = [];
16
+
17
+ beforeAll(() => {
18
+ sql = createTestSql();
19
+ });
20
+
21
+ afterAll(async () => {
22
+ await dropSchemas(sql, ...schemasToDrop);
23
+ await sql.end();
24
+ });
25
+
26
+ async function prepare() {
27
+ const appSchema = uniqueSchema("shred_app");
28
+ const engineSchema = uniqueSchema("shred_engine");
29
+ schemasToDrop.push(appSchema, engineSchema);
30
+ await prepareWorkerSchemas(sql, appSchema, engineSchema, { withDependencies: true });
31
+ return { appSchema, engineSchema };
32
+ }
33
+
34
+ async function seedUser(appSchema: string, engineSchema: string) {
35
+ const userId = await insertUser(sql, appSchema, "shred.me@example.com", "Shred Me");
36
+ await vaultUser(sql, userId, TEST_SECRETS, {
37
+ appSchema,
38
+ engineSchema,
39
+ now: new Date("2020-01-01T00:00:00.000Z"),
40
+ defaultRetentionYears: 1,
41
+ noticeWindowHours: 48,
42
+ rootTable: "users",
43
+ rootIdColumn: "id",
44
+ rootPiiColumns: {
45
+ email: "HMAC",
46
+ full_name: "STATIC_MASK",
47
+ },
48
+ satelliteTargets: [],
49
+ compiledTargets: [
50
+ { table: `${appSchema}.users`, pii_columns: ["email", "full_name"] },
51
+ {
52
+ table: `${appSchema}.orders`,
53
+ parent: `${appSchema}.users`,
54
+ join: `${appSchema}.users.id = ${appSchema}.orders.user_id`,
55
+ pii_columns: [],
56
+ },
57
+ ],
58
+ });
59
+ return userId;
60
+ }
61
+
62
+ async function sendNotice(appSchema: string, engineSchema: string, userId: number) {
63
+ await dispatchPreErasureNotice(
64
+ sql,
65
+ userId,
66
+ TEST_SECRETS,
67
+ {
68
+ sendEmail: vi.fn().mockResolvedValue(undefined),
69
+ },
70
+ {
71
+ appSchema,
72
+ engineSchema,
73
+ now: new Date("2020-12-30T00:00:00.000Z"),
74
+ }
75
+ );
76
+ }
77
+
78
+ it("shreds the key after retention expiry and replaces the vault payload with a destroyed sentinel", async () => {
79
+ const { appSchema, engineSchema } = await prepare();
80
+ const userId = await seedUser(appSchema, engineSchema);
81
+ await sendNotice(appSchema, engineSchema, userId);
82
+
83
+ const result = await shredUser(sql, userId, {
84
+ appSchema,
85
+ engineSchema,
86
+ now: new Date("2021-01-02T00:00:00.000Z"),
87
+ });
88
+
89
+ expect(result.action).toBe("shredded");
90
+
91
+ const keysAfter = await sql`
92
+ SELECT *
93
+ FROM ${sql(engineSchema)}.user_keys
94
+ WHERE user_uuid_hash = ${result.userHash}
95
+ `;
96
+ const [vaultAfter] = await sql`
97
+ SELECT encrypted_pii, shredded_at
98
+ FROM ${sql(engineSchema)}.pii_vault
99
+ WHERE root_schema = ${appSchema}
100
+ AND root_table = 'users'
101
+ AND root_id = ${userId.toString()}
102
+ `;
103
+ const outboxRows = await sql`
104
+ SELECT *
105
+ FROM ${sql(engineSchema)}.outbox
106
+ WHERE idempotency_key = ${`shred:${appSchema}:users:${userId}`}
107
+ `;
108
+
109
+ expect(keysAfter).toHaveLength(0);
110
+ expect(vaultAfter?.encrypted_pii).toEqual({ v: 1, destroyed: true });
111
+ expect(vaultAfter?.shredded_at).toBeDefined();
112
+ expect(outboxRows).toHaveLength(1);
113
+ expect(outboxRows[0]?.event_type).toBe("SHRED_SUCCESS");
114
+ });
115
+
116
+ it("refuses to shred before the retention expiry", async () => {
117
+ const { appSchema, engineSchema } = await prepare();
118
+ const userId = await seedUser(appSchema, engineSchema);
119
+ await sendNotice(appSchema, engineSchema, userId);
120
+
121
+ await expect(
122
+ shredUser(sql, userId, {
123
+ appSchema,
124
+ engineSchema,
125
+ now: new Date("2020-12-31T00:00:00.000Z"),
126
+ })
127
+ ).rejects.toThrow(/before retention expiry/i);
128
+ });
129
+
130
+ it("refuses to shred if the pre-erasure notice has not been sent", async () => {
131
+ const { appSchema, engineSchema } = await prepare();
132
+ const userId = await seedUser(appSchema, engineSchema);
133
+
134
+ await expect(
135
+ shredUser(sql, userId, {
136
+ appSchema,
137
+ engineSchema,
138
+ now: new Date("2021-01-02T00:00:00.000Z"),
139
+ })
140
+ ).rejects.toThrow(/notice has been sent/i);
141
+ });
142
+
143
+ it("is idempotent when the same shred request is replayed", async () => {
144
+ const { appSchema, engineSchema } = await prepare();
145
+ const userId = await seedUser(appSchema, engineSchema);
146
+ await sendNotice(appSchema, engineSchema, userId);
147
+
148
+ const first = await shredUser(sql, userId, {
149
+ appSchema,
150
+ engineSchema,
151
+ now: new Date("2021-01-02T00:00:00.000Z"),
152
+ });
153
+ const second = await shredUser(sql, userId, {
154
+ appSchema,
155
+ engineSchema,
156
+ now: new Date("2021-01-03T00:00:00.000Z"),
157
+ });
158
+
159
+ expect(first.action).toBe("shredded");
160
+ expect(second.action).toBe("already_shredded");
161
+ expect(second.userHash).toBe(first.userHash);
162
+ });
163
+ });