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,213 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+ import { createFetchDispatcher } from "@modules/network";
3
+
4
+ const apiUrl = "https://api.compliance.io/outbox";
5
+ type FetchMock = (input: string | URL | Request, init?: RequestInit) => Promise<Response>;
6
+ describe("Fetch Dispatcher Integration", () => {
7
+ /**
8
+ * Layman Terms:
9
+ * Tests the mailman. We make sure that when he tries to deliver a postcard (an HTTP POST request),
10
+ * he correctly tells us if the house accepted it (Status 200 OK) or if the door was locked (Error).
11
+ *
12
+ * Technical Terms:
13
+ * Validates the `createFetchDispatcher` transport layer. It mocks the global `fetch` API to ensure
14
+ * headers, abort signals, and payload serializations are correctly formatted according to the Outbox
15
+ * event contract.
16
+ */
17
+ it("successfully dispatches an outbox event and parses a 200 OK", async () => {
18
+ const mockFetch = vi.fn<FetchMock>(async () => new Response(null, { status: 200 }));
19
+ vi.spyOn(globalThis, "fetch").mockImplementation(mockFetch as any);
20
+
21
+ const dispatcher = createFetchDispatcher({
22
+ url: apiUrl,
23
+ token: "secret-token",
24
+ clientId: "worker-tenant-1",
25
+ });
26
+
27
+ const success = await dispatcher({
28
+ id: "evt-123",
29
+ idempotency_key: "ik-123",
30
+ user_uuid_hash: "hash-456",
31
+ event_type: "USER_VAULTED",
32
+ payload: {
33
+ request_id: "3dc5c993-2297-4138-906f-f8569d60c611",
34
+ subject_opaque_id: "usr_1",
35
+ event_timestamp: "2026-04-19T10:00:00.000Z",
36
+ },
37
+ previous_hash: "GENESIS",
38
+ current_hash: "abcd",
39
+ chain_status: "finalized",
40
+ status: "pending",
41
+ attempt_count: 0,
42
+ lease_token: null,
43
+ lease_expires_at: null,
44
+ next_attempt_at: new Date(),
45
+ processed_at: null,
46
+ last_error: null,
47
+ created_at: new Date(),
48
+ updated_at: new Date(),
49
+ });
50
+
51
+ expect(success).toBe(true);
52
+ expect(mockFetch).toHaveBeenCalledTimes(1);
53
+
54
+ // Verify headers and body
55
+ const [url, requestInit] = mockFetch.mock.calls[0] as [string, RequestInit];
56
+ expect(url).toBe(apiUrl);
57
+ expect(requestInit.headers).toEqual({
58
+ "content-type": "application/json",
59
+ "x-client-id": "worker-tenant-1",
60
+ authorization: "Bearer secret-token",
61
+ });
62
+ expect(requestInit.redirect).toBe("error");
63
+ expect(JSON.parse(String(requestInit.body))).toMatchObject({
64
+ idempotency_key: "ik-123",
65
+ event_type: "USER_VAULTED",
66
+ request_id: "3dc5c993-2297-4138-906f-f8569d60c611",
67
+ subject_opaque_id: "usr_1",
68
+ payload: {
69
+ request_id: "3dc5c993-2297-4138-906f-f8569d60c611",
70
+ subject_opaque_id: "usr_1",
71
+ event_timestamp: "2026-04-19T10:00:00.000Z",
72
+ },
73
+ });
74
+ });
75
+
76
+ it("classifies 500 responses as retryable transport failures", async () => {
77
+ const mockFetch = vi.fn<FetchMock>(async () => new Response(null, { status: 500 }));
78
+
79
+ vi.spyOn(globalThis, "fetch").mockImplementation(mockFetch as any);
80
+
81
+ const dispatcher = createFetchDispatcher({
82
+ url: apiUrl,
83
+ token: "secret-token-2",
84
+ clientId: "worker-tenant-2",
85
+ });
86
+
87
+ await expect(
88
+ dispatcher({
89
+ id: "evt-123",
90
+ idempotency_key: "ik-123",
91
+ user_uuid_hash: "hash-456",
92
+ event_type: "USER_VAULTED",
93
+ payload: {
94
+ request_id: "3dc5c993-2297-4138-906f-f8569d60c611",
95
+ subject_opaque_id: "usr_1",
96
+ event_timestamp: "2026-04-19T10:00:00.000Z",
97
+ },
98
+ previous_hash: "GENESIS",
99
+ current_hash: "abcd",
100
+ chain_status: "finalized",
101
+ status: "pending",
102
+ attempt_count: 0,
103
+ lease_token: null,
104
+ lease_expires_at: null,
105
+ next_attempt_at: new Date(),
106
+ processed_at: null,
107
+ last_error: null,
108
+ created_at: new Date(),
109
+ updated_at: new Date(),
110
+ })
111
+ ).rejects.toMatchObject({
112
+ code: "OUTBOX_DELIVERY_FAILED",
113
+ retryable: true,
114
+ fatal: false,
115
+ });
116
+ });
117
+
118
+ it("classifies 401 responses as fatal configuration failures", async () => {
119
+ const mockFetch = vi.fn<FetchMock>(async () => new Response(null, { status: 401 }));
120
+
121
+ vi.spyOn(globalThis, "fetch").mockImplementation(mockFetch as any);
122
+
123
+ const dispatcher = createFetchDispatcher({
124
+ url: apiUrl,
125
+ token: "secret-token-2",
126
+ clientId: "worker-tenant-3",
127
+ });
128
+
129
+ await expect(
130
+ dispatcher({
131
+ id: "evt-401",
132
+ idempotency_key: "ik-401",
133
+ user_uuid_hash: "hash-401",
134
+ event_type: "USER_VAULTED",
135
+ payload: {
136
+ request_id: "3dc5c993-2297-4138-906f-f8569d60c611",
137
+ subject_opaque_id: "usr_1",
138
+ event_timestamp: "2026-04-19T10:00:00.000Z",
139
+ },
140
+ previous_hash: "GENESIS",
141
+ current_hash: "abcd",
142
+ chain_status: "finalized",
143
+ status: "pending",
144
+ attempt_count: 0,
145
+ lease_token: null,
146
+ lease_expires_at: null,
147
+ next_attempt_at: new Date(),
148
+ processed_at: null,
149
+ last_error: null,
150
+ created_at: new Date(),
151
+ updated_at: new Date(),
152
+ })
153
+ ).rejects.toMatchObject({
154
+ code: "OUTBOX_AUTH_REJECTED",
155
+ retryable: false,
156
+ fatal: true,
157
+ });
158
+ });
159
+
160
+ it("classifies stale WORM chain heads as retryable concurrency failures", async () => {
161
+ const mockFetch = vi.fn<FetchMock>(
162
+ async () =>
163
+ new Response(
164
+ JSON.stringify({
165
+ code: "API_OUTBOX_PREVIOUS_HASH_INVALID",
166
+ retryable: false,
167
+ }),
168
+ {
169
+ status: 409,
170
+ headers: { "content-type": "application/json" },
171
+ }
172
+ )
173
+ );
174
+
175
+ vi.spyOn(globalThis, "fetch").mockImplementation(mockFetch as any);
176
+
177
+ const dispatcher = createFetchDispatcher({
178
+ url: apiUrl,
179
+ token: "secret-token-3",
180
+ clientId: "worker-tenant-4",
181
+ });
182
+
183
+ await expect(
184
+ dispatcher({
185
+ id: "evt-409",
186
+ idempotency_key: "ik-409",
187
+ user_uuid_hash: "hash-409",
188
+ event_type: "NOTIFICATION_SENT",
189
+ payload: {
190
+ request_id: "3dc5c993-2297-4138-906f-f8569d60c611",
191
+ subject_opaque_id: "usr_1",
192
+ event_timestamp: "2026-04-19T10:00:00.000Z",
193
+ },
194
+ previous_hash: "stale-head",
195
+ current_hash: "abcd",
196
+ chain_status: "finalized",
197
+ status: "pending",
198
+ attempt_count: 0,
199
+ lease_token: null,
200
+ lease_expires_at: null,
201
+ next_attempt_at: new Date(),
202
+ processed_at: null,
203
+ last_error: null,
204
+ created_at: new Date(),
205
+ updated_at: new Date(),
206
+ })
207
+ ).rejects.toMatchObject({
208
+ code: "OUTBOX_DELIVERY_FAILED",
209
+ retryable: true,
210
+ fatal: false,
211
+ });
212
+ });
213
+ });
@@ -0,0 +1,84 @@
1
+ import { afterAll, beforeAll, describe, expect, it } from "vitest";
2
+ import { getDependencyGraph } from "@modules/db";
3
+ import { createTestSql, dropSchemas, uniqueSchema } from "./helpers";
4
+ import type { Sql } from "@/types";
5
+
6
+ describe("Graph Engine (Database Crawler)", () => {
7
+ let sql: Sql;
8
+ const schema = uniqueSchema("graph");
9
+
10
+ beforeAll(async () => {
11
+ sql = createTestSql();
12
+
13
+ await dropSchemas(sql, schema);
14
+ await sql`CREATE SCHEMA ${sql(schema)}`;
15
+
16
+ await sql`CREATE TABLE ${sql(schema)}.users (id SERIAL PRIMARY KEY, email TEXT, full_name TEXT)`;
17
+ await sql`CREATE TABLE ${sql(schema)}.orders (id SERIAL PRIMARY KEY, user_id INTEGER REFERENCES ${sql(schema)}.users(id))`;
18
+ await sql`CREATE TABLE ${sql(schema)}.profiles (id SERIAL PRIMARY KEY, user_id INTEGER REFERENCES ${sql(schema)}.users(id))`;
19
+ await sql`CREATE TABLE ${sql(schema)}.shipping_addresses (id SERIAL PRIMARY KEY, order_id INTEGER REFERENCES ${sql(schema)}.orders(id))`;
20
+ await sql`CREATE TABLE ${sql(schema)}.address_verification_logs (id SERIAL PRIMARY KEY, address_id INTEGER REFERENCES ${sql(schema)}.shipping_addresses(id))`;
21
+ await sql`CREATE TABLE ${sql(schema)}.level_4 (id SERIAL PRIMARY KEY, log_id INTEGER REFERENCES ${sql(schema)}.address_verification_logs(id))`;
22
+ await sql`CREATE TABLE ${sql(schema)}.level_5 (id SERIAL PRIMARY KEY, l4_id INTEGER REFERENCES ${sql(schema)}.level_4(id))`;
23
+ await sql`CREATE TABLE ${sql(schema)}.orphan_table (id SERIAL PRIMARY KEY, data TEXT)`;
24
+ await sql`CREATE TABLE ${sql(schema)}.circ_a (id SERIAL PRIMARY KEY)`;
25
+ await sql`CREATE TABLE ${sql(schema)}.circ_b (id SERIAL PRIMARY KEY, a_id INTEGER REFERENCES ${sql(schema)}.circ_a(id))`;
26
+ await sql`ALTER TABLE ${sql(schema)}.circ_a ADD COLUMN b_id INTEGER REFERENCES ${sql(schema)}.circ_b(id)`;
27
+ await sql`CREATE TABLE ${sql(schema)}.cascade_root (id SERIAL PRIMARY KEY)`;
28
+ await sql`CREATE TABLE ${sql(schema)}.cascade_child (id SERIAL PRIMARY KEY, root_id INTEGER REFERENCES ${sql(schema)}.cascade_root(id) ON DELETE CASCADE)`;
29
+ await sql`CREATE TABLE ${sql(schema)}.set_null_root (id SERIAL PRIMARY KEY)`;
30
+ await sql`CREATE TABLE ${sql(schema)}.set_null_child (id SERIAL PRIMARY KEY, root_id INTEGER REFERENCES ${sql(schema)}.set_null_root(id) ON DELETE SET NULL)`;
31
+ });
32
+
33
+ afterAll(async () => {
34
+ await dropSchemas(sql, schema);
35
+ await sql.end();
36
+ });
37
+
38
+ it("maps a multi-level dependency graph without duplicate loop inflation", async () => {
39
+ const graph = await getDependencyGraph(sql, schema, "users");
40
+ const tableNames = graph.map((node) => node.table_name);
41
+
42
+ expect(tableNames).toContain(`${schema}.orders`);
43
+ expect(tableNames).toContain(`${schema}.profiles`);
44
+ expect(tableNames).toContain(`${schema}.shipping_addresses`);
45
+ expect(tableNames).toContain(`${schema}.address_verification_logs`);
46
+ expect(tableNames).toContain(`${schema}.level_5`);
47
+
48
+ const orderNode = graph.find((node) => node.table_name === `${schema}.orders`);
49
+ const shippingNode = graph.find((node) => node.table_name === `${schema}.shipping_addresses`);
50
+ const logNode = graph.find((node) => node.table_name === `${schema}.address_verification_logs`);
51
+ const level5Node = graph.find((node) => node.table_name === `${schema}.level_5`);
52
+
53
+ expect(orderNode?.depth).toBe(1);
54
+ expect(orderNode?.delete_action).toBe("NO_ACTION");
55
+ expect(shippingNode?.depth).toBe(2);
56
+ expect(logNode?.depth).toBe(3);
57
+ expect(level5Node?.depth).toBe(5);
58
+ });
59
+
60
+ it("returns an empty array for a table with zero dependencies", async () => {
61
+ await expect(getDependencyGraph(sql, schema, "orphan_table")).resolves.toEqual([]);
62
+ });
63
+
64
+ it("breaks cycles instead of looping until a depth guard happens to stop it", async () => {
65
+ const graph = await getDependencyGraph(sql, schema, "circ_a");
66
+ const nodesForCircB = graph.filter((node) => node.table_name === `${schema}.circ_b`);
67
+
68
+ expect(nodesForCircB).toHaveLength(1);
69
+ expect(nodesForCircB[0]?.depth).toBe(1);
70
+ });
71
+
72
+ it("fails closed when FK delete actions can silently mutate dependent data", async () => {
73
+ await expect(getDependencyGraph(sql, schema, "cascade_root")).rejects.toThrow(/ON DELETE CASCADE/i);
74
+ await expect(getDependencyGraph(sql, schema, "set_null_root")).rejects.toThrow(/ON DELETE SET_NULL/i);
75
+ });
76
+
77
+ it("fails closed when the recursion depth limit would truncate the graph", async () => {
78
+ await expect(getDependencyGraph(sql, schema, "users", { maxDepth: 3 })).rejects.toThrow(/safety limit/i);
79
+ });
80
+
81
+ it("fails clearly when the root table does not exist", async () => {
82
+ await expect(getDependencyGraph(sql, schema, "does_not_exist")).rejects.toThrow(/does not exist/i);
83
+ });
84
+ });
@@ -0,0 +1,101 @@
1
+ import postgres from "postgres";
2
+ import { runMigrations } from "@modules/db";
3
+ import type { Sql } from "@/types";
4
+
5
+ export const TEST_DATABASE_URL = process.env.TEST_DATABASE_URL ?? "postgres://postgres:postgres@localhost:5432/postgres";
6
+
7
+ export const TEST_SECRETS = {
8
+ kek: new Uint8Array(32).fill(0x42),
9
+ hmacKey: new Uint8Array(32).fill(0x24),
10
+ };
11
+
12
+ export function createTestSql(): Sql {
13
+ return postgres(TEST_DATABASE_URL);
14
+ }
15
+
16
+ export function uniqueSchema(prefix: string): string {
17
+ return `${prefix}_${globalThis.crypto.randomUUID().replace(/-/g, "").slice(0, 16)}`;
18
+ }
19
+
20
+ export async function dropSchemas(sql: Sql, ...schemas: string[]) {
21
+ for (const schema of schemas) {
22
+ await sql`DROP SCHEMA IF EXISTS ${sql(schema)} CASCADE`;
23
+ }
24
+ }
25
+
26
+ export async function createAppSchema(
27
+ sql: Sql,
28
+ schema: string,
29
+ options: { withDependencies?: boolean; withDeepDependencies?: boolean } = {}
30
+ ) {
31
+ await sql`CREATE SCHEMA ${sql(schema)}`;
32
+ await sql`
33
+ CREATE TABLE ${sql(schema)}.users (
34
+ id SERIAL PRIMARY KEY,
35
+ email TEXT NOT NULL,
36
+ full_name TEXT NOT NULL
37
+ )
38
+ `;
39
+
40
+ if (!options.withDependencies) {
41
+ return;
42
+ }
43
+
44
+ await sql`
45
+ CREATE TABLE ${sql(schema)}.orders (
46
+ id SERIAL PRIMARY KEY,
47
+ user_id INTEGER REFERENCES ${sql(schema)}.users(id),
48
+ amount DECIMAL NOT NULL
49
+ )
50
+ `;
51
+
52
+ await sql`
53
+ CREATE TABLE ${sql(schema)}.profiles (
54
+ id SERIAL PRIMARY KEY,
55
+ user_id INTEGER REFERENCES ${sql(schema)}.users(id),
56
+ bio TEXT
57
+ )
58
+ `;
59
+
60
+ if (!options.withDeepDependencies) {
61
+ return;
62
+ }
63
+
64
+ await sql`
65
+ CREATE TABLE ${sql(schema)}.shipping_addresses (
66
+ id SERIAL PRIMARY KEY,
67
+ order_id INTEGER REFERENCES ${sql(schema)}.orders(id),
68
+ street TEXT NOT NULL,
69
+ city TEXT NOT NULL
70
+ )
71
+ `;
72
+
73
+ await sql`
74
+ CREATE TABLE ${sql(schema)}.address_verification_logs (
75
+ id SERIAL PRIMARY KEY,
76
+ address_id INTEGER REFERENCES ${sql(schema)}.shipping_addresses(id),
77
+ verified_at TIMESTAMPTZ DEFAULT NOW()
78
+ )
79
+ `;
80
+ }
81
+
82
+ export async function insertUser(sql: Sql, schema: string, email: string, fullName: string): Promise<number> {
83
+ const rows = await sql<{ id: number }[]>`
84
+ INSERT INTO ${sql(schema)}.users (email, full_name)
85
+ VALUES (${email}, ${fullName})
86
+ RETURNING id
87
+ `;
88
+
89
+ return rows[0]!.id;
90
+ }
91
+
92
+ export async function prepareWorkerSchemas(
93
+ sql: Sql,
94
+ appSchema: string,
95
+ engineSchema: string,
96
+ options: { withDependencies?: boolean; withDeepDependencies?: boolean } = {}
97
+ ) {
98
+ await dropSchemas(sql, appSchema, engineSchema);
99
+ await createAppSchema(sql, appSchema, options);
100
+ await runMigrations(sql, engineSchema);
101
+ }
@@ -0,0 +1,168 @@
1
+ import { afterAll, beforeAll, describe, expect, it } from "vitest";
2
+ import type { WorkerConfig } from "@modules/config";
3
+ import { assertIndexPreflight, collectIndexRequirements } from "@modules/bootstrap";
4
+ import type { Sql } from "@/types";
5
+ import { createTestSql, dropSchemas, uniqueSchema, TEST_SECRETS } from "./helpers";
6
+
7
+ describe("Worker index preflight", () => {
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 setup() {
21
+ const schema = uniqueSchema("idx_preflight");
22
+ schemasToDrop.push(schema);
23
+ await dropSchemas(sql, schema);
24
+ await sql`CREATE SCHEMA ${sql(schema)}`;
25
+ await sql`
26
+ CREATE TABLE ${sql(schema)}.users (
27
+ id TEXT PRIMARY KEY,
28
+ tenant_id TEXT,
29
+ email TEXT NOT NULL,
30
+ full_name TEXT NOT NULL
31
+ )
32
+ `;
33
+ await sql`
34
+ CREATE TABLE ${sql(schema)}.transactions (
35
+ id BIGSERIAL PRIMARY KEY,
36
+ user_id TEXT NOT NULL,
37
+ amount NUMERIC NOT NULL
38
+ )
39
+ `;
40
+ await sql`
41
+ CREATE TABLE ${sql(schema)}.support_tickets (
42
+ id BIGSERIAL PRIMARY KEY,
43
+ user_id TEXT NOT NULL,
44
+ requester_email TEXT
45
+ )
46
+ `;
47
+
48
+ return schema;
49
+ }
50
+
51
+ function buildConfig(appSchema: string): WorkerConfig {
52
+ return {
53
+ version: "1",
54
+ database: {
55
+ app_schema: appSchema,
56
+ engine_schema: "engine",
57
+ },
58
+ compliance_policy: {
59
+ default_retention_years: 0,
60
+ notice_window_hours: 48,
61
+ retention_rules: [
62
+ {
63
+ rule_name: "PMLA_FINANCIAL",
64
+ legal_citation: "PMLA Sec 12",
65
+ if_has_data_in: ["transactions"],
66
+ retention_years: 10,
67
+ },
68
+ ],
69
+ },
70
+ graph: {
71
+ root_table: "users",
72
+ root_id_column: "id",
73
+ max_depth: 32,
74
+ root_pii_columns: {
75
+ email: "HMAC",
76
+ full_name: "STATIC_MASK",
77
+ },
78
+ },
79
+ satellite_targets: [
80
+ {
81
+ table: "support_tickets",
82
+ lookup_column: "user_id",
83
+ action: "redact",
84
+ masking_rules: {
85
+ requester_email: "HMAC",
86
+ },
87
+ },
88
+ ],
89
+ blob_targets: [],
90
+ rules: [
91
+ {
92
+ id: "dpdp_static",
93
+ targets: [
94
+ {
95
+ table: `${appSchema}.users`,
96
+ parent_columns: [],
97
+ child_columns: [],
98
+ primary_key_columns: ["id"],
99
+ pii_columns: ["email"],
100
+ },
101
+ {
102
+ table: `${appSchema}.support_tickets`,
103
+ parent: `${appSchema}.users`,
104
+ parent_columns: ["id"],
105
+ child_columns: ["user_id"],
106
+ primary_key_columns: ["id"],
107
+ action: "redact",
108
+ mutation_rules: {
109
+ requester_email: "HMAC",
110
+ },
111
+ pii_columns: ["requester_email"],
112
+ },
113
+ ],
114
+ },
115
+ ],
116
+ outbox: {
117
+ batch_size: 10,
118
+ lease_seconds: 30,
119
+ max_attempts: 3,
120
+ base_backoff_ms: 1000,
121
+ },
122
+ security: {
123
+ notification_lease_seconds: 120,
124
+ master_key_env: "DPDP_MASTER_KEY",
125
+ hmac_key_env: "DPDP_HMAC_KEY",
126
+ },
127
+ integrity: {
128
+ expected_schema_hash: "ab".repeat(32),
129
+ },
130
+ legal_attestation: {
131
+ dpo_identifier: "dpo@example.com",
132
+ configuration_version: "v1",
133
+ legal_review_date: "2026-05-01",
134
+ schema_hash: "ab".repeat(32),
135
+ acknowledgment: "Reviewed",
136
+ },
137
+ masterKey: TEST_SECRETS.kek,
138
+ hmacKey: TEST_SECRETS.hmacKey,
139
+ };
140
+ }
141
+
142
+ it("collects every runtime lookup that must be index-backed", async () => {
143
+ const schema = await setup();
144
+ const requirements = collectIndexRequirements(buildConfig(schema));
145
+ expect(requirements).toEqual(
146
+ expect.arrayContaining([
147
+ expect.objectContaining({ table: "users", columns: ["id"] }),
148
+ expect.objectContaining({ table: "transactions", columns: ["id"] }),
149
+ expect.objectContaining({ table: "support_tickets", columns: ["id"] }),
150
+ expect.objectContaining({ table: "support_tickets", columns: ["user_id"] }),
151
+ ])
152
+ );
153
+ });
154
+
155
+ it("fails closed when evidence or satellite lookups would table-scan", async () => {
156
+ const schema = await setup();
157
+ const config = buildConfig(schema);
158
+
159
+ await expect(assertIndexPreflight(sql, config)).rejects.toThrow(/missing index requirement/i);
160
+
161
+ await sql`CREATE INDEX ${sql(`${schema}_support_tickets_user_id_idx`)} ON ${sql(schema)}.support_tickets (user_id)`;
162
+
163
+ await expect(assertIndexPreflight(sql, config)).resolves.toEqual({
164
+ checked: expect.any(Number),
165
+ missing: [],
166
+ });
167
+ });
168
+ });
@@ -0,0 +1,62 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import {
3
+ classifyLeaf,
4
+ metadataScore,
5
+ validateAadhaar,
6
+ validateGstin,
7
+ validateLuhn,
8
+ validatePan,
9
+ } from "@modules/introspector";
10
+
11
+ describe("Introspector PII classifier", () => {
12
+ it("detects high-confidence Indian identity and payment identifiers", () => {
13
+ expect(validateAadhaar("2345-6789-1238")).toBe(true);
14
+ expect(classifyLeaf("2345-6789-1238")).toContain("aadhaar");
15
+ expect(classifyLeaf("2345-6789-1234")).not.toContain("aadhaar");
16
+
17
+ expect(validatePan("ABCPE1234F")).toBe(true);
18
+ expect(classifyLeaf("ABCPE1234F")).toContain("pan");
19
+ expect(classifyLeaf("ABCDE1234F")).not.toContain("pan");
20
+
21
+ expect(validateGstin("27ABCPE1234F1Z5")).toBe(true);
22
+ expect(classifyLeaf("27ABCPE1234F1Z5")).toContain("gstin");
23
+ expect(classifyLeaf("27ABCDE1234F1Z5")).not.toContain("gstin");
24
+
25
+ expect(validateLuhn("4111 1111 1111 1111")).toBe(true);
26
+ expect(classifyLeaf("4111 1111 1111 1111")).toContain("credit_card");
27
+ expect(classifyLeaf("4111 1111 1111 1112")).not.toContain("credit_card");
28
+ });
29
+
30
+ it("detects communication, account, and infrastructure identifiers", () => {
31
+ expect(classifyLeaf("person@example.com")).toContain("email");
32
+ expect(classifyLeaf("+91-9876543210")).toContain("indian_mobile");
33
+ expect(classifyLeaf("person.name@upi")).toContain("upi");
34
+ expect(classifyLeaf("HDFC0001234")).toContain("ifsc");
35
+ expect(classifyLeaf("P1234567")).toContain("indian_passport");
36
+ expect(classifyLeaf("ABC1234567")).toContain("voter_epic");
37
+ expect(classifyLeaf("203.0.113.10")).toContain("ipv4");
38
+ expect(classifyLeaf("2001:db8:85a3::8a2e:370:7334")).toContain("ipv6");
39
+ expect(classifyLeaf("00:1A:2B:3C:4D:5E")).toContain("mac_address");
40
+ });
41
+
42
+ it("requires metadata support for false-positive-prone numeric patterns", () => {
43
+ expect(classifyLeaf("123456789012")).not.toContain("bank_account");
44
+ expect(classifyLeaf("123456789012", "bank_account_number")).toContain("bank_account");
45
+
46
+ expect(classifyLeaf("1990-01-31")).not.toContain("date_of_birth");
47
+ expect(classifyLeaf("1990-01-31", "date_of_birth")).toContain("date_of_birth");
48
+
49
+ expect(classifyLeaf("KA0120111234567")).not.toContain("indian_driving_license");
50
+ expect(classifyLeaf("KA0120111234567", "driving_license_number")).toContain("indian_driving_license");
51
+
52
+ expect(classifyLeaf("560001")).not.toContain("indian_pin_code");
53
+ expect(classifyLeaf("560001", "postal_code")).toContain("indian_pin_code");
54
+ });
55
+
56
+ it("does not infer personal names without a dedicated NER model", () => {
57
+ expect(metadataScore("full_name")).toBe(0);
58
+ expect(metadataScore("first_name")).toBe(0);
59
+ expect(metadataScore("customer_name")).toBe(0);
60
+ expect(classifyLeaf("Priya Sharma")).toEqual([]);
61
+ });
62
+ });