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,247 @@
|
|
|
1
|
+
import { CODE, fail } from "@/errors";
|
|
2
|
+
import type { EnvType } from "@/types";
|
|
3
|
+
import { base64ToBytes } from "@/lib";
|
|
4
|
+
import { type KeySourceConfig, signAwsKmsRequest } from "./kms";
|
|
5
|
+
import { readLegacyEnvKey, readRuntimeSecret } from "./reader";
|
|
6
|
+
import { decodeKeyMaterial, encodeVaultPathSegment, fetchJson, isRecord, normalizeBase64 } from "./repository";
|
|
7
|
+
|
|
8
|
+
export interface ResolveKeyOptions {
|
|
9
|
+
env: EnvType
|
|
10
|
+
keyName: string;
|
|
11
|
+
legacyEnvName: string;
|
|
12
|
+
fallbackLegacyEnvName?: string;
|
|
13
|
+
source?: KeySourceConfig;
|
|
14
|
+
fetchFn?: typeof fetch;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
async function requiredRuntimeSecret(
|
|
18
|
+
env: EnvType,
|
|
19
|
+
envName: string,
|
|
20
|
+
purpose: string
|
|
21
|
+
): Promise<string> {
|
|
22
|
+
const value = await readRuntimeSecret(env, envName);
|
|
23
|
+
if (!value) {
|
|
24
|
+
fail({
|
|
25
|
+
code: CODE.KMS_SECRET_MISSING,
|
|
26
|
+
detail: `${envName} is required to resolve ${purpose}.`,
|
|
27
|
+
context: { envName, purpose },
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
return value;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Resolves a configured key source using only synchronous local sources.
|
|
36
|
+
*
|
|
37
|
+
* @param options - Key lookup contract and runtime environment map.
|
|
38
|
+
* @returns A 32-byte key resolved from env or file.
|
|
39
|
+
* @throws {WorkerError} If a remote key provider is configured on the sync path.
|
|
40
|
+
*/
|
|
41
|
+
export async function resolveConfiguredKeyAsync(options: ResolveKeyOptions): Promise<Uint8Array> {
|
|
42
|
+
const source = options.source;
|
|
43
|
+
if (!source) {
|
|
44
|
+
return readLegacyEnvKey(options);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (source.provider === "env") {
|
|
48
|
+
return decodeKeyMaterial(
|
|
49
|
+
await requiredRuntimeSecret(
|
|
50
|
+
options.env,
|
|
51
|
+
source.env,
|
|
52
|
+
options.keyName
|
|
53
|
+
), options.keyName
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (source.provider === "file") {
|
|
58
|
+
const rawKey = (await Bun.file(source.path).text()).trim();
|
|
59
|
+
return decodeKeyMaterial(rawKey, options.keyName)
|
|
60
|
+
}
|
|
61
|
+
fail({
|
|
62
|
+
code: "KMS_ASYNC_PROVIDER_ON_SYNC_PATH",
|
|
63
|
+
title: "Remote key provider requires async boot",
|
|
64
|
+
detail: `${source.provider} key sources require readWorkerConfigFromRuntime().`,
|
|
65
|
+
category: "configuration",
|
|
66
|
+
retryable: false,
|
|
67
|
+
fatal: true,
|
|
68
|
+
context: { provider: source.provider, keyName: options.keyName }
|
|
69
|
+
})
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async function resolveAwsKmsKey(
|
|
73
|
+
source: Extract<KeySourceConfig, { provider: "aws_kms" }>,
|
|
74
|
+
options: ResolveKeyOptions
|
|
75
|
+
): Promise<Uint8Array> {
|
|
76
|
+
const fetchFn = options.fetchFn ?? globalThis.fetch;
|
|
77
|
+
const endpoint = new URL(source.endpoint ?? `https://kms.${source.region}.amazonaws.com/`);
|
|
78
|
+
const body = JSON.stringify({
|
|
79
|
+
CiphertextBlob: source.ciphertext_blob_base64,
|
|
80
|
+
...(source.key_id ? { KeyId: source.key_id } : {}),
|
|
81
|
+
...(source.encryption_context ? { EncryptionContext: source.encryption_context } : {}),
|
|
82
|
+
});
|
|
83
|
+
const headers = await signAwsKmsRequest(endpoint, source.region, body, {
|
|
84
|
+
accessKeyId: await requiredRuntimeSecret(options.env,
|
|
85
|
+
source.access_key_id_env,
|
|
86
|
+
options.keyName
|
|
87
|
+
),
|
|
88
|
+
secretAccessKey: await requiredRuntimeSecret(
|
|
89
|
+
options.env,
|
|
90
|
+
source.secret_access_key_env,
|
|
91
|
+
options.keyName
|
|
92
|
+
),
|
|
93
|
+
sessionToken: await readRuntimeSecret(
|
|
94
|
+
options.env,
|
|
95
|
+
source.session_token_env
|
|
96
|
+
) || undefined,
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
const json = await fetchJson(fetchFn, endpoint, { method: "POST", headers, body }, "AWS KMS");
|
|
100
|
+
if (!isRecord(json) || typeof json.Plaintext !== "string") {
|
|
101
|
+
fail({
|
|
102
|
+
code: CODE.KMS_RESPONSE_INVALID,
|
|
103
|
+
detail: "AWS KMS response did not include a base64 Plaintext field.",
|
|
104
|
+
context: { provider: "aws_kms" },
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return decodeKeyMaterial(base64ToBytes(normalizeBase64(json.Plaintext)), options.keyName);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
async function resolveGcpToken(
|
|
112
|
+
source: Extract<KeySourceConfig, { provider: "gcp_secret_manager" }>,
|
|
113
|
+
options: ResolveKeyOptions
|
|
114
|
+
): Promise<string> {
|
|
115
|
+
const envToken = await readRuntimeSecret(options.env, source.access_token_env)
|
|
116
|
+
if (envToken) return envToken;
|
|
117
|
+
|
|
118
|
+
const json = await fetchJson(
|
|
119
|
+
options.fetchFn ?? globalThis.fetch,
|
|
120
|
+
source.metadata_token_url,
|
|
121
|
+
{
|
|
122
|
+
method: "GET",
|
|
123
|
+
headers: {
|
|
124
|
+
"metadata-flavor": "Google",
|
|
125
|
+
},
|
|
126
|
+
},
|
|
127
|
+
"GCP metadata server"
|
|
128
|
+
);
|
|
129
|
+
|
|
130
|
+
if (!isRecord(json) || typeof json.access_token !== "string") {
|
|
131
|
+
fail({
|
|
132
|
+
code: CODE.KMS_RESPONSE_INVALID,
|
|
133
|
+
detail: "GCP metadata token response did not include access_token.",
|
|
134
|
+
context: { provider: "gcp_metadata" },
|
|
135
|
+
})
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
return json.access_token;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
async function resolveGcpSecretManagerKey(
|
|
142
|
+
source: Extract<KeySourceConfig, { provider: "gcp_secret_manager" }>,
|
|
143
|
+
options: ResolveKeyOptions
|
|
144
|
+
): Promise<Uint8Array> {
|
|
145
|
+
const fetchFn = options.fetchFn ?? globalThis.fetch;
|
|
146
|
+
const endpoint = source.endpoint ?? `https://secretmanager.googleapis.com/v1/${source.secret_version}:access`
|
|
147
|
+
const accessToken = await resolveGcpToken(source, options);
|
|
148
|
+
const json = await fetchJson(
|
|
149
|
+
fetchFn,
|
|
150
|
+
endpoint,
|
|
151
|
+
{
|
|
152
|
+
method: "GET",
|
|
153
|
+
headers: {
|
|
154
|
+
authorization: `Bearer ${accessToken}`
|
|
155
|
+
},
|
|
156
|
+
},
|
|
157
|
+
"GCP Secret Manager"
|
|
158
|
+
);
|
|
159
|
+
|
|
160
|
+
const payload = isRecord(json) && isRecord(json.payload) ? json.payload : null;
|
|
161
|
+
if (!payload || typeof payload.data !== "string") {
|
|
162
|
+
fail({
|
|
163
|
+
code: CODE.KMS_RESPONSE_INVALID,
|
|
164
|
+
detail: "GCP Secret Manager response did not include payload.data.",
|
|
165
|
+
context: { provider: "gcp_secret_manager" },
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
return decodeKeyMaterial(base64ToBytes(normalizeBase64(payload.data)), options.keyName);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
async function resolveVaultKey(
|
|
174
|
+
source: Extract<KeySourceConfig, { provider: "hashicorp_vault" }>,
|
|
175
|
+
options: ResolveKeyOptions
|
|
176
|
+
): Promise<Uint8Array> {
|
|
177
|
+
const fetchFn = options.fetchFn ?? globalThis.fetch;
|
|
178
|
+
const address = source.address ?? await readRuntimeSecret(options.env, source.address_env);
|
|
179
|
+
if (!address) {
|
|
180
|
+
fail({
|
|
181
|
+
code: CODE.KMS_SECRET_MISSING,
|
|
182
|
+
detail: `${source.address_env} or security key source address is required to resolve ${options.keyName}.`,
|
|
183
|
+
context: { provider: "hashicorp_vault", addressEnv: source.address_env },
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const url = new URL(
|
|
188
|
+
`/v1/${encodeVaultPathSegment(source.mount)}/data/${encodeVaultPathSegment(source.path)}`,
|
|
189
|
+
address.endsWith("/") ? address : `${address}/`
|
|
190
|
+
);
|
|
191
|
+
if (source.version !== undefined) {
|
|
192
|
+
url.searchParams.set("version", String(source.version));
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const headers = new Headers({
|
|
196
|
+
"x-vault-token": await requiredRuntimeSecret(options.env, source.token_env, options.keyName),
|
|
197
|
+
});
|
|
198
|
+
const namespace = await readRuntimeSecret(options.env, source.namespace_env);
|
|
199
|
+
if (namespace) {
|
|
200
|
+
headers.set("x-vault-namespace", namespace);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const json = await fetchJson(
|
|
204
|
+
fetchFn,
|
|
205
|
+
url,
|
|
206
|
+
{ method: "GET", headers },
|
|
207
|
+
"HashiCorp Vault");
|
|
208
|
+
const data = isRecord(json) && isRecord(json.data) && isRecord(json.data.data) ? json.data.data : null;
|
|
209
|
+
const rawValue = data?.[source.field];
|
|
210
|
+
if (typeof rawValue !== "string") {
|
|
211
|
+
fail({
|
|
212
|
+
code: CODE.KMS_RESPONSE_INVALID,
|
|
213
|
+
detail: `HashiCorp Vault response did not include data.data.${source.field}.`,
|
|
214
|
+
context: { provider: "hashicorp_vault", field: source.field },
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
return decodeKeyMaterial(rawValue, options.keyName);
|
|
219
|
+
};
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Resolves a configured key source from env, file, AWS KMS, GCP Secret Manager, or Vault KV v2.
|
|
223
|
+
*
|
|
224
|
+
* @param options - Key lookup contract and runtime environment map.
|
|
225
|
+
* @returns A 32-byte key suitable for Web Crypto operations.
|
|
226
|
+
* @throws {WorkerError} If retrieval fails or the provider returns invalid key material.
|
|
227
|
+
*/
|
|
228
|
+
export async function resolveConfiguredKey(options: ResolveKeyOptions): Promise<Uint8Array> {
|
|
229
|
+
const source = options.source;
|
|
230
|
+
if (!source) {
|
|
231
|
+
return readLegacyEnvKey(options);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
if (source.provider === "env" || source.provider === "file") {
|
|
235
|
+
return resolveConfiguredKeyAsync(options);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
if (source.provider === "aws_kms") {
|
|
239
|
+
return resolveAwsKmsKey(source, options);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
if (source.provider === "gcp_secret_manager") {
|
|
243
|
+
return resolveGcpSecretManagerKey(source, options);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
return resolveVaultKey(source, options);
|
|
247
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { fail } from "../errors";
|
|
2
|
+
import { readRuntimeSecret } from "./reader";
|
|
3
|
+
import { base64ToBytes } from "@/lib";
|
|
4
|
+
|
|
5
|
+
function toArrayBuffer(bytes: Uint8Array): ArrayBuffer {
|
|
6
|
+
const copy = new Uint8Array(bytes.length);
|
|
7
|
+
copy.set(bytes);
|
|
8
|
+
return copy.buffer as ArrayBuffer;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Verifies an optional detached Ed25519 signature for `compliance.worker.yml`.
|
|
13
|
+
*
|
|
14
|
+
* When no public key is configured the check is skipped. When a public key is configured, the
|
|
15
|
+
* worker fails closed unless the detached signature exists and verifies successfully.
|
|
16
|
+
*
|
|
17
|
+
* @param env - Runtime environment map.
|
|
18
|
+
* @param configPath - Path to the worker YAML manifest.
|
|
19
|
+
* @throws {WorkerError} When the signature is required but missing or invalid.
|
|
20
|
+
*/
|
|
21
|
+
export async function verifySignedWorkerConfig(
|
|
22
|
+
env: Record<string, string | undefined>,
|
|
23
|
+
configPath: string | URL
|
|
24
|
+
): Promise<void> {
|
|
25
|
+
const publicKeySpkiBase64 = await readRuntimeSecret(
|
|
26
|
+
env,
|
|
27
|
+
"CONFIG_SIGNING_PUBLIC_KEY_SPKI_BASE64"
|
|
28
|
+
);
|
|
29
|
+
if (!publicKeySpkiBase64) {
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const signaturePath = env.DPDP_CONFIG_SIGNATURE_PATH ?? `${String(configPath)}.sig`;
|
|
34
|
+
let signatureBase64: string;
|
|
35
|
+
try {
|
|
36
|
+
signatureBase64 = (await Bun.file(signaturePath).text()).trim();
|
|
37
|
+
} catch (error) {
|
|
38
|
+
fail({
|
|
39
|
+
code: "CONFIG_SIGNATURE_MISSING",
|
|
40
|
+
title: "Missing worker config signature",
|
|
41
|
+
detail: `Detached config signature ${signaturePath} is required when config signing is enabled.`,
|
|
42
|
+
category: "configuration",
|
|
43
|
+
retryable: false,
|
|
44
|
+
fatal: true,
|
|
45
|
+
cause: error,
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const publicKey = await globalThis.crypto.subtle.importKey(
|
|
50
|
+
"spki",
|
|
51
|
+
toArrayBuffer(base64ToBytes(await publicKeySpkiBase64)),
|
|
52
|
+
{ name: "Ed25519" },
|
|
53
|
+
false,
|
|
54
|
+
["verify"]
|
|
55
|
+
);
|
|
56
|
+
const configBytes = new Uint8Array(await Bun.file(configPath).arrayBuffer());
|
|
57
|
+
const verified = await globalThis.crypto.subtle.verify(
|
|
58
|
+
"Ed25519",
|
|
59
|
+
publicKey,
|
|
60
|
+
toArrayBuffer(base64ToBytes(signatureBase64)),
|
|
61
|
+
toArrayBuffer(configBytes)
|
|
62
|
+
);
|
|
63
|
+
|
|
64
|
+
if (!verified) {
|
|
65
|
+
fail({
|
|
66
|
+
code: "CONFIG_SIGNATURE_INVALID",
|
|
67
|
+
title: "Invalid worker config signature",
|
|
68
|
+
detail: `Detached config signature ${signaturePath} failed verification.`,
|
|
69
|
+
category: "integrity",
|
|
70
|
+
retryable: false,
|
|
71
|
+
fatal: true,
|
|
72
|
+
context: {
|
|
73
|
+
configPath: String(configPath),
|
|
74
|
+
signaturePath,
|
|
75
|
+
},
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from "./types";
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type postgres from "postgres";
|
|
2
|
+
|
|
3
|
+
export type EnvType = Record<string, string | undefined>;
|
|
4
|
+
|
|
5
|
+
export type Tsql = postgres.TransactionSql;
|
|
6
|
+
|
|
7
|
+
export type Sql = postgres.Sql;
|
|
8
|
+
|
|
9
|
+
export type SqlExecutor = postgres.TransactionSql | postgres.Sql;
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Safely replaces existing properties in a type with new definitions.
|
|
13
|
+
* Prevents TypeScript conflicts by removing the old field before applying the new one.
|
|
14
|
+
*
|
|
15
|
+
* @example
|
|
16
|
+
* type User = { id: number; name: string; role: string };
|
|
17
|
+
*
|
|
18
|
+
* type AdminUser = Override<User, {
|
|
19
|
+
* id: string;
|
|
20
|
+
* role: "admin" | "superadmin";
|
|
21
|
+
* }>;
|
|
22
|
+
*/
|
|
23
|
+
export type Override<T, R> = Omit<T, keyof R> & R;
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { fail } from "@/errors";
|
|
2
|
+
|
|
3
|
+
const IDENTIFIER_PATTERN = /^[a-zA-Z_][a-zA-Z0-9_]*$/;
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Validates an identifier used in dynamic SQL fragments.
|
|
7
|
+
*
|
|
8
|
+
* @param name - Candidate identifier.
|
|
9
|
+
* @param label - Human readable label used in validtation errors.
|
|
10
|
+
* @returns Unchanged identifier when valid.
|
|
11
|
+
* @throws {WorkerError} When `name` contains unsafe characters.
|
|
12
|
+
*/
|
|
13
|
+
export function assertIdentifier(name: string, label: string): string {
|
|
14
|
+
if (!IDENTIFIER_PATTERN.test(name)) {
|
|
15
|
+
fail({
|
|
16
|
+
code: "IDENTIFIER_INVALID",
|
|
17
|
+
title: "Invalid identifier",
|
|
18
|
+
detail: `Invalid ${label}: "${name}". Only letters, numbers, and underscores are allowed.`,
|
|
19
|
+
category: "validation",
|
|
20
|
+
retryable: false,
|
|
21
|
+
context: { label },
|
|
22
|
+
});
|
|
23
|
+
};
|
|
24
|
+
return name;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Quotes and escapes one SQL identifier.
|
|
29
|
+
*
|
|
30
|
+
* @param name - Untrusted identifier text.
|
|
31
|
+
* @returns Safe quoted identifier.
|
|
32
|
+
*/
|
|
33
|
+
export function quoteIdentifier(name: string): string {
|
|
34
|
+
return `"${name.replace(/"/g, "\"\"")}"`;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Quotes a `schema.table` pair after validating both identifiers.
|
|
39
|
+
*
|
|
40
|
+
* @param schema - Schema identifier.
|
|
41
|
+
* @param table - Table identifier.
|
|
42
|
+
* @returns Fully qualified quoted identifier string.
|
|
43
|
+
* @throws {WorkerError} When either identifier is invalid.
|
|
44
|
+
*/
|
|
45
|
+
export function quoteQualifiedIdentifier(schema: string, table: string): string {
|
|
46
|
+
return `${quoteIdentifier(assertIdentifier(schema, "schema name"))}.${quoteIdentifier(assertIdentifier(table, "table name"))}`;
|
|
47
|
+
}
|
|
48
|
+
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
type JsonPrimitive = string | number | boolean | null;
|
|
2
|
+
type JsonValue = JsonPrimitive | JsonValue[] | { [key: string]: JsonValue };
|
|
3
|
+
|
|
4
|
+
function sortJson(value: JsonValue): JsonValue {
|
|
5
|
+
if (Array.isArray(value)) {
|
|
6
|
+
return value.map((item) => sortJson(item));
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
if (value && typeof value === "object") {
|
|
10
|
+
return Object.fromEntries(
|
|
11
|
+
Object.keys(value)
|
|
12
|
+
.sort((left, right) => left.localeCompare(right))
|
|
13
|
+
.map((key) => [key, sortJson((value as Record<string, JsonValue>)[key]!)])
|
|
14
|
+
);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
return value;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Serializes unknown input into deterministic JSON text by sorting object keys recursively.
|
|
23
|
+
*
|
|
24
|
+
* Non-JSON values are first normalized using native `JSON.stringify` semantics, then canonicalized.
|
|
25
|
+
*/
|
|
26
|
+
export function canonicalJsonStringify(value: unknown): string {
|
|
27
|
+
const firstPass = JSON.stringify(value);
|
|
28
|
+
if (firstPass === undefined) {
|
|
29
|
+
throw new TypeError("Value is not JSON-serializable.");
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const parsed = JSON.parse(firstPass) as JsonValue;
|
|
33
|
+
return JSON.stringify(sortJson(parsed));
|
|
34
|
+
}
|
|
35
|
+
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import pino, { type DestinationStream, type Logger } from "pino";
|
|
2
|
+
import { asWorkerError } from "@/errors";
|
|
3
|
+
|
|
4
|
+
export interface LoggerBindings {
|
|
5
|
+
[key: string]: string
|
|
6
|
+
};
|
|
7
|
+
|
|
8
|
+
const REDACTED_PATHS = [
|
|
9
|
+
"authorization",
|
|
10
|
+
"*.authorization",
|
|
11
|
+
"headers.authorization",
|
|
12
|
+
"req.headers.authorization",
|
|
13
|
+
"apiToken",
|
|
14
|
+
"*.apiToken",
|
|
15
|
+
"token",
|
|
16
|
+
"*.token",
|
|
17
|
+
"masterKey",
|
|
18
|
+
"*.masterKey",
|
|
19
|
+
"hmacKey",
|
|
20
|
+
"*.hmacKey",
|
|
21
|
+
"kek",
|
|
22
|
+
"*.kek",
|
|
23
|
+
"encrypted_pii",
|
|
24
|
+
"*.encrypted_pii",
|
|
25
|
+
"encrypted_pii.data",
|
|
26
|
+
"*.encrypted_pii.data",
|
|
27
|
+
"encrypted_dek",
|
|
28
|
+
"*.encrypted_dek",
|
|
29
|
+
"payload.data",
|
|
30
|
+
"*.payload.data",
|
|
31
|
+
"payload.email",
|
|
32
|
+
"*.payload.email",
|
|
33
|
+
"payload.full_name",
|
|
34
|
+
"*.payload.full_name",
|
|
35
|
+
"email",
|
|
36
|
+
"*.email",
|
|
37
|
+
"full_name",
|
|
38
|
+
"*.full_name",
|
|
39
|
+
];
|
|
40
|
+
|
|
41
|
+
function serializeErrorForLog(error: unknown) {
|
|
42
|
+
const normalizedError = asWorkerError(error);
|
|
43
|
+
return {
|
|
44
|
+
...normalizedError.toProblem(),
|
|
45
|
+
stack: normalizedError.stack,
|
|
46
|
+
};
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Creates a Pino logger configured for worker-safe redaction and structured error serialization.
|
|
51
|
+
*
|
|
52
|
+
* @param bindings - Optional static bindings merged into every log record.
|
|
53
|
+
* @param destination - Optional Pino destination stream.
|
|
54
|
+
* @returns Configured Pino logger instance.
|
|
55
|
+
*/
|
|
56
|
+
export function createWorkerLogger(
|
|
57
|
+
bindings: LoggerBindings = {},
|
|
58
|
+
destination?: DestinationStream
|
|
59
|
+
): Logger {
|
|
60
|
+
const instance = pino(
|
|
61
|
+
{
|
|
62
|
+
level: process.env.LOG_LEVEL ?? "info",
|
|
63
|
+
messageKey: "message",
|
|
64
|
+
timestamp: pino.stdTimeFunctions.isoTime,
|
|
65
|
+
base: {
|
|
66
|
+
service: "compliance-worker",
|
|
67
|
+
plane: "data"
|
|
68
|
+
},
|
|
69
|
+
redact: {
|
|
70
|
+
paths: REDACTED_PATHS,
|
|
71
|
+
censor: "[REDACTED]",
|
|
72
|
+
},
|
|
73
|
+
formatters: {
|
|
74
|
+
level: (label) => ({ level: label })
|
|
75
|
+
},
|
|
76
|
+
serializers: {
|
|
77
|
+
err: serializeErrorForLog
|
|
78
|
+
},
|
|
79
|
+
},
|
|
80
|
+
destination
|
|
81
|
+
)
|
|
82
|
+
return Object.keys(bindings).length > 0 ? instance.child(bindings) : instance;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export const logger = createWorkerLogger();
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Returns a child logger bound to contextual fields.
|
|
89
|
+
*
|
|
90
|
+
* @param bindings - Context bindings added to each emitted record.
|
|
91
|
+
* @returns Child logger.
|
|
92
|
+
*/
|
|
93
|
+
export function getLogger(bindings: LoggerBindings): Logger {
|
|
94
|
+
return logger.child(bindings);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Logs and normalizes unknown errors using standardized worker error envelopes.
|
|
99
|
+
*
|
|
100
|
+
* @param loggerInstance - Logger to emit to.
|
|
101
|
+
* @param error - Unknown error value.
|
|
102
|
+
* @param message - Log message.
|
|
103
|
+
* @param bindings - Additional structured context.
|
|
104
|
+
* @returns Normalized `WorkerError`.
|
|
105
|
+
*/
|
|
106
|
+
export function logError(
|
|
107
|
+
loggerInstace: Logger,
|
|
108
|
+
error: unknown,
|
|
109
|
+
message: string,
|
|
110
|
+
bindings: LoggerBindings = {}
|
|
111
|
+
) {
|
|
112
|
+
const normalizedError = asWorkerError(error);
|
|
113
|
+
const level = normalizedError.fatal ? "fatal" : normalizedError.retryable ? "warn" : "fatal"
|
|
114
|
+
loggerInstace[level]({ ...bindings, err: normalizedError });
|
|
115
|
+
return normalizedError;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function terminate(logger: Logger, error: unknown, code: number) {
|
|
119
|
+
const normalized = asWorkerError(error, {
|
|
120
|
+
code: "RUNTIME_FATAL",
|
|
121
|
+
title: "Fatal runtime error",
|
|
122
|
+
detail: "A fatal runtime error reached the process boundary.",
|
|
123
|
+
category: "runtime",
|
|
124
|
+
fatal: true,
|
|
125
|
+
});
|
|
126
|
+
logError(logger, normalized, "Fatal runtime error reached process boundary");
|
|
127
|
+
process.exit(code);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Registers process-level fatal guards for unhandled rejections and uncaught exceptions.
|
|
132
|
+
*
|
|
133
|
+
* @param logger - Root logger used to emit terminal failure diagnostics.
|
|
134
|
+
* @returns vaild; installs listners on `process`.
|
|
135
|
+
*/
|
|
136
|
+
export function registerProcessGuard(logger: Logger) {
|
|
137
|
+
process.on("unhandledRejection", (reason) => {
|
|
138
|
+
terminate(logger, asWorkerError(reason, {
|
|
139
|
+
code: "RUNTIME_UNHANDLED_REJECTION",
|
|
140
|
+
title: "Unhandled promise rejections",
|
|
141
|
+
detail: "An unhandled promise rejection reach the process boundary.",
|
|
142
|
+
category: "runtime",
|
|
143
|
+
retryable: false,
|
|
144
|
+
fatal: true,
|
|
145
|
+
}), 1)
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
process.on("uncaughtException", (error) => {
|
|
149
|
+
terminate(logger, asWorkerError(error, {
|
|
150
|
+
code: "RUNTIME_UNCAUGHT_EXCEPTION",
|
|
151
|
+
title: "Uncaught exception",
|
|
152
|
+
detail: "An uncaught exception reached the process boundary.",
|
|
153
|
+
category: "runtime",
|
|
154
|
+
retryable: false,
|
|
155
|
+
fatal: true,
|
|
156
|
+
}), 1);
|
|
157
|
+
})
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export const workerLogger = getLogger({ component: "worker" });
|
|
161
|
+
export const outboxLogger = getLogger({ component: "outbox" });
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import type { ZodError } from "zod";
|
|
2
|
+
import type { $ZodIssue } from "zod/v4/core";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Structured worker-side validation issue retained in problem details and log
|
|
6
|
+
*/
|
|
7
|
+
export interface WorkerValidationIssue {
|
|
8
|
+
path: string;
|
|
9
|
+
param: string;
|
|
10
|
+
code: string;
|
|
11
|
+
message: string;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
function formatIssuePath(path: PropertyKey[]): string {
|
|
15
|
+
if (path.length === 0) {
|
|
16
|
+
return "<root>";
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
return path.reduce<string>((accumulator, segment) => {
|
|
20
|
+
if (typeof segment === "number") {
|
|
21
|
+
return `${accumulator}[${segment}]`;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
return accumulator.length > 0 ? `${accumulator}.${String(segment)}` : String(segment);
|
|
25
|
+
|
|
26
|
+
}, "");
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function toValidateIssue(issue: $ZodIssue) {
|
|
30
|
+
const path = formatIssuePath(issue.path);
|
|
31
|
+
return {
|
|
32
|
+
path,
|
|
33
|
+
param: path,
|
|
34
|
+
code: issue.code,
|
|
35
|
+
message: issue.message
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Converts a Zod validation error into a deterministic issue list for worker diagnostics.
|
|
41
|
+
*
|
|
42
|
+
* @param error - Validation error raised by worker configuration or protocol parsing.
|
|
43
|
+
* @returns Flat issue list with normalized parameter paths and messages.
|
|
44
|
+
*/
|
|
45
|
+
export function formatZodIssues(error: ZodError) {
|
|
46
|
+
return error.issues.map(toValidateIssue);
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Produces a compact summary for worker validation failures while preserving the full issue list.
|
|
51
|
+
*
|
|
52
|
+
* @param error - Validation error to summarize.
|
|
53
|
+
* @returns Human-readable summary for `WorkerProblemDetails.detail`.
|
|
54
|
+
*/
|
|
55
|
+
export function summarizeZodError(error: ZodError): string {
|
|
56
|
+
const issues = formatZodIssues(error);
|
|
57
|
+
if (issues.length === 0) {
|
|
58
|
+
return "Worker validation failed.";
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
if (issues.length === 1) {
|
|
62
|
+
const issue = issues[0];
|
|
63
|
+
if (!issue) {
|
|
64
|
+
return "Worker validation failed.";
|
|
65
|
+
};
|
|
66
|
+
return `${issue.param}: ${issue.message}`;
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
return `Worker validation failed with ${issues.length} issue(s)`;
|
|
70
|
+
};
|