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.
- package/.env.example +55 -0
- package/Dockerfile +33 -0
- package/compliance.worker.yaml +64 -0
- package/package.json +41 -0
- package/src/constants/index.ts +1 -0
- package/src/errors/fail.ts +110 -0
- package/src/errors/index.ts +4 -0
- package/src/errors/inferer.ts +166 -0
- package/src/errors/registry.ts +122 -0
- package/src/errors/types.ts +65 -0
- package/src/errors/worker.ts +161 -0
- package/src/index.ts +328 -0
- package/src/lib/crypto/digest.ts +22 -0
- package/src/lib/crypto/encoding.ts +78 -0
- package/src/lib/crypto/index.ts +2 -0
- package/src/lib/index.ts +1 -0
- package/src/modules/bootstrap/index.ts +2 -0
- package/src/modules/bootstrap/integrity.ts +38 -0
- package/src/modules/bootstrap/preflight.ts +296 -0
- package/src/modules/cli/check-integrity.ts +48 -0
- package/src/modules/cli/dry-run.ts +90 -0
- package/src/modules/cli/graph.ts +87 -0
- package/src/modules/cli/index.ts +184 -0
- package/src/modules/cli/init.ts +115 -0
- package/src/modules/cli/inspect.ts +86 -0
- package/src/modules/cli/introspector.ts +117 -0
- package/src/modules/cli/keygen.ts +38 -0
- package/src/modules/cli/scan.ts +126 -0
- package/src/modules/cli/sign.ts +50 -0
- package/src/modules/cli/ui.ts +61 -0
- package/src/modules/cli/verify-schema.ts +31 -0
- package/src/modules/cli/verify.ts +85 -0
- package/src/modules/config/compatibility.ts +271 -0
- package/src/modules/config/index.ts +4 -0
- package/src/modules/config/reader.ts +149 -0
- package/src/modules/config/signature.ts +69 -0
- package/src/modules/config/validation.ts +658 -0
- package/src/modules/crypto/aes.ts +158 -0
- package/src/modules/crypto/envelope.ts +48 -0
- package/src/modules/crypto/hmac.ts +60 -0
- package/src/modules/crypto/index.ts +3 -0
- package/src/modules/db/drift.ts +36 -0
- package/src/modules/db/graph.ts +203 -0
- package/src/modules/db/index.ts +4 -0
- package/src/modules/db/migrations.ts +254 -0
- package/src/modules/db/sql-debug.ts +61 -0
- package/src/modules/engine/blob/index.ts +3 -0
- package/src/modules/engine/blob/s3.ts +455 -0
- package/src/modules/engine/blob/store.ts +236 -0
- package/src/modules/engine/blob/types.ts +44 -0
- package/src/modules/engine/helpers/identity.ts +47 -0
- package/src/modules/engine/helpers/index.ts +4 -0
- package/src/modules/engine/helpers/outbox.ts +118 -0
- package/src/modules/engine/helpers/runtime.ts +115 -0
- package/src/modules/engine/helpers/types.ts +61 -0
- package/src/modules/engine/index.ts +6 -0
- package/src/modules/engine/notifier/config.ts +147 -0
- package/src/modules/engine/notifier/dispatcher.ts +300 -0
- package/src/modules/engine/notifier/index.ts +3 -0
- package/src/modules/engine/notifier/payload.ts +51 -0
- package/src/modules/engine/notifier/reservation.ts +153 -0
- package/src/modules/engine/notifier/types.ts +38 -0
- package/src/modules/engine/shredder.ts +254 -0
- package/src/modules/engine/types.ts +146 -0
- package/src/modules/engine/vault/compiled-targets.ts +562 -0
- package/src/modules/engine/vault/context.ts +254 -0
- package/src/modules/engine/vault/dry-run.ts +94 -0
- package/src/modules/engine/vault/execution.ts +485 -0
- package/src/modules/engine/vault/index.ts +3 -0
- package/src/modules/engine/vault/purge.ts +82 -0
- package/src/modules/engine/vault/retention.ts +124 -0
- package/src/modules/engine/vault/satellite-mutation.ts +193 -0
- package/src/modules/engine/vault/satellite.ts +103 -0
- package/src/modules/engine/vault/shadow.ts +36 -0
- package/src/modules/engine/vault/static-plan.ts +116 -0
- package/src/modules/engine/vault/store.ts +34 -0
- package/src/modules/engine/vault/vault.ts +84 -0
- package/src/modules/introspector/classifier.ts +502 -0
- package/src/modules/introspector/dag.ts +276 -0
- package/src/modules/introspector/index.ts +7 -0
- package/src/modules/introspector/naming.ts +75 -0
- package/src/modules/introspector/report.ts +153 -0
- package/src/modules/introspector/run.ts +123 -0
- package/src/modules/introspector/s3-sampler.ts +227 -0
- package/src/modules/introspector/types.ts +131 -0
- package/src/modules/introspector/yaml.ts +101 -0
- package/src/modules/network/api/control-plane.ts +275 -0
- package/src/modules/network/api/index.ts +1 -0
- package/src/modules/network/api/validation.ts +71 -0
- package/src/modules/network/index.ts +4 -0
- package/src/modules/network/object-store/aws/client.ts +444 -0
- package/src/modules/network/object-store/aws/credentials.ts +271 -0
- package/src/modules/network/object-store/aws/index.ts +2 -0
- package/src/modules/network/object-store/aws/sigv4.ts +190 -0
- package/src/modules/network/object-store/aws/type.ts +6 -0
- package/src/modules/network/object-store/index.ts +1 -0
- package/src/modules/network/outbox/dispatcher.ts +183 -0
- package/src/modules/network/outbox/index.ts +3 -0
- package/src/modules/network/outbox/process.ts +133 -0
- package/src/modules/network/outbox/shared.ts +56 -0
- package/src/modules/network/outbox/store.ts +346 -0
- package/src/modules/network/outbox/types.ts +54 -0
- package/src/modules/network/request-signing.ts +61 -0
- package/src/modules/worker/index.ts +2 -0
- package/src/modules/worker/tasks.ts +58 -0
- package/src/modules/worker/types.ts +89 -0
- package/src/modules/worker/worker.ts +243 -0
- package/src/secrets/index.ts +4 -0
- package/src/secrets/kms/index.ts +2 -0
- package/src/secrets/kms/signature.ts +82 -0
- package/src/secrets/kms/validation.ts +64 -0
- package/src/secrets/reader.ts +42 -0
- package/src/secrets/repository/crypto.ts +89 -0
- package/src/secrets/repository/index.ts +2 -0
- package/src/secrets/repository/methods.ts +37 -0
- package/src/secrets/resolvers.ts +247 -0
- package/src/secrets/signature.ts +78 -0
- package/src/types/index.ts +1 -0
- package/src/types/types.ts +23 -0
- package/src/utils/identifiers.ts +48 -0
- package/src/utils/index.ts +3 -0
- package/src/utils/json.ts +35 -0
- package/src/utils/logger.ts +161 -0
- package/src/validation/zod.ts +70 -0
- package/tests/adversarial.test.ts +464 -0
- package/tests/blob-s3.test.ts +216 -0
- package/tests/config.test.ts +395 -0
- package/tests/control-plane-client.test.ts +108 -0
- package/tests/crypto.test.ts +106 -0
- package/tests/errors.test.ts +69 -0
- package/tests/fetch-dispatcher.test.ts +213 -0
- package/tests/graph.test.ts +84 -0
- package/tests/helpers/index.ts +101 -0
- package/tests/index-preflight.test.ts +168 -0
- package/tests/introspector-classifier.test.ts +62 -0
- package/tests/introspector-report.test.ts +85 -0
- package/tests/introspector.test.ts +394 -0
- package/tests/kms.test.ts +124 -0
- package/tests/logger.test.ts +61 -0
- package/tests/notifier.test.ts +303 -0
- package/tests/outbox.test.ts +478 -0
- package/tests/purge-policy.test.ts +124 -0
- package/tests/retention.test.ts +103 -0
- package/tests/s3-client.test.ts +110 -0
- package/tests/satellite.test.ts +119 -0
- package/tests/schema-compatibility.test.ts +237 -0
- package/tests/schema-integrity.test.ts +64 -0
- package/tests/shredder.test.ts +163 -0
- package/tests/vault.compiled-targets.test.ts +243 -0
- package/tests/vault.replica.test.ts +59 -0
- package/tests/vault.test.ts +279 -0
- package/tests/worker.retry.test.ts +291 -0
- package/tests/worker.test.ts +200 -0
- package/tsconfig.json +19 -0
- package/vitest.config.ts +13 -0
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
WorkerErrorCategory,
|
|
3
|
+
WorkerErrorCode,
|
|
4
|
+
WorkerErrorContext,
|
|
5
|
+
WorkerErrorOptions,
|
|
6
|
+
WorkerProblemDetails,
|
|
7
|
+
WorkerErrorFallback
|
|
8
|
+
} from "./types";
|
|
9
|
+
import { formatZodIssues, type WorkerValidationIssue } from "@/validation/zod";
|
|
10
|
+
import {
|
|
11
|
+
inferCategory,
|
|
12
|
+
inferCode,
|
|
13
|
+
inferDetail,
|
|
14
|
+
inferFatal,
|
|
15
|
+
inferRetryability,
|
|
16
|
+
inferTitle
|
|
17
|
+
} from "./inferer";
|
|
18
|
+
import { ZodError } from "zod";
|
|
19
|
+
|
|
20
|
+
function buildCause(cause: unknown): Error | undefined {
|
|
21
|
+
if (cause == null) return undefined;
|
|
22
|
+
if (cause instanceof Error) return cause;
|
|
23
|
+
|
|
24
|
+
return new Error(typeof cause === "string" ? cause : JSON.stringify(cause));
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function normalizeErrorType(code: WorkerErrorCode): string {
|
|
28
|
+
return `urn:dpdp:worker:error:${code.toLowerCase().replace(/^dpdp_/, "")}`;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* WorkerError envelope mapped to RFC-7807-compatible problem details.
|
|
33
|
+
*/
|
|
34
|
+
export class WorkerError extends Error {
|
|
35
|
+
readonly type: string;
|
|
36
|
+
readonly code: WorkerErrorCode;
|
|
37
|
+
readonly title: string;
|
|
38
|
+
readonly detail: string;
|
|
39
|
+
readonly category: WorkerErrorCategory;
|
|
40
|
+
readonly retryable: boolean;
|
|
41
|
+
readonly fatal: boolean;
|
|
42
|
+
readonly context?: WorkerErrorContext | null;
|
|
43
|
+
readonly issues?: WorkerValidationIssue[] | null;
|
|
44
|
+
|
|
45
|
+
constructor(options: WorkerErrorOptions) {
|
|
46
|
+
const cause = buildCause(options.cause)
|
|
47
|
+
super(options.detail, { cause })
|
|
48
|
+
|
|
49
|
+
this.name = "WorkerError";
|
|
50
|
+
this.type = options.type ?? normalizeErrorType(options.code);
|
|
51
|
+
this.code = options.code;
|
|
52
|
+
this.title = options.title;
|
|
53
|
+
this.detail = options.detail;
|
|
54
|
+
this.category = options.category;
|
|
55
|
+
this.retryable = options.retryable ?? false;
|
|
56
|
+
this.fatal = options.fatal ?? false;
|
|
57
|
+
this.context = options.context ?? null;
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Issue resolution:
|
|
61
|
+
* 1. Use explicitly provided issues.
|
|
62
|
+
* 2. If missing, check if the cause is a ZodError and format it.
|
|
63
|
+
* 3. Otherwise, null.
|
|
64
|
+
*/
|
|
65
|
+
if (options.issues) {
|
|
66
|
+
this.issues = options.issues;
|
|
67
|
+
} else if (cause instanceof ZodError) {
|
|
68
|
+
this.issues = formatZodIssues(cause);
|
|
69
|
+
} else {
|
|
70
|
+
this.issues = null;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
toProblem(instance?: string): WorkerProblemDetails {
|
|
75
|
+
const causeProblem = this.cause ? asWorkerError(this.cause).toProblem() : undefined;
|
|
76
|
+
|
|
77
|
+
const problem: WorkerProblemDetails = {
|
|
78
|
+
type: this.type,
|
|
79
|
+
code: this.code,
|
|
80
|
+
title: this.title,
|
|
81
|
+
detail: this.detail,
|
|
82
|
+
category: this.category,
|
|
83
|
+
retryable: this.retryable,
|
|
84
|
+
fatal: this.fatal,
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
if (instance) problem.instance = instance;
|
|
88
|
+
if (this.context) problem.context = this.context;
|
|
89
|
+
if (this.issues && this.issues.length > 0) problem.issues = this.issues;
|
|
90
|
+
if (causeProblem) problem.cause = causeProblem;
|
|
91
|
+
|
|
92
|
+
return problem;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
97
|
+
return typeof value === "object" && value !== null;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function isWorkerProblem(value: unknown): value is WorkerProblemDetails {
|
|
101
|
+
return isRecord(value) && typeof value.code === "string" && typeof value.detail === "string";
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Constructs a normalized `WorkerError`
|
|
106
|
+
*
|
|
107
|
+
* @param options - Error metadata and classification.
|
|
108
|
+
* @returns Worker error instance
|
|
109
|
+
*/
|
|
110
|
+
export function workerError(options: WorkerErrorOptions): WorkerError {
|
|
111
|
+
return new WorkerError(options)
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Normalizes unknown error into `WorkerError`, applying fallback defaults when needed
|
|
116
|
+
*
|
|
117
|
+
* @param error - Unknown thrown value.
|
|
118
|
+
* @param fallback - Optional fallback fields used when inference is ambiguous
|
|
119
|
+
* @returns Normalized Worker Error
|
|
120
|
+
*/
|
|
121
|
+
export function asWorkerError(error: unknown, fallback: WorkerErrorFallback = {}): WorkerError {
|
|
122
|
+
if (error instanceof WorkerError) return error;
|
|
123
|
+
|
|
124
|
+
if (isWorkerProblem(error)) {
|
|
125
|
+
return workerError({
|
|
126
|
+
code: error.code,
|
|
127
|
+
title: error.title,
|
|
128
|
+
detail: error.detail,
|
|
129
|
+
category: error.category,
|
|
130
|
+
retryable: error.retryable,
|
|
131
|
+
fatal: error.fatal,
|
|
132
|
+
context: error.context,
|
|
133
|
+
issues: error.issues,
|
|
134
|
+
cause: error.cause,
|
|
135
|
+
type: error.type,
|
|
136
|
+
});
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
return workerError({
|
|
140
|
+
code: inferCode(error, fallback),
|
|
141
|
+
title: inferTitle(error, fallback),
|
|
142
|
+
detail: inferDetail(error, fallback),
|
|
143
|
+
category: inferCategory(error, fallback),
|
|
144
|
+
retryable: inferRetryability(error, fallback),
|
|
145
|
+
fatal: inferFatal(error, fallback),
|
|
146
|
+
context: fallback.context,
|
|
147
|
+
issues: error instanceof ZodError ? (fallback.issues ?? formatZodIssues(error)) : fallback.issues,
|
|
148
|
+
cause: error instanceof Error ? error.cause : undefined
|
|
149
|
+
});
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Serializes unknown errors into worker problem-details payload.
|
|
154
|
+
*
|
|
155
|
+
* @param error - Unknown thrown value.
|
|
156
|
+
* @param instance - Optional instance path/context identifier.
|
|
157
|
+
* @returns Structured worker problem details.
|
|
158
|
+
*/
|
|
159
|
+
export function serializeWorkerError(error: unknown, instance?: string): WorkerProblemDetails {
|
|
160
|
+
return asWorkerError(error).toProblem(instance);
|
|
161
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
import postgres from "postgres";
|
|
2
|
+
import { normalize } from "zod";
|
|
3
|
+
import type { Sql } from "./types";
|
|
4
|
+
import {
|
|
5
|
+
readWorkerConfigFromRuntime,
|
|
6
|
+
verifySignatureWorkerConfig,
|
|
7
|
+
assertConfigSchemaCompatibility,
|
|
8
|
+
} from "./modules/config";
|
|
9
|
+
import {
|
|
10
|
+
createRedactingSqlDebugLogger,
|
|
11
|
+
runMigrations
|
|
12
|
+
} from "./modules/db";
|
|
13
|
+
import {
|
|
14
|
+
assertSchemaIntegrity,
|
|
15
|
+
assertIndexPreflight
|
|
16
|
+
} from "./modules/bootstrap";
|
|
17
|
+
import { createFetchDispatcher, createS3Client, createControlPlaneApiClient } from "./modules/network";
|
|
18
|
+
import { type MockMailer } from "./modules/engine";
|
|
19
|
+
import { ComplianceWorker } from "./modules/worker";
|
|
20
|
+
import { asWorkerError, workerError } from "./errors";
|
|
21
|
+
import { getLogger, logError, registerProcessGuard } from "./utils";
|
|
22
|
+
import { sha256HexDigest } from "./lib";
|
|
23
|
+
import { readRuntimeSecret } from "./secrets";
|
|
24
|
+
|
|
25
|
+
const logger = getLogger({ component: "bootstrap" });
|
|
26
|
+
let workerBooted = false;
|
|
27
|
+
let workerQuarantined = false;
|
|
28
|
+
|
|
29
|
+
async function checkDatabaseHealth(sql: Sql): Promise<boolean> {
|
|
30
|
+
try {
|
|
31
|
+
await sql`SELECT 1`;
|
|
32
|
+
return true;
|
|
33
|
+
} catch {
|
|
34
|
+
return false;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function sleep(milliseconds: number): Promise<void> {
|
|
39
|
+
return new Promise((resolve) => setTimeout(resolve, milliseconds));
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function readPositiveIntegerEnv(name: string, fallback: number): number {
|
|
43
|
+
const raw = process.env[name];
|
|
44
|
+
if (!raw) {
|
|
45
|
+
return fallback;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const parsed = Number(raw);
|
|
49
|
+
return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function computeIdlePollDelayMs(emptyPolls: number, baseMs: number, maxMs: number): number {
|
|
53
|
+
const cappedExponent = Math.min(emptyPolls, 10);
|
|
54
|
+
const exponential = Math.min(baseMs * 2 ** cappedExponent, maxMs);
|
|
55
|
+
const jitter = Math.floor(globalThis.crypto.getRandomValues(
|
|
56
|
+
new Uint32Array(1))[0]! % Math.max(1, Math.floor(baseMs / 2)
|
|
57
|
+
));
|
|
58
|
+
return Math.min(exponential + jitter, maxMs);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async function sendMailerWebhook(
|
|
62
|
+
url: string,
|
|
63
|
+
message: Parameters<MockMailer["sendEmail"]>[0],
|
|
64
|
+
timeoutMs: number
|
|
65
|
+
): ReturnType<MockMailer["sendEmail"]> {
|
|
66
|
+
const controller = new AbortController();
|
|
67
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
68
|
+
|
|
69
|
+
try {
|
|
70
|
+
const response = await fetch(url, {
|
|
71
|
+
method: "POST",
|
|
72
|
+
headers: {
|
|
73
|
+
"content-type": "application/json",
|
|
74
|
+
},
|
|
75
|
+
body: JSON.stringify(message),
|
|
76
|
+
signal: controller.signal,
|
|
77
|
+
redirect: "error",
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
if (!response.ok) {
|
|
81
|
+
throw workerError({
|
|
82
|
+
code: "MAILER_TRANSPORT_FAILED",
|
|
83
|
+
title: "Mailer transport failed",
|
|
84
|
+
detail: `MAILER_WEBHOOK_URL responded with HTTP ${response.status}.`,
|
|
85
|
+
category: "network",
|
|
86
|
+
retryable: response.status >= 500 || response.status === 429,
|
|
87
|
+
fatal: response.status >= 400 && response.status < 500 && response.status !== 429,
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const providerMessageId = response.headers.get("x-message-id") ?? response.headers.get("x-provider-message-id");
|
|
92
|
+
return {
|
|
93
|
+
provider: new URL(url).hostname,
|
|
94
|
+
providerMessageId: providerMessageId ?? undefined,
|
|
95
|
+
metadata: {
|
|
96
|
+
status: response.status,
|
|
97
|
+
},
|
|
98
|
+
};
|
|
99
|
+
} catch (error) {
|
|
100
|
+
if (error instanceof DOMException && error.name === "AbortError") {
|
|
101
|
+
throw workerError({
|
|
102
|
+
code: "MAILER_TRANSPORT_TIMEOUT",
|
|
103
|
+
title: "Mailer transport timed out",
|
|
104
|
+
detail: `MAILER_WEBHOOK_URL did not respond within ${timeoutMs}ms.`,
|
|
105
|
+
category: "network",
|
|
106
|
+
retryable: true,
|
|
107
|
+
fatal: false,
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
throw error;
|
|
112
|
+
} finally {
|
|
113
|
+
clearTimeout(timer);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
async function main() {
|
|
118
|
+
registerProcessGuard(logger);
|
|
119
|
+
logger.info("Starting Compliance Worker");
|
|
120
|
+
|
|
121
|
+
const configPath = new URL("../compliance.worker.yaml", import.meta.url)
|
|
122
|
+
await verifySignatureWorkerConfig(process.env, configPath);
|
|
123
|
+
const file = await Bun.file(configPath).text();
|
|
124
|
+
const workerConfigHash = await sha256HexDigest(file);
|
|
125
|
+
const config = await readWorkerConfigFromRuntime(process.env, configPath);
|
|
126
|
+
const postgresDebug = (process.env.LOG_LEVEL ?? "info").toLowerCase() === "debug"
|
|
127
|
+
? createRedactingSqlDebugLogger(logger, Object.keys(config.graph.root_pii_columns))
|
|
128
|
+
: undefined;
|
|
129
|
+
|
|
130
|
+
let sql: Sql | undefined;
|
|
131
|
+
let sqlReplica: Sql | undefined;
|
|
132
|
+
|
|
133
|
+
try {
|
|
134
|
+
sql = postgres(process.env.DB_URL ?? "postgres://postgres:postgres@localhost:5432/postgres", {
|
|
135
|
+
max: 10,
|
|
136
|
+
idle_timeout: 20,
|
|
137
|
+
connect_timeout: 10,
|
|
138
|
+
debug: postgresDebug
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
sqlReplica = config.database.replica_db_url
|
|
142
|
+
? postgres(config.database.replica_db_url, {
|
|
143
|
+
max: 10,
|
|
144
|
+
idle_timeout: 20,
|
|
145
|
+
connect_timeout: 10,
|
|
146
|
+
debug: postgresDebug
|
|
147
|
+
})
|
|
148
|
+
: undefined
|
|
149
|
+
|
|
150
|
+
await runMigrations(sql, config.database.engine_schema)
|
|
151
|
+
|
|
152
|
+
const skipSchemaCheck = process.env.SKIP_SCHEMA_CHECK === "true";
|
|
153
|
+
if (skipSchemaCheck) {
|
|
154
|
+
logger.warn("Skipping schema integrity and compatibility checks as requested by SKIP_SCHEMA_CHECK=true");
|
|
155
|
+
} else {
|
|
156
|
+
try {
|
|
157
|
+
await assertSchemaIntegrity(
|
|
158
|
+
sql,
|
|
159
|
+
config.database.app_schema,
|
|
160
|
+
config.legal_attestation.schema_hash ?? config.integrity.expected_schema_hash
|
|
161
|
+
);
|
|
162
|
+
await assertConfigSchemaCompatibility(sql, config);
|
|
163
|
+
await assertIndexPreflight(sql, config);
|
|
164
|
+
} catch (error) {
|
|
165
|
+
const normalized = asWorkerError(error);
|
|
166
|
+
if (
|
|
167
|
+
normalized.code !== "SCHEMA_DRIFT_DETECTED" &&
|
|
168
|
+
normalized.code !== "CONFIG_SCHEMA_MISMATCH" &&
|
|
169
|
+
normalized.code !== "INDEX_PREFLIGHT_FAILED"
|
|
170
|
+
) {
|
|
171
|
+
throw normalized;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
workerQuarantined = true;
|
|
175
|
+
logger.error(
|
|
176
|
+
{ code: normalized.code, detail: normalized.detail, context: normalized.context },
|
|
177
|
+
"Worker entered quarantine mode; mutation tasks will not be claimed until configuration/schema/indexes are repaired"
|
|
178
|
+
);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const workerClientId = process.env.API_CLIENT_ID ?? "worker-1";
|
|
183
|
+
const workerBearerToken = await readRuntimeSecret(process.env, "API_WORKER_TOKEN") || "worker-secret";
|
|
184
|
+
const requestSigningSecret = await readRuntimeSecret(process.env, "API_REQUEST_SIGNING_SECRET") || undefined;
|
|
185
|
+
const workerAuthHeaders = {
|
|
186
|
+
"x-client-id": workerClientId,
|
|
187
|
+
authorization: `Bearer ${workerBearerToken}`
|
|
188
|
+
} as const;
|
|
189
|
+
|
|
190
|
+
const pushOutboxEvent = createFetchDispatcher({
|
|
191
|
+
url: process.env.API_OUTBOX_URL ?? "http://localhost:3000/api/v1/worker/outbox",
|
|
192
|
+
token: workerBearerToken,
|
|
193
|
+
clientId: workerClientId,
|
|
194
|
+
requestSigningSecret,
|
|
195
|
+
timeoutMs: 10_000,
|
|
196
|
+
})
|
|
197
|
+
|
|
198
|
+
const mailerWebhookUrl = process.env.MAILER_WEBHOOK_URL;
|
|
199
|
+
if (!mailerWebhookUrl) {
|
|
200
|
+
throw workerError({
|
|
201
|
+
code: "MAILER_TRANSPORT_MISSING",
|
|
202
|
+
title: "Missing mailer transport",
|
|
203
|
+
detail: "MAILER_WEBHOOK_URL must be configured for production notice dispatch.",
|
|
204
|
+
category: "configuration",
|
|
205
|
+
retryable: false,
|
|
206
|
+
fatal: true,
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const mailerTimeoutMs = Number(process.env.MAILER_TIMEOUT_MS ?? "10000");
|
|
211
|
+
const mailer: MockMailer = {
|
|
212
|
+
async sendEmail(message) {
|
|
213
|
+
await sendMailerWebhook(mailerWebhookUrl, message, mailerTimeoutMs);
|
|
214
|
+
},
|
|
215
|
+
};
|
|
216
|
+
|
|
217
|
+
const apiClient = createControlPlaneApiClient({
|
|
218
|
+
syncUrl: process.env.API_SYNC_URL ?? "http://localhost:3000/api/v1/worker/sync",
|
|
219
|
+
ackBaseUrl: process.env.API_BASE_URL ?? "http://localhost:3000/api/v1/worker/tasks",
|
|
220
|
+
workerAuthHeaders,
|
|
221
|
+
workerConfigHash,
|
|
222
|
+
workerConfigVersion: config.legal_attestation.configuration_version,
|
|
223
|
+
workerDpoIdentifier: config.legal_attestation.dpo_identifier,
|
|
224
|
+
pushOutboxEvent,
|
|
225
|
+
requestSigningSecret,
|
|
226
|
+
timeoutMs: 10_000,
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
const worker = new ComplianceWorker({
|
|
230
|
+
sql,
|
|
231
|
+
sqlReplica,
|
|
232
|
+
config,
|
|
233
|
+
secrets: { kek: config.masterKey, hmacKey: config.hmacKey },
|
|
234
|
+
apiClient,
|
|
235
|
+
mailer,
|
|
236
|
+
s3Client: config.blob_targets.length > 0 ? createS3Client() : undefined,
|
|
237
|
+
taskHeartbeatIntervalMs: readPositiveIntegerEnv("WORKER_TASK_HEARTBEAT_INTERVAL_MS", 30_000),
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
const metricsPort = Number(process.env.METRICS_PORT ?? "9466");
|
|
241
|
+
|
|
242
|
+
Bun.serve({
|
|
243
|
+
port: metricsPort,
|
|
244
|
+
fetch: async (request) => {
|
|
245
|
+
const url = new URL(request.url);
|
|
246
|
+
if (url.pathname === "/healthz") {
|
|
247
|
+
return new Response("ok", { status: 200 });
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
if (url.pathname === "/readyz") {
|
|
251
|
+
const ready = workerBooted && (await checkDatabaseHealth(sql!));
|
|
252
|
+
return new Response(ready ? "ready" : "not ready", {
|
|
253
|
+
status: ready ? 200 : 503,
|
|
254
|
+
});
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
return new Response("Not Found", { status: 404 });
|
|
258
|
+
},
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
workerBooted = true;
|
|
262
|
+
const pollBaseDelayMs = readPositiveIntegerEnv("WORKER_POLL_BASE_DELAY_MS", 1_000);
|
|
263
|
+
const pollMaxDelayMs = readPositiveIntegerEnv("WORKER_POLL_MAX_DELAY_MS", 30_000);
|
|
264
|
+
const taskConcurrency = readPositiveIntegerEnv("WORKER_TASK_CONCURRENCY", 1);
|
|
265
|
+
let emptyPolls = 0;
|
|
266
|
+
|
|
267
|
+
logger.info(
|
|
268
|
+
{
|
|
269
|
+
appSchema: config.database.app_schema,
|
|
270
|
+
engineSchema: config.database.engine_schema,
|
|
271
|
+
replicaEnabled: Boolean(sqlReplica),
|
|
272
|
+
metricsPort,
|
|
273
|
+
mailerTimeoutMs,
|
|
274
|
+
workerConfigHash,
|
|
275
|
+
workerConfigVersion: config.legal_attestation.configuration_version,
|
|
276
|
+
dpoIdentifier: config.legal_attestation.dpo_identifier,
|
|
277
|
+
quarantined: workerQuarantined,
|
|
278
|
+
pollBaseDelayMs,
|
|
279
|
+
pollMaxDelayMs,
|
|
280
|
+
taskConcurrency,
|
|
281
|
+
},
|
|
282
|
+
"DPDP Compliance Worker booted"
|
|
283
|
+
);
|
|
284
|
+
|
|
285
|
+
while (true) {
|
|
286
|
+
try {
|
|
287
|
+
const processedCount = workerQuarantined ? 0 : await worker.processTaskBatch(taskConcurrency);
|
|
288
|
+
const realy = await worker.flushOutbox();
|
|
289
|
+
|
|
290
|
+
if (processedCount > 0 || realy.claimed > 0) {
|
|
291
|
+
emptyPolls = 0;
|
|
292
|
+
continue;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
const delayMs = computeIdlePollDelayMs(emptyPolls, pollBaseDelayMs, pollMaxDelayMs);
|
|
296
|
+
emptyPolls += 1;
|
|
297
|
+
await sleep(delayMs);
|
|
298
|
+
|
|
299
|
+
} catch (error) {
|
|
300
|
+
const normalized = logError(logger, error, "Worker loop iteration failed");
|
|
301
|
+
if (normalized.fatal) {
|
|
302
|
+
throw normalized;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
await sleep(normalized.retryable
|
|
306
|
+
? pollBaseDelayMs
|
|
307
|
+
: Math.min(pollMaxDelayMs, pollBaseDelayMs * 10)
|
|
308
|
+
);
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
} finally {
|
|
314
|
+
const shutdownTasks: Promise<unknown>[] = [];
|
|
315
|
+
if (sql) {
|
|
316
|
+
shutdownTasks.push(sql.end());
|
|
317
|
+
}
|
|
318
|
+
if (sqlReplica) {
|
|
319
|
+
shutdownTasks.push(sqlReplica.end());
|
|
320
|
+
}
|
|
321
|
+
await Promise.allSettled(shutdownTasks);
|
|
322
|
+
}
|
|
323
|
+
};
|
|
324
|
+
|
|
325
|
+
main().catch((error) => {
|
|
326
|
+
logError(logger, error, "Worker failed to start");
|
|
327
|
+
process.exit(1);
|
|
328
|
+
});
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
const textEncoder = new TextEncoder();
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Converts binary data into lowercase hexadecimal representation.
|
|
5
|
+
* @param buffer - Input bytes.
|
|
6
|
+
* @returns Hex string.
|
|
7
|
+
*/
|
|
8
|
+
export function hexEncode(buffer: ArrayBuffer): string {
|
|
9
|
+
return Array.from(new Uint8Array(buffer), (byte) => byte.toString(16).padStart(2, "0")).join("");
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Computes SHA-256 digest for UTF-8 text and returns lowercase hex.
|
|
14
|
+
*
|
|
15
|
+
* @param input - Text payload to hash.
|
|
16
|
+
* @returns SHA-256 digest encoded as hex.
|
|
17
|
+
*/
|
|
18
|
+
export async function sha256HexDigest(input: string): Promise<string> {
|
|
19
|
+
const data = textEncoder.encode(input);
|
|
20
|
+
const digest = await globalThis.crypto.subtle.digest("SHA-256", data);
|
|
21
|
+
return hexEncode(digest);
|
|
22
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Web-native byte encoding helpers for Bun/Web Crypto code paths.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
function bytesToBinary(bytes: Uint8Array): string {
|
|
6
|
+
let output = "";
|
|
7
|
+
const chunkSize = 0x8000;
|
|
8
|
+
|
|
9
|
+
for (let offset = 0; offset < bytes.length; offset += chunkSize) {
|
|
10
|
+
output += String.fromCharCode(...bytes.subarray(offset, offset + chunkSize));
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
return output;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Encodes raw bytes as base64.
|
|
18
|
+
*
|
|
19
|
+
* @param bytes - Binary payload.
|
|
20
|
+
* @returns Base64 string.
|
|
21
|
+
*/
|
|
22
|
+
export function bytesToBase64(bytes: Uint8Array): string {
|
|
23
|
+
return btoa(bytesToBinary(bytes));
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Decodes Base64 into raw bytes.
|
|
28
|
+
*
|
|
29
|
+
* @param value - Base64 payload.
|
|
30
|
+
* @returns Decoded bytes.
|
|
31
|
+
* @throws {TypeError} when value is not valid Base64.
|
|
32
|
+
*/
|
|
33
|
+
export function base64ToBytes(value: string): Uint8Array {
|
|
34
|
+
const binary = atob(value);
|
|
35
|
+
const bytes = new Uint8Array(binary.length);
|
|
36
|
+
|
|
37
|
+
for (let i = 0; i < binary.length; i++) {
|
|
38
|
+
bytes[i] = binary.charCodeAt(i);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
return bytes;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Encodes raw bytes as lowercase hexadecimal.
|
|
46
|
+
*
|
|
47
|
+
* @param bytes - Binary payload.
|
|
48
|
+
* @returns Lowercase hex string.
|
|
49
|
+
*/
|
|
50
|
+
export function bytesToHex(bytes: Uint8Array): string {
|
|
51
|
+
return Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("");
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Decodes hexadecimal text into raw bytes
|
|
56
|
+
*
|
|
57
|
+
* @param value - Hex payload
|
|
58
|
+
* @returns Decoded Bytes
|
|
59
|
+
* @throws {TypeError} When the input is not valid even-length hex.
|
|
60
|
+
*/
|
|
61
|
+
export function hexToBytes(value: string): Uint8Array {
|
|
62
|
+
if (value.length % 2 !== 0 || /[^0-9a-f]/i.test(value)) {
|
|
63
|
+
throw new TypeError("Invalid hexadecimal string.");
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const bytes = new Uint8Array(value.length / 2)
|
|
67
|
+
for (let i = 0; i < value.length; i += 2) {
|
|
68
|
+
bytes[i / 2] = Number.parseInt(value.slice(i, i + 2), 16);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return bytes;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function copyBytes(bytes: Uint8Array): Uint8Array {
|
|
75
|
+
const copy = new Uint8Array(bytes.length);
|
|
76
|
+
copy.set(bytes);
|
|
77
|
+
return copy as Uint8Array;
|
|
78
|
+
}
|
package/src/lib/index.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from "./crypto";
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import type { Sql } from "@/types";
|
|
2
|
+
import { detectSchemaDrift } from "@modules/db";
|
|
3
|
+
import { fail } from "@/errors";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Validates runtime schema fingerprint against the expected hash from worker configuration.
|
|
7
|
+
*
|
|
8
|
+
* @param sql - Postgres pool used to inspect `information_schema`.
|
|
9
|
+
* @param appSchema - Application schema expected by the worker.
|
|
10
|
+
* @param expectedSchemaHash - Expected SHA-256 schema fingerprint from config.
|
|
11
|
+
* @returns Detected schema hash when validation succeeds.
|
|
12
|
+
* @throws {WorkerError} When detected hash does not match expected hash.
|
|
13
|
+
*/
|
|
14
|
+
export async function assertSchemaIntegrity(
|
|
15
|
+
sql: Sql,
|
|
16
|
+
appSchema: string,
|
|
17
|
+
expectedSchemaHash: string
|
|
18
|
+
): Promise<string> {
|
|
19
|
+
const detectedSchemaHash = await detectSchemaDrift(sql, appSchema);
|
|
20
|
+
|
|
21
|
+
if (detectedSchemaHash !== expectedSchemaHash) {
|
|
22
|
+
fail({
|
|
23
|
+
code: "SCHEMA_DRIFT_DETECTED",
|
|
24
|
+
title: "Schema drift detected",
|
|
25
|
+
detail: `Schema drift detected for ${appSchema}. Expected ${expectedSchemaHash}, received ${detectedSchemaHash}. Refusing to start.`,
|
|
26
|
+
category: "integrity",
|
|
27
|
+
retryable: false,
|
|
28
|
+
fatal: true,
|
|
29
|
+
context: {
|
|
30
|
+
appSchema,
|
|
31
|
+
expectedSchemaHash,
|
|
32
|
+
detectedSchemaHash,
|
|
33
|
+
},
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return detectedSchemaHash;
|
|
38
|
+
}
|