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,50 @@
1
+ import pc from "picocolors";
2
+ import path from "node:path";
3
+ import { UI, exitWithError } from "./ui";
4
+ import { base64ToBytes, bytesToBase64 } from "@/lib";
5
+
6
+ /**
7
+ * Signs the compliance manifest with a private key.
8
+ */
9
+ export async function signAction(options: { config: string; key?: string }) {
10
+ UI.header("Manifest Signing");
11
+
12
+ const configPath = path.resolve(options.config);
13
+ const keyPath = options.key ? path.resolve(options.key) : "worker.pkcs8.key";
14
+
15
+ UI.info(`Manifest : ${pc.bold(options.config)}`);
16
+ UI.info(`Key : ${pc.bold(keyPath)}`);
17
+
18
+ const spinner = UI.spinner("Signing manifest data...");
19
+
20
+ try {
21
+ const keyData = (await Bun.file(keyPath).text()).trim();
22
+ const manifestData = new Uint8Array(await Bun.file(configPath).arrayBuffer());
23
+
24
+ const privateKey = await globalThis.crypto.subtle.importKey(
25
+ "pkcs8",
26
+ base64ToBytes(keyData).buffer as ArrayBuffer,
27
+ { name: "Ed25519" },
28
+ false,
29
+ ["sign"]
30
+ );
31
+
32
+ const signature = await globalThis.crypto.subtle.sign(
33
+ "Ed25519",
34
+ privateKey,
35
+ manifestData.buffer as ArrayBuffer
36
+ );
37
+
38
+ const signatureBase64 = bytesToBase64(new Uint8Array(signature));
39
+ const sigPath = `${configPath}.sig`;
40
+
41
+ await Bun.write(sigPath, signatureBase64);
42
+ spinner.stop();
43
+
44
+ UI.success(`Detached signature generated: ${pc.bold(sigPath)}`);
45
+ UI.info("Ensure this file is present alongside your manifest in the worker environment.");
46
+ } catch (err) {
47
+ spinner.fail("Signing failed");
48
+ exitWithError("Process failed", err instanceof Error ? err.message : String(err));
49
+ }
50
+ }
@@ -0,0 +1,61 @@
1
+ import pc from "picocolors";
2
+ import ora, { type Ora } from "ora";
3
+ import Table from "cli-table3";
4
+ import boxen from "boxen";
5
+
6
+ /**
7
+ * Standardized UI components for the Compliance Worker CLI.
8
+ * Designed for professional output in diverse terminal environments.
9
+ */
10
+ export const UI = {
11
+ header: (title: string) => {
12
+ console.log(
13
+ boxen(pc.bold(pc.blue(`COMPLIANCE WORKER — ${title.toUpperCase()}`)), {
14
+ padding: { top: 0, bottom: 0, left: 2, right: 2 },
15
+ margin: { top: 1, bottom: 1 },
16
+ borderStyle: "round",
17
+ borderColor: "blue",
18
+ })
19
+ );
20
+ },
21
+
22
+ divider: () => console.log(pc.gray("─".repeat(process.stdout.columns || 60))),
23
+
24
+ spinner: (text: string): Ora => ora({ text, color: "blue" }).start(),
25
+
26
+ success: (msg: string) => console.log(`\n${pc.green("✔")} ${pc.bold(msg)}`),
27
+ error: (msg: string) => console.error(`\n${pc.red("✖")} ${pc.bold(msg)}`),
28
+ warn: (msg: string) => console.log(`\n${pc.yellow("⚠")} ${pc.bold(msg)}`),
29
+ info: (msg: string) => console.log(`\n${pc.cyan("ℹ")} ${msg}`),
30
+
31
+ step: (n: number, msg: string) => {
32
+ console.log(`\n${pc.bold(pc.white(`${n}. ${msg}`))}`);
33
+ },
34
+
35
+ subStep: (msg: string, indent = 3) => {
36
+ console.log(`${" ".repeat(indent)}${pc.gray("└─")} ${pc.white(msg)}`);
37
+ },
38
+
39
+ keyValue: (key: string, val: string) => {
40
+ console.log(` ${pc.gray(key.padEnd(20))}: ${pc.bold(val)}`);
41
+ },
42
+
43
+ table: (head: string[]) => {
44
+ return new Table({
45
+ head: head.map((h) => pc.bold(pc.white(h))),
46
+ style: { head: [], border: ["gray"] },
47
+ });
48
+ },
49
+
50
+ hint: (msg: string) => {
51
+ console.log(pc.italic(pc.gray(`\nPRO-TIP: ${msg}`)));
52
+ },
53
+ };
54
+
55
+ export function exitWithError(msg: string, detail?: string): never {
56
+ UI.error(msg);
57
+ if (detail) {
58
+ console.error(pc.gray(`\n${pc.bold("Detail:")}\n${detail}\n`));
59
+ }
60
+ process.exit(1);
61
+ }
@@ -0,0 +1,31 @@
1
+ import postgres from "postgres";
2
+ import { verifySchemaIntegrity } from "@modules/introspector";
3
+ import { UI, exitWithError } from "./ui";
4
+
5
+ /**
6
+ * Executes the CI/CD Privacy-as-Code schema gate.
7
+ *
8
+ * @param options - Manifest path and database URL.
9
+ * @returns Resolves only when the live schema hash matches legal attestation.
10
+ */
11
+ export async function verifySchemaAction(options: { config: string; url?: string }): Promise<void> {
12
+ UI.header("Privacy-as-Code Schema Verification");
13
+
14
+ const dbUrl = options.url ?? process.env.DATABASE_URL;
15
+ if (!dbUrl) {
16
+ exitWithError("Database URL required.", "Pass --url or set DATABASE_URL.");
17
+ }
18
+
19
+ const sql = postgres(dbUrl, { max: 1 });
20
+ const spinner = UI.spinner(`Comparing live schema to ${options.config}...`);
21
+ try {
22
+ const liveHash = await verifySchemaIntegrity({ sql, configPath: options.config });
23
+ spinner.succeed("Schema hash matches legal attestation");
24
+ UI.keyValue("Live Schema Hash", liveHash);
25
+ } catch (error) {
26
+ spinner.fail("Schema verification failed");
27
+ exitWithError("Privacy-as-Code gate failed", error instanceof Error ? error.message : String(error));
28
+ } finally {
29
+ await sql.end();
30
+ }
31
+ }
@@ -0,0 +1,85 @@
1
+ import postgres from "postgres";
2
+ import pc from "picocolors";
3
+ import { UI, exitWithError } from "./ui";
4
+ import path from "node:path";
5
+ import { assertConfigSchemaCompatibility, readWorkerConfig } from "../config";
6
+ import { detectSchemaDrift } from "../db";
7
+ import { verifySignedWorkerConfig } from "@/secrets/signature";
8
+
9
+ /**
10
+ * Validates the local manifest against the database and signing keys.
11
+ */
12
+ export async function verifyAction(options: { config: string; url?: string }) {
13
+ UI.header("Integrity Verification");
14
+
15
+ const dbUrl = options.url || process.env.DATABASE_URL;
16
+ if (!dbUrl) exitWithError("Database URL required.", "Provide --url or set DATABASE_URL env.");
17
+
18
+ const configPath = path.resolve(options.config);
19
+ UI.info(`Verifying manifest: ${pc.bold(options.config)}`);
20
+
21
+ // Use dummy keys if not provided for verification path
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
+ let config;
29
+ try {
30
+ config = await readWorkerConfig(mockEnv, configPath);
31
+ } catch (err) {
32
+ exitWithError("Validation failed", err instanceof Error ? err.message : String(err));
33
+ }
34
+
35
+ const sql = postgres(dbUrl);
36
+
37
+ try {
38
+ UI.step(1, "Database Schema Compatibility");
39
+ const compatSpinner = UI.spinner("Checking existence of configured objects...");
40
+ try {
41
+ await assertConfigSchemaCompatibility(sql, config);
42
+ compatSpinner.succeed("All configured tables and columns verified.");
43
+ } catch (err) {
44
+ compatSpinner.fail("Schema mismatch detected.");
45
+ console.log(pc.red(` ${err instanceof Error ? err.message : String(err)}`));
46
+ }
47
+
48
+ UI.step(2, "Schema Drift Detection");
49
+ const hashSpinner = UI.spinner("Computing SHA-256 fingerprint...");
50
+ const liveHash = await detectSchemaDrift(sql, config.database.app_schema);
51
+ const expectedHash = config.integrity.expected_schema_hash;
52
+ hashSpinner.stop();
53
+
54
+ UI.keyValue("Manifest Hash", expectedHash);
55
+ UI.keyValue("Live Schema Hash", liveHash);
56
+
57
+ if (liveHash === expectedHash) {
58
+ UI.success("Fingerprints match. Configuration is current.");
59
+ } else {
60
+ UI.error("DRIFT DETECTED: Database structure has changed.");
61
+ UI.hint(`If this was intended, update 'integrity.expected_schema_hash' to: ${pc.bold(liveHash)}`);
62
+ }
63
+
64
+ UI.step(3, "Cryptographic Signatures");
65
+ if (process.env.DPDP_CONFIG_SIGNING_PUBLIC_KEY_SPKI_BASE64) {
66
+ const sigSpinner = UI.spinner("Verifying detached Ed25519 signature...");
67
+ try {
68
+ await verifySignedWorkerConfig(process.env, configPath);
69
+ sigSpinner.succeed("Signature valid. Manifest is untampered.");
70
+ } catch (err) {
71
+ sigSpinner.fail("Signature verification failed.");
72
+ console.log(pc.red(` ${err instanceof Error ? err.message : String(err)}`));
73
+ }
74
+ } else {
75
+ UI.info("Skipped: DPDP_CONFIG_SIGNING_PUBLIC_KEY_SPKI_BASE64 not set.");
76
+ }
77
+
78
+ UI.divider();
79
+ UI.success("Verification pipeline concluded.");
80
+ } catch (err) {
81
+ exitWithError("System failure", err instanceof Error ? err.message : String(err));
82
+ } finally {
83
+ await sql.end();
84
+ }
85
+ }
@@ -0,0 +1,271 @@
1
+ import type { WorkerConfig } from "./validation";
2
+ import { fail } from "@/errors";
3
+ import type { Sql } from "@/types";
4
+
5
+ interface SchemaColumnRow {
6
+ table_name: string;
7
+ column_name: string;
8
+ }
9
+
10
+ interface SchemaTableRow {
11
+ table_name: string;
12
+ }
13
+
14
+ function formatColumn(tableName: string, columnName: string): string {
15
+ return `${tableName}.${columnName}`;
16
+ }
17
+
18
+ function tableNameFromReference(reference: string, appSchema: string): string {
19
+ const parts = reference.split(".");
20
+ if (parts.length === 1) {
21
+ return parts[0]!;
22
+ }
23
+
24
+ if (parts[0] !== appSchema) {
25
+ return "";
26
+ }
27
+
28
+ return parts[1]!;
29
+ }
30
+
31
+ /**
32
+ * Verifies that the live application schema satisfies every column/table reference in the
33
+ * worker configuration before any task execution begins.
34
+ *
35
+ * This catches configuration drift such as missing root columns, satellite lookup columns,
36
+ * masking-rule targets, and retention evidence tables at boot time instead of failing after
37
+ * the worker has already leased work.
38
+ *
39
+ * @param sql - Postgres pool used for metadata inspection.
40
+ * @param config - Parsed worker configuration.
41
+ * @throws {WorkerError} When the application schema does not satisfy the worker configuration.
42
+ */
43
+ export async function assertConfigSchemaCompatibility(
44
+ sql: Sql,
45
+ config: WorkerConfig
46
+ ): Promise<void> {
47
+ const rows = await sql<SchemaColumnRow[]>`
48
+ SELECT table_name, column_name
49
+ FROM information_schema.columns
50
+ WHERE table_schema = ${config.database.app_schema}
51
+ ORDER BY table_name ASC, ordinal_position ASC
52
+ `;
53
+ const tableRows = await sql<SchemaTableRow[]>`
54
+ SELECT table_name
55
+ FROM information_schema.tables
56
+ WHERE table_schema = ${config.database.app_schema}
57
+ AND table_type = 'BASE TABLE'
58
+ ORDER BY table_name ASC
59
+ `;
60
+
61
+ const columnsByTable = new Map<string, Set<string>>();
62
+ for (const row of rows) {
63
+ const existing = columnsByTable.get(row.table_name) ?? new Set<string>();
64
+ existing.add(row.column_name);
65
+ columnsByTable.set(row.table_name, existing);
66
+ }
67
+
68
+ const violations: string[] = [];
69
+ const rootTable = config.graph.root_table;
70
+ const rootColumns = columnsByTable.get(rootTable);
71
+
72
+ if (!rootColumns) {
73
+ violations.push(`missing root table ${config.database.app_schema}.${rootTable}`);
74
+ } else {
75
+ const requiredRootColumns = new Set<string>([
76
+ config.graph.root_id_column,
77
+ ...Object.keys(config.graph.root_pii_columns),
78
+ ...config.satellite_targets.map((target) => target.lookup_column),
79
+ ]);
80
+
81
+ if (config.graph.notice_email_column) {
82
+ requiredRootColumns.add(config.graph.notice_email_column);
83
+ }
84
+
85
+ if (config.graph.notice_name_column) {
86
+ requiredRootColumns.add(config.graph.notice_name_column);
87
+ }
88
+
89
+ if (config.purge_policy?.enabled && config.purge_policy.selector?.kind == "boolean_column") {
90
+ requiredRootColumns.add(config.purge_policy.selector.column);
91
+ }
92
+
93
+ for (const column of requiredRootColumns) {
94
+ if (!rootColumns.has(column)) {
95
+ violations.push(
96
+ `missing root column ${formatColumn(`${config.database.app_schema}.${rootTable}`, column)}`
97
+ );
98
+ }
99
+ }
100
+ }
101
+
102
+ for (const target of config.satellite_targets) {
103
+ const targetColumns = columnsByTable.get(target.table);
104
+ if (!targetColumns) {
105
+ violations.push(`missing satellite table ${config.database.app_schema}.${target.table}`);
106
+ continue;
107
+ }
108
+
109
+ if (!targetColumns.has(target.lookup_column)) {
110
+ violations.push(
111
+ `missing satellite lookup column ${formatColumn(
112
+ `${config.database.app_schema}.${target.table}`,
113
+ target.lookup_column
114
+ )}`
115
+ );
116
+ }
117
+
118
+ for (const column of Object.keys(target.masking_rules ?? {})) {
119
+ if (!targetColumns.has(column)) {
120
+ violations.push(
121
+ `missing satellite masking column ${formatColumn(
122
+ `${config.database.app_schema}.${target.table}`,
123
+ column
124
+ )}`
125
+ );
126
+ }
127
+ }
128
+ }
129
+
130
+ for (const rule of config.compliance_policy.retention_rules) {
131
+ for (const tableName of rule.if_has_data_in) {
132
+ if (!columnsByTable.has(tableName)) {
133
+ violations.push(`missing retention evidence table ${config.database.app_schema}.${tableName}`);
134
+ }
135
+ }
136
+ }
137
+
138
+ const compiledRules = config.rules ?? [];
139
+ if (compiledRules.length > 0) {
140
+ const dagTables = new Set<string>();
141
+ for (const rule of compiledRules) {
142
+ for (const target of rule.targets) {
143
+ const tableName = tableNameFromReference(target.table, config.database.app_schema);
144
+ if (!tableName) {
145
+ violations.push(`compiled DAG target ${target.table} is outside ${config.database.app_schema}`);
146
+ continue;
147
+ }
148
+
149
+ dagTables.add(tableName);
150
+ const targetColumns = columnsByTable.get(tableName);
151
+ if (!targetColumns) {
152
+ violations.push(`compiled DAG references missing table ${config.database.app_schema}.${tableName}`);
153
+ continue;
154
+ }
155
+
156
+ for (const column of target.pii_columns) {
157
+ if (!targetColumns.has(column)) {
158
+ violations.push(
159
+ `compiled DAG references missing PII column ${formatColumn(
160
+ `${config.database.app_schema}.${tableName}`,
161
+ column
162
+ )}`
163
+ );
164
+ }
165
+ }
166
+
167
+ for (const column of Object.keys(target.mutation_rules ?? {})) {
168
+ if (!targetColumns.has(column)) {
169
+ violations.push(
170
+ `compiled DAG references missing mutation column ${formatColumn(
171
+ `${config.database.app_schema}.${tableName}`,
172
+ column
173
+ )}`
174
+ );
175
+ }
176
+ }
177
+
178
+ for (const column of target.child_columns) {
179
+ if (!targetColumns.has(column)) {
180
+ violations.push(
181
+ `compiled DAG references missing child join column ${formatColumn(
182
+ `${config.database.app_schema}.${tableName}`,
183
+ column
184
+ )}`
185
+ );
186
+ }
187
+ }
188
+
189
+ for (const column of target.primary_key_columns) {
190
+ if (!targetColumns.has(column)) {
191
+ violations.push(
192
+ `compiled DAG references missing primary key column ${formatColumn(
193
+ `${config.database.app_schema}.${tableName}`,
194
+ column
195
+ )}`
196
+ );
197
+ }
198
+ }
199
+
200
+ if (tableName !== config.graph.root_table && target.pii_columns.length > 0 && !target.action) {
201
+ violations.push(
202
+ `compiled DAG target ${config.database.app_schema}.${tableName} contains PII columns but has no mutation action`
203
+ );
204
+ }
205
+
206
+ if (target.action === "redact" && (!target.mutation_rules || Object.keys(target.mutation_rules).length === 0)) {
207
+ violations.push(
208
+ `compiled DAG target ${config.database.app_schema}.${tableName} has redact action but no mutation_rules`
209
+ );
210
+ }
211
+
212
+ if (tableName !== config.graph.root_table) {
213
+ if (!target.parent) {
214
+ violations.push(`compiled DAG target ${config.database.app_schema}.${tableName} is missing parent`);
215
+ } else {
216
+ const parentTableName = tableNameFromReference(target.parent, config.database.app_schema);
217
+ const parentColumns = parentTableName ? columnsByTable.get(parentTableName) : undefined;
218
+ if (!parentTableName) {
219
+ violations.push(`compiled DAG parent ${target.parent} is outside ${config.database.app_schema}`);
220
+ } else if (!parentColumns) {
221
+ violations.push(`compiled DAG references missing parent table ${config.database.app_schema}.${parentTableName}`);
222
+ } else {
223
+ for (const column of target.parent_columns) {
224
+ if (!parentColumns.has(column)) {
225
+ violations.push(
226
+ `compiled DAG references missing parent join column ${formatColumn(
227
+ `${config.database.app_schema}.${parentTableName}`,
228
+ column
229
+ )}`
230
+ );
231
+ }
232
+ }
233
+ }
234
+
235
+ if (
236
+ target.parent_columns.length !== target.child_columns.length ||
237
+ (target.parent_columns.length === 0 && !target.join && !target.fk_condition)
238
+ ) {
239
+ violations.push(
240
+ `compiled DAG target ${config.database.app_schema}.${tableName} has incomplete join columns`
241
+ );
242
+ }
243
+ }
244
+ }
245
+ }
246
+ }
247
+
248
+ for (const table of tableRows) {
249
+ if (!dagTables.has(table.table_name)) {
250
+ violations.push(
251
+ `live table ${config.database.app_schema}.${table.table_name} is missing from compiled DAG`
252
+ );
253
+ }
254
+ }
255
+ }
256
+
257
+ if (violations.length > 0) {
258
+ fail({
259
+ code: "CONFIG_SCHEMA_MISMATCH",
260
+ title: "Worker config does not match the application schema",
261
+ detail: `Detected ${violations.length} schema compatibility violation(s): ${violations.join("; ")}`,
262
+ category: "configuration",
263
+ retryable: false,
264
+ fatal: true,
265
+ context: {
266
+ appSchema: config.database.app_schema,
267
+ violations,
268
+ },
269
+ });
270
+ }
271
+ }
@@ -0,0 +1,4 @@
1
+ export * from "./signature";
2
+ export * from "./validation";
3
+ export * from "./reader";
4
+ export * from "./compatibility";
@@ -0,0 +1,149 @@
1
+ import { asWorkerError } from "@/errors";
2
+ import {
3
+ type WorkerConfig,
4
+ type WorkerYamlConfig,
5
+ workerYamlSchema,
6
+ normalizeWorkerYaml,
7
+ } from "./validation";
8
+ import yaml from "js-yaml";
9
+ import type { EnvType } from "@/types";
10
+ import { resolveConfiguredKey, resolveConfiguredKeyAsync } from "@/secrets";
11
+
12
+ /**
13
+ * Reads a configuration file by detecting the current JavaScript runtime.
14
+ *
15
+ * It prioritizes `Bun.file` for performance if available, otherwise it
16
+ * dynamically imports `node:fs/promises` to read the file in Node.js.
17
+ *
18
+ * @param configPath - The path or URL to the configuration file.
19
+ * @returns A promise that resolves to the file's content as a UTF-8 string.
20
+ * @throws Error if the file cannot be found or read.
21
+ */
22
+ async function readConfigText(configPath: string | URL): Promise<string> {
23
+ /**
24
+ * Augments @module `globalThis` with Bun to satisfy type definition.
25
+ */
26
+ const bunRuntime = (globalThis as typeof globalThis & {
27
+ Bun?: { file: (path: string | URL) => { text: () => Promise<string> } };
28
+ }).Bun
29
+
30
+ if (bunRuntime) {
31
+ return bunRuntime.file(configPath).text();
32
+ }
33
+
34
+ const { readFile } = await import("node:fs/promises");
35
+ return readFile(configPath, "utf-8");
36
+ }
37
+
38
+ /**
39
+ *
40
+ * @param configPath - Path to config.
41
+ * @returns {WorkerYamlConfig}
42
+ */
43
+ async function readAndValidateWorkerYaml(configPath: string | URL): Promise<WorkerYamlConfig> {
44
+ const yamlText = await readConfigText(configPath);
45
+ let parsedYaml: unknown;
46
+ try {
47
+ parsedYaml = yaml.load(yamlText);
48
+ } catch (error) {
49
+ throw asWorkerError(error, {
50
+ code: "CONFIG_YAML_INVALID",
51
+ title: "Invalid worker YAML",
52
+ detail: `Failed to parse ${String(configPath)} as YAML.`,
53
+ category: "configuration",
54
+ retryable: false,
55
+ fatal: true,
56
+ context: { configPath: String(configPath) },
57
+ })
58
+ }
59
+
60
+ let parsedConfig: WorkerYamlConfig;
61
+ try {
62
+ parsedConfig = normalizeWorkerYaml(workerYamlSchema.parse(parsedYaml))
63
+ } catch (error) {
64
+ throw asWorkerError(error, {
65
+ code: "CONFIG_SCHEMA_INVALID",
66
+ title: "Invalid worker configuration",
67
+ category: "configuration",
68
+ retryable: false,
69
+ fatal: true,
70
+ context: { configPath: String(configPath) },
71
+ });
72
+ }
73
+
74
+ return parsedConfig;
75
+ }
76
+
77
+ /**
78
+ * Reads `compliance.worker.yml`, validates it strictly, and resolves local cryptographic secrets.
79
+ *
80
+ * This synchronous path is intended for tests and local env/file sources. Production boot should
81
+ * use `readWorkerConfigFromRuntime` so remote KMS/Vault providers can be resolved without blocking
82
+ * or silently falling back to process env.
83
+ *
84
+ * @param env - Environment map used to resolve key material.
85
+ * @param configPath - Worker YAML path.
86
+ * @returns Fully validated worker configuration with decoded binary keys.
87
+ * @throws {WorkerError} When YAML parsing, schema validation, or local secret decoding fails.
88
+ */
89
+ export async function readWorkerConfig(
90
+ env: Record<string, string | undefined> = process.env,
91
+ configPath: string | URL = new URL("../../compliance.worker.yml", import.meta.url)
92
+ ): Promise<WorkerConfig> {
93
+ const parsedConfig = await readAndValidateWorkerYaml(configPath);
94
+ const masterKey = await resolveConfiguredKeyAsync({
95
+ env,
96
+ keyName: parsedConfig.security.master_key_env,
97
+ legacyEnvName: parsedConfig.security.master_key_env,
98
+ source: parsedConfig.security.master_key_source,
99
+ });
100
+ const hmacKey = await resolveConfiguredKeyAsync({
101
+ env,
102
+ keyName: parsedConfig.security.hmac_key_env,
103
+ legacyEnvName: parsedConfig.security.hmac_key_env,
104
+ fallbackLegacyEnvName: parsedConfig.security.master_key_env,
105
+ source: parsedConfig.security.hmac_key_source,
106
+ });
107
+
108
+ return {
109
+ ...parsedConfig,
110
+ masterKey,
111
+ hmacKey,
112
+ };
113
+ }
114
+
115
+ /**
116
+ * Reads `compliance.worker.yml` and resolves env, file, AWS KMS, GCP Secret Manager, or Vault keys.
117
+ *
118
+ * @param env - Environment map used for provider credentials and legacy env key fallback.
119
+ * @param configPath - Worker YAML path.
120
+ * @returns Fully validated worker configuration with runtime-resolved binary keys.
121
+ * @throws {WorkerError} When YAML, schema validation, provider access, or key decoding fails.
122
+ */
123
+ export const readWorkerConfigFromRuntime = async (
124
+ env: EnvType = process.env,
125
+ configPath: string | URL = new URL("../../compliance.worker.yml", import.meta.url)
126
+ ): Promise<WorkerConfig> => {
127
+ const parsedConfig = await readAndValidateWorkerYaml(configPath);
128
+ const [masterKey, hmacKey] = await Promise.all([
129
+ resolveConfiguredKey({
130
+ env,
131
+ keyName: parsedConfig.security.hmac_key_env,
132
+ legacyEnvName: parsedConfig.security.master_key_env,
133
+ source: parsedConfig.security.master_key_source
134
+ }),
135
+ resolveConfiguredKey({
136
+ env,
137
+ keyName: parsedConfig.security.hmac_key_env,
138
+ legacyEnvName: parsedConfig.security.hmac_key_env,
139
+ fallbackLegacyEnvName: parsedConfig.security.master_key_env,
140
+ source: parsedConfig.security.hmac_key_source,
141
+ }),
142
+ ]);
143
+
144
+ return {
145
+ ...parsedConfig,
146
+ masterKey,
147
+ hmacKey,
148
+ }
149
+ };