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,296 @@
1
+ import type { Sql } from "@/types";
2
+ import { assertIdentifier } from "@/utils";
3
+ import type { CompiledExecutionTarget, WorkerConfig } from "@modules/config/validation";
4
+ import { fail } from "@/errors";
5
+
6
+ export interface IndexRequirement {
7
+ schema: string;
8
+ table: string;
9
+ columns: string[];
10
+ reason: string;
11
+ }
12
+
13
+ export interface IndexPreflightResult {
14
+ checked: number,
15
+ missing: IndexRequirement[]
16
+ }
17
+
18
+ interface IndexPrefixRow {
19
+ exists: boolean;
20
+ }
21
+
22
+ interface ColumnExistsRow {
23
+ exists: boolean;
24
+ }
25
+
26
+ function qualifiedTable(value: string, defaultSchema: string): { schema: string; table: string } {
27
+ const parts = value.split(".");
28
+ if (parts.length === 1) {
29
+ return {
30
+ schema: defaultSchema,
31
+ table: assertIdentifier(parts[0]!, "table name"),
32
+ };
33
+ }
34
+
35
+ if (parts.length === 2) {
36
+ return {
37
+ schema: assertIdentifier(parts[0]!, "table schema"),
38
+ table: assertIdentifier(parts[1]!, "table name"),
39
+ };
40
+ }
41
+
42
+ fail({
43
+ code: "INDEX_PREFLIGHT_TABLE_INVALID",
44
+ title: "Invalid index preflight table reference",
45
+ detail: `Expected table or schema.table, received "${value}".`,
46
+ category: "configuration",
47
+ retryable: false,
48
+ fatal: true,
49
+ });
50
+ }
51
+
52
+ function addRequirement(
53
+ requirements: Map<string, IndexRequirement>,
54
+ requirement: IndexRequirement
55
+ ): void {
56
+ const safeRequirement: IndexRequirement = {
57
+ schema: assertIdentifier(requirement.schema, "index requirement schema"),
58
+ table: assertIdentifier(requirement.table, "index requirement schema"),
59
+ columns: requirement.columns.map((column) => assertIdentifier(column, "index requirement schema")),
60
+ reason: requirement.reason,
61
+ }
62
+ const key = `${safeRequirement.schema}.${safeRequirement.table}:${safeRequirement.columns.join(",")}`;
63
+ requirements.set(key, safeRequirement);
64
+ }
65
+
66
+ function addCompiledTargetRequirements(
67
+ requirements: Map<string, IndexRequirement>,
68
+ target: CompiledExecutionTarget,
69
+ defaultSchema: string
70
+ ): void {
71
+ const child = qualifiedTable(target.table, defaultSchema);
72
+ if (target.child_columns.length > 0) {
73
+ addRequirement(requirements, {
74
+ ...child,
75
+ columns: target.child_columns,
76
+ reason: `compiled DAG child join for ${target.table}`,
77
+ });
78
+ }
79
+
80
+ const primaryKeyColumns = target.primary_key_columns.length > 0 ? target.primary_key_columns : ["id"];
81
+ addRequirement(requirements, {
82
+ ...child,
83
+ columns: primaryKeyColumns,
84
+ reason: `compiled DAG bounded mutation identity for ${target.table}`,
85
+ });
86
+
87
+ if (!target.parent || target.parent_columns.length === 0) {
88
+ return;
89
+ }
90
+
91
+ const parent = qualifiedTable(target.parent, defaultSchema);
92
+ addRequirement(requirements, {
93
+ ...parent,
94
+ columns: target.parent_columns,
95
+ reason: `compiled DAG parent join for ${target.table}`,
96
+ });
97
+ }
98
+
99
+ /**
100
+ * Builds the exact lookup/index contract required for safe runtime execution.
101
+ *
102
+ * @param config - DPO-attested worker configuration.
103
+ * @returns Deduplicated index requirements for root, evidence, satellite, blob, and compiled-DAG lookups.
104
+ */
105
+ export function collectIndexRequirements(config: WorkerConfig): IndexRequirement[] {
106
+ const requirements = new Map<string, IndexRequirement>();
107
+ const appSchema = config.database.app_schema;
108
+
109
+ addRequirement(requirements, {
110
+ schema: appSchema,
111
+ table: config.graph.root_table,
112
+ columns: [config.graph.root_id_column],
113
+ reason: "root row lock and task subject lookup",
114
+ });
115
+
116
+ for (const rule of config.compliance_policy.retention_rules) {
117
+ for (const table of rule.if_has_data_in) {
118
+ addRequirement(requirements, {
119
+ schema: appSchema,
120
+ table,
121
+ columns: [config.graph.root_id_column],
122
+ reason: `retention evidence rule ${rule.rule_name}`,
123
+ });
124
+ }
125
+ }
126
+
127
+ for (const target of config.satellite_targets) {
128
+ addRequirement(requirements, {
129
+ schema: appSchema,
130
+ table: target.table,
131
+ columns: [target.lookup_column],
132
+ reason: `satellite ${target.action} lookup`,
133
+ });
134
+ }
135
+
136
+ for (const target of config.blob_targets) {
137
+ if (target.lookup_column) {
138
+ addRequirement(requirements, {
139
+ schema: appSchema,
140
+ table: target.table,
141
+ columns: [target.lookup_column],
142
+ reason: `blob target lookup for ${target.table}.${target.column}`,
143
+ });
144
+ }
145
+ }
146
+
147
+ for (const rule of config.rules ?? []) {
148
+ for (const target of rule.targets) {
149
+ addCompiledTargetRequirements(requirements, target, appSchema);
150
+ }
151
+ }
152
+
153
+ return Array.from(requirements.values()).sort((left, right) =>
154
+ `${left.schema}.${left.table}.${left.columns.join(".")}`.localeCompare(
155
+ `${right.schema}.${right.table}.${right.columns.join(".")}`
156
+ )
157
+ );
158
+ }
159
+
160
+ async function hasIndexPrefix(
161
+ sql: Sql,
162
+ requirement: IndexRequirement
163
+ ): Promise<boolean> {
164
+ const [row] = await sql<IndexPrefixRow[]>`
165
+ SELECT EXISTS (
166
+ SELECT 1
167
+ FROM pg_index AS idx
168
+ JOIN pg_class AS table_class
169
+ ON table_class.oid = idx.indrelid
170
+ JOIN pg_namespace AS table_namespace
171
+ ON table_namespace.oid = table_class.relnamespace
172
+ JOIN LATERAL unnest(idx.indkey) WITH ORDINALITY AS indexed(attnum, ordinality)
173
+ ON indexed.ordinality <= ${requirement.columns.length}
174
+ JOIN pg_attribute AS attribute
175
+ ON attribute.attrelid = table_class.oid
176
+ AND attribute.attnum = indexed.attnum
177
+ WHERE table_namespace.nspname = ${requirement.schema}
178
+ AND table_class.relname = ${requirement.table}
179
+ AND idx.indisvalid
180
+ AND idx.indisready
181
+ AND idx.indpred IS NULL
182
+ GROUP BY idx.indexrelid
183
+ HAVING array_agg(attribute.attname ORDER BY indexed.ordinality) = ${requirement.columns}
184
+ OR array_agg(attribute.attname ORDER BY attribute.attname) = ${[...requirement.columns].sort()}
185
+ ) AS exists
186
+ `;
187
+
188
+ return row?.exists ?? false;
189
+ }
190
+
191
+ async function tableHasColumn(
192
+ sql: Sql,
193
+ schema: string,
194
+ table: string,
195
+ column: string
196
+ ): Promise<boolean> {
197
+ const [row] = await sql<ColumnExistsRow[]>`
198
+ SELECT EXISTS (
199
+ SELECT 1
200
+ FROM information_schema.columns
201
+ WHERE table_schema = ${schema}
202
+ AND table_name = ${table}
203
+ AND column_name = ${column}
204
+ ) AS exists
205
+ `;
206
+ return row?.exists ?? false;
207
+ }
208
+
209
+ async function collectTenantScopedRequirements(
210
+ sql: Sql,
211
+ requirements: IndexRequirement[]
212
+ ): Promise<IndexRequirement[]> {
213
+ const tenantRequirements: IndexRequirement[] = [];
214
+ const seenTables = new Map<string, boolean>();
215
+
216
+ for (const requirement of requirements) {
217
+ if (requirement.columns.includes("tenant_id")) {
218
+ continue;
219
+ }
220
+ if (
221
+ requirement.reason.startsWith("root row lock") ||
222
+ requirement.reason.startsWith("compiled DAG parent join")
223
+ ) {
224
+ continue;
225
+ }
226
+
227
+ const key = `${requirement.schema}.${requirement.table}`;
228
+ let hasTenantId = seenTables.get(key);
229
+ if (hasTenantId === undefined) {
230
+ hasTenantId = await tableHasColumn(sql, requirement.schema, requirement.table, "tenant_id");
231
+ seenTables.set(key, hasTenantId);
232
+ }
233
+
234
+ if (hasTenantId) {
235
+ tenantRequirements.push({
236
+ ...requirement,
237
+ columns: [...requirement.columns, "tenant_id"],
238
+ reason: `${requirement.reason} with tenant isolation`,
239
+ });
240
+ }
241
+ }
242
+
243
+ return tenantRequirements;
244
+ }
245
+
246
+ /**
247
+ * Verifies that configured runtime lookups are backed by usable non-partial indexes.
248
+ *
249
+ * The worker can mutate safely only when root locks, retention evidence probes, satellite
250
+ * batches, and compiled-DAG joins are index-backed. This preflight prevents a bad YAML from
251
+ * creating table scans or broad lock pressure against very large tenant databases.
252
+ *
253
+ * @param sql - Postgres pool used for catalog inspection.
254
+ * @param config - Parsed worker configuration.
255
+ * @returns Requirement summary when every lookup is index-backed.
256
+ * @throws {WorkerError} When one or more required indexes are missing.
257
+ */
258
+ export async function assertIndexPreflight(
259
+ sql: Sql,
260
+ config: WorkerConfig
261
+ ): Promise<IndexPreflightResult> {
262
+ const baseRequirements = collectIndexRequirements(config);
263
+ const requirements = [
264
+ ...baseRequirements,
265
+ ...await collectTenantScopedRequirements(sql, baseRequirements),
266
+ ];
267
+ const missing: IndexRequirement[] = [];
268
+
269
+ for (const requirement of requirements) {
270
+ if (!await hasIndexPrefix(sql, requirement)) {
271
+ missing.push(requirement);
272
+ }
273
+ }
274
+
275
+ if (missing.length > 0) {
276
+ fail({
277
+ code: "INDEX_PREFLIGHT_FAILED",
278
+ title: "Required lookup indexes are missing",
279
+ detail: `Detected ${missing.length} missing index requirement(s): ${missing
280
+ .map((item) => `${item.schema}.${item.table}(${item.columns.join(", ")}) for ${item.reason}`)
281
+ .join("; ")}`,
282
+ category: "configuration",
283
+ retryable: false,
284
+ fatal: true,
285
+ context: {
286
+ checked: requirements.length,
287
+ missing,
288
+ },
289
+ });
290
+ }
291
+
292
+ return {
293
+ checked: requirements.length,
294
+ missing,
295
+ };
296
+ }
@@ -0,0 +1,48 @@
1
+ import postgres from "postgres";
2
+ import path from "node:path";
3
+ import { UI, exitWithError } from "./ui";
4
+ import { assertSchemaIntegrity } from "@modules/bootstrap";
5
+ import { assertConfigSchemaCompatibility, readWorkerConfig } from "@modules/config";
6
+
7
+ /**
8
+ * Runs fail-closed integrity validation for CI/CD and worker boot gates.
9
+ *
10
+ * @param options - Manifest path and database URL.
11
+ * @returns Resolves only when schema hash and compiled DAG checks pass.
12
+ */
13
+ export async function checkIntegrityAction(options: { config: string; url?: string }) {
14
+ UI.header("Fail-Closed Integrity Check");
15
+
16
+ const dbUrl = options.url || process.env.DATABASE_URL;
17
+ if (!dbUrl) {
18
+ exitWithError("Database URL required.", "Provide --url or set DATABASE_URL env.");
19
+ }
20
+
21
+ const configPath = path.resolve(options.config);
22
+ const mockEnv = {
23
+ ...process.env,
24
+ DPDP_MASTER_KEY: process.env.DPDP_MASTER_KEY || "0".repeat(64),
25
+ DPDP_HMAC_KEY: process.env.DPDP_HMAC_KEY || "0".repeat(64),
26
+ };
27
+
28
+ const config = await readWorkerConfig(mockEnv, configPath);
29
+ const expectedSchemaHash =
30
+ config.legal_attestation.schema_hash ?? config.integrity.expected_schema_hash;
31
+ const sql = postgres(dbUrl);
32
+
33
+ try {
34
+ const detectedHash = await assertSchemaIntegrity(
35
+ sql,
36
+ config.database.app_schema,
37
+ expectedSchemaHash
38
+ );
39
+ await assertConfigSchemaCompatibility(sql, config);
40
+
41
+ UI.keyValue("Live Schema Hash", detectedHash);
42
+ UI.success("Schema hash, legal attestation, and compiled DAG are current.");
43
+ } catch (error) {
44
+ exitWithError("Integrity check failed", error instanceof Error ? error.message : String(error));
45
+ } finally {
46
+ await sql.end();
47
+ }
48
+ }
@@ -0,0 +1,90 @@
1
+ import postgres from "postgres";
2
+ import pc from "picocolors";
3
+ import { UI, exitWithError } from "./ui";
4
+ import path from "node:path";
5
+ import { readWorkerConfig } from "../config";
6
+ import { vaultUser } from "../engine";
7
+
8
+ /**
9
+ * Preview the results of a vault operation for a specific subject ID.
10
+ */
11
+ export async function dryRunAction(options: {
12
+ id: string,
13
+ config: string,
14
+ url?: string,
15
+ }) {
16
+ UI.header("Vault Simulation");
17
+
18
+ const dbUrl = options.url || process.env.DATABASE_URL;
19
+ if (!dbUrl) exitWithError("Database URL required.", "Provider --url or set DATABASE_URL env.");
20
+
21
+ const configPath = path.resolve(options.config);
22
+ UI.info(`Subject : ${pc.bold(options.id)}`);
23
+ UI.info(`Manifest : ${pc.bold(options.config)}`);
24
+
25
+ const mockEnv = {
26
+ ...process.env,
27
+ DPDP_MASTER_KEY: process.env.DPDP_MASTER_KEY || "0".repeat(64),
28
+ DPDP_HMAC_KEY: process.env.DPDP_HMAC_KEY || "0".repeat(64),
29
+ };
30
+
31
+ let config;
32
+ try {
33
+ config = await readWorkerConfig(mockEnv, configPath);
34
+ } catch (err) {
35
+ exitWithError("Config load failed", err instanceof Error ? err.message : String(err));
36
+ }
37
+
38
+ const spinner = UI.spinner(`Executing dry-run pipeline for ${options.id}`);
39
+ const sql = postgres(dbUrl);
40
+
41
+ try {
42
+ const result = await vaultUser(sql, options.id, {
43
+ kek: config.masterKey,
44
+ hmacKey: config.hmacKey,
45
+ }, {
46
+ appSchema: config.database.app_schema,
47
+ engineSchema: config.database.engine_schema,
48
+ defaultRetentionYears: config.compliance_policy.default_retention_years,
49
+ noticeWindowHours: config.compliance_policy.notice_window_hours,
50
+ graphMaxDepth: config.graph.max_depth,
51
+ rootTable: config.graph.root_table,
52
+ rootIdColumn: config.graph.root_id_column,
53
+ rootPiiColumns: config.graph.root_pii_columns,
54
+ satelliteTargets: config.satellite_targets,
55
+ blobTargets: config.blob_targets,
56
+ retentionRules: config.compliance_policy.retention_rules,
57
+ dryRun: true,
58
+ now: new Date(),
59
+ });
60
+ spinner.stop();
61
+
62
+ if (!result.plan) exitWithError("Internal Error", "Vault engine failed to return a plan.");
63
+
64
+ UI.step(1, "Executive Summary");
65
+ UI.subStep(result.plan.summary);
66
+
67
+ UI.step(2, "Integrity Checks");
68
+ result.plan.checks.forEach(c => UI.subStep(c));
69
+
70
+ UI.step(3, "Cryptographic Pipeline");
71
+ result.plan.cryptoSteps.forEach(s => UI.subStep(pc.yellow(s)));
72
+
73
+ UI.step(4, "Proposed SQL Transactions");
74
+ result.plan.sqlSteps.forEach(s => UI.subStep(pc.magenta(s)));
75
+
76
+ UI.divider();
77
+ UI.info("FINAL PREVIEW:");
78
+ UI.keyValue("Planned Action", result.action);
79
+ UI.keyValue("Worker Hash", result.userHash || "N/A");
80
+ UI.keyValue("Dependencies", result.dependencyCount.toString());
81
+ UI.keyValue("Retention", `${result.retentionYears ?? 0} years (${result.appliedRuleName})`);
82
+
83
+ UI.success("Dry-run complete. No production data was modified.");
84
+ } catch (err) {
85
+ spinner.fail("Simulation failed");
86
+ exitWithError("Dry-run error", err instanceof Error ? err.message : String(err));
87
+ } finally {
88
+ await sql.end();
89
+ }
90
+ }
@@ -0,0 +1,87 @@
1
+ import { type DependencyNode, getDependencyGraph } from "@modules/db";
2
+ import { readWorkerConfig } from "@modules/config";
3
+ import pc from "picocolors";
4
+ import { UI, exitWithError } from "./ui";
5
+ import postgres from "postgres";
6
+
7
+ function renderTree(rootName: string, nodes: DependencyNode[]) {
8
+ const nodesByParent: Record<string, DependencyNode[]> = {};
9
+ nodes.forEach(n => {
10
+ if (!nodesByParent[n.parent_table]) nodesByParent[n.parent_table] = [];
11
+ nodesByParent[n.parent_table]?.push(n);
12
+ });
13
+
14
+ function print(tableName: string, prefix: string, isLast: boolean) {
15
+ const children = nodesByParent[tableName] || [];
16
+ children.forEach((child, i) => {
17
+ const isChildLast = i === children.length - 1;
18
+ const marker = isChildLast ? "└── " : "├── ";
19
+ const unsafe = ["CASCADE", "SET_NULL", "SET_DEFAULT"].includes(child.delete_action);
20
+ const color = unsafe ? pc.red : pc.cyan;
21
+
22
+ console.log(`${prefix}${marker}${color(child.table_name)} ${pc.gray(`(${child.column_name})`)}`);
23
+ print(child.table_name, prefix + (isChildLast ? " " : "│ "), isChildLast);
24
+ });
25
+ }
26
+
27
+ print(rootName, "", true);
28
+ }
29
+
30
+ /**
31
+ * Visualizes the recursive foreign-key graph.
32
+ */
33
+ export async function graphAction(options: {
34
+ table: string,
35
+ url?: string,
36
+ schema?: string,
37
+ maxDepth: string,
38
+ }): Promise<void> {
39
+ UI.header("Dependecy Graph Audit");
40
+
41
+ const dbUrl = options.url || process.env.DATABASE_URL
42
+ if (!dbUrl) exitWithError("Database URL required.", "Pass --url or set DATABASE_URL env.");
43
+
44
+ let appSchema = options.schema;
45
+ if (!appSchema) {
46
+ try {
47
+ const config = await readWorkerConfig(process.env);
48
+ appSchema = config.database.app_schema;
49
+ } catch (err) {
50
+ exitWithError("Schema missing.", "Provide --schema or ensure compliance.worker.yml exists.");
51
+ }
52
+ }
53
+
54
+ const maxDepth = parseInt(options.maxDepth, 10);
55
+ const spinner = UI.spinner(`Crawling relationships for ${appSchema}.${options.table}...`);
56
+ const sql = postgres(dbUrl);
57
+
58
+ try {
59
+ const nodes = await getDependencyGraph(sql, appSchema, options.table, {
60
+ maxDepth,
61
+ failOnUnsafeDeleteAction: false,
62
+ });
63
+ spinner.stop();
64
+
65
+ if (nodes.length === 0) {
66
+ UI.success("Leaf table detected: 0 downstream dependencies found.");
67
+ return;
68
+ }
69
+
70
+ console.log(`\n${pc.bold(pc.white(options.table))}`);
71
+ renderTree(options.table, nodes);
72
+
73
+ const unsafe = nodes.filter((n) => ["CASCADE", "SET_NULL", "SET_DEFAULT"].includes(n.delete_action));
74
+ if (unsafe.length > 0) {
75
+ UI.error(`${unsafe.length} UNSAFE CONSTRAINTS FOUND`);
76
+ unsafe.forEach(n => console.log(` ${pc.red("✖")} ${n.table_name}.${n.column_name} — ON DELETE ${n.delete_action}`));
77
+ UI.warn("Live execution will fail until these are converted to NO ACTION or RESTRICT.");
78
+ } else {
79
+ UI.success("Graph validation passed: All constraints are safe for anonymization.");
80
+ }
81
+ } catch (err) {
82
+ spinner.fail("Graph crawl failed");
83
+ exitWithError("Analysis error", err instanceof Error ? err.message : String(err));
84
+ } finally {
85
+ sql.end();
86
+ }
87
+ }