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
package/.env.example
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# Compliance Engine Worker / Introspector
|
|
2
|
+
# The worker does need runtime configuration. In production, prefer Docker/Kubernetes secrets
|
|
3
|
+
# and compliance.worker.yml over a local .env file. This file is for local development only.
|
|
4
|
+
|
|
5
|
+
LOG_LEVEL=info
|
|
6
|
+
|
|
7
|
+
# Client application database. This is the database inside the client's VPC/VPS.
|
|
8
|
+
DATABASE_URL=postgres://dpdp:dpdp@127.0.0.1:55432/dpdp_local
|
|
9
|
+
|
|
10
|
+
# Control Plane worker endpoints.
|
|
11
|
+
API_CLIENT_ID=worker-1
|
|
12
|
+
API_WORKER_TOKEN=replace-with-worker-token
|
|
13
|
+
API_REQUEST_SIGNING_SECRET=replace-with-worker-request-signing-secret
|
|
14
|
+
API_SYNC_URL=http://127.0.0.1:3000/api/v1/worker/sync
|
|
15
|
+
API_BASE_URL=http://127.0.0.1:3000/api/v1/worker/tasks
|
|
16
|
+
API_OUTBOX_URL=http://127.0.0.1:3000/api/v1/worker/outbox
|
|
17
|
+
|
|
18
|
+
# Metrics and health server exposed by the worker.
|
|
19
|
+
METRICS_PORT=9464
|
|
20
|
+
|
|
21
|
+
# Legal notice delivery. Required for production notice dispatch.
|
|
22
|
+
MAILER_WEBHOOK_URL=http://127.0.0.1:18080/mail
|
|
23
|
+
MAILER_TIMEOUT_MS=10000
|
|
24
|
+
|
|
25
|
+
# Local crypto material. For production, use compliance.worker.yml key sources:
|
|
26
|
+
# env, file, aws_kms, gcp_secret_manager, or hashicorp_vault.
|
|
27
|
+
DPDP_MASTER_KEY=replace-with-32-byte-base64-key
|
|
28
|
+
DPDP_HMAC_KEY=replace-with-64-hex-or-base64-hmac-key
|
|
29
|
+
|
|
30
|
+
# Optional signed worker config verification.
|
|
31
|
+
DPDP_CONFIG_SIGNING_PUBLIC_KEY_SPKI_BASE64=
|
|
32
|
+
DPDP_CONFIG_SIGNATURE_PATH=./compliance.worker.yml.sig
|
|
33
|
+
|
|
34
|
+
# Optional AWS credentials for S3/blob purge and AWS KMS.
|
|
35
|
+
# Prefer ECS/EC2 IAM roles in production.
|
|
36
|
+
AWS_ACCESS_KEY_ID=
|
|
37
|
+
AWS_SECRET_ACCESS_KEY=
|
|
38
|
+
AWS_SESSION_TOKEN=
|
|
39
|
+
AWS_REGION=ap-south-1
|
|
40
|
+
AWS_EC2_METADATA_DISABLED=true
|
|
41
|
+
AWS_CONTAINER_CREDENTIALS_RELATIVE_URI=
|
|
42
|
+
AWS_CONTAINER_CREDENTIALS_FULL_URI=
|
|
43
|
+
AWS_CONTAINER_AUTHORIZATION_TOKEN=
|
|
44
|
+
AWS_CONTAINER_AUTHORIZATION_TOKEN_FILE=
|
|
45
|
+
|
|
46
|
+
# Optional Google Secret Manager provider.
|
|
47
|
+
GCP_ACCESS_TOKEN=
|
|
48
|
+
|
|
49
|
+
# Optional HashiCorp Vault provider.
|
|
50
|
+
VAULT_ADDR=
|
|
51
|
+
VAULT_TOKEN=
|
|
52
|
+
VAULT_NAMESPACE=
|
|
53
|
+
|
|
54
|
+
# Test runner override.
|
|
55
|
+
TEST_DATABASE_URL=postgres://dpdp:dpdp@127.0.0.1:55432/dpdp_local
|
package/Dockerfile
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# syntax=docker/dockerfile:1.7
|
|
2
|
+
|
|
3
|
+
ARG BUN_VERSION=1.3.12
|
|
4
|
+
|
|
5
|
+
FROM oven/bun:${BUN_VERSION}-slim AS deps
|
|
6
|
+
WORKDIR /workspace
|
|
7
|
+
|
|
8
|
+
COPY . .
|
|
9
|
+
RUN bun install --frozen-lockfile --production
|
|
10
|
+
|
|
11
|
+
FROM oven/bun:${BUN_VERSION}-slim AS verify
|
|
12
|
+
WORKDIR /workspace
|
|
13
|
+
|
|
14
|
+
COPY . .
|
|
15
|
+
RUN bun install --frozen-lockfile
|
|
16
|
+
|
|
17
|
+
RUN bun run worker:typecheck
|
|
18
|
+
|
|
19
|
+
FROM oven/bun:${BUN_VERSION}-slim AS runtime
|
|
20
|
+
WORKDIR /app
|
|
21
|
+
|
|
22
|
+
ENV NODE_ENV=production
|
|
23
|
+
ENV METRICS_PORT=9464
|
|
24
|
+
|
|
25
|
+
COPY --from=deps /workspace/node_modules /app/node_modules
|
|
26
|
+
COPY --from=deps /workspace/package.json /app/package.json
|
|
27
|
+
COPY --from=deps /workspace/tsconfig.json /app/tsconfig.json
|
|
28
|
+
COPY apps/worker /app/apps/worker
|
|
29
|
+
|
|
30
|
+
EXPOSE 9464
|
|
31
|
+
|
|
32
|
+
WORKDIR /app/apps/worker
|
|
33
|
+
ENTRYPOINT ["bun", "src/index.ts"]
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# Example `compliance.worker.yaml`
|
|
2
|
+
version: "1.0"
|
|
3
|
+
database:
|
|
4
|
+
app_schema: "mock_app"
|
|
5
|
+
engine_schema: "dpdp_engine"
|
|
6
|
+
|
|
7
|
+
compliance_policy:
|
|
8
|
+
default_retention_years: 0
|
|
9
|
+
notice_window_hours: 48
|
|
10
|
+
retention_rules:
|
|
11
|
+
- rule_name: "PMLA_FINANCIAL"
|
|
12
|
+
legal_citation: "Prevention of Money Laundering Act, 2002, Sec 12"
|
|
13
|
+
if_has_data_in:
|
|
14
|
+
- "transactions"
|
|
15
|
+
- "invoices"
|
|
16
|
+
retention_years: 10
|
|
17
|
+
- rule_name: "RBI_KYC"
|
|
18
|
+
legal_citation: "RBI KYC Directions, 2016, Sec 38"
|
|
19
|
+
if_has_data_in:
|
|
20
|
+
- "kyc_documents"
|
|
21
|
+
retention_years: 5
|
|
22
|
+
|
|
23
|
+
graph:
|
|
24
|
+
root_table: "users"
|
|
25
|
+
root_id_column: "id"
|
|
26
|
+
max_depth: 32
|
|
27
|
+
notice_email_column: "email"
|
|
28
|
+
notice_name_column: "full_name"
|
|
29
|
+
root_pii_columns:
|
|
30
|
+
email: "HMAC"
|
|
31
|
+
full_name: "STATIC_MASK"
|
|
32
|
+
|
|
33
|
+
satellite_targets:
|
|
34
|
+
- table: "marketing_leads"
|
|
35
|
+
lookup_column: "email"
|
|
36
|
+
action: "redact"
|
|
37
|
+
masking_rules:
|
|
38
|
+
email: "HMAC"
|
|
39
|
+
name: "STATIC_MASK"
|
|
40
|
+
- table: "system_audit_logs"
|
|
41
|
+
lookup_column: "user_identifier"
|
|
42
|
+
action: "hard_delete"
|
|
43
|
+
|
|
44
|
+
blob_targets: []
|
|
45
|
+
|
|
46
|
+
outbox:
|
|
47
|
+
batch_size: 10
|
|
48
|
+
lease_seconds: 60
|
|
49
|
+
max_attempts: 10
|
|
50
|
+
base_backoff_ms: 1000
|
|
51
|
+
|
|
52
|
+
security:
|
|
53
|
+
notification_lease_seconds: 120
|
|
54
|
+
master_key_env: "DPDP_MASTER_KEY"
|
|
55
|
+
hmac_key_env: "DPDP_HMAC_KEY"
|
|
56
|
+
|
|
57
|
+
integrity:
|
|
58
|
+
expected_schema_hash: "0000000000000000000000000000000000000000000000000000000000000000"
|
|
59
|
+
|
|
60
|
+
legal_attestation:
|
|
61
|
+
dpo_identifier: "dpo-name@client.com"
|
|
62
|
+
configuration_version: "v1.2.0"
|
|
63
|
+
legal_review_date: "2026-04-20"
|
|
64
|
+
acknowledgment: "I confirm this configuration accurately reflects our data retention obligations under DPDP Sec 12."
|
package/package.json
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "dpdp-erasure-cli",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"module": "index.ts",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"dpdp-cli": "./src/modules/cli/index.ts"
|
|
8
|
+
},
|
|
9
|
+
"publishConfig": {
|
|
10
|
+
"access": "public"
|
|
11
|
+
},
|
|
12
|
+
"scripts": {
|
|
13
|
+
"dev": "bun src/index.ts",
|
|
14
|
+
"typecheck": "tsc --noEmit",
|
|
15
|
+
"cli": "bun src/modules/cli/index.ts",
|
|
16
|
+
"test": "vitest run --fileParallelism=false",
|
|
17
|
+
"test:ui": "vitest run --ui"
|
|
18
|
+
},
|
|
19
|
+
"devDependencies": {
|
|
20
|
+
"@types/bun": "latest",
|
|
21
|
+
"@types/js-yaml": "^4.0.9",
|
|
22
|
+
"@types/pino": "^7.0.5",
|
|
23
|
+
"@vitest/ui": "4.1.5",
|
|
24
|
+
"vitest": "^4.1.5"
|
|
25
|
+
},
|
|
26
|
+
"peerDependencies": {
|
|
27
|
+
"typescript": "^5"
|
|
28
|
+
},
|
|
29
|
+
"dependencies": {
|
|
30
|
+
"@inquirer/prompts": "^8.5.2",
|
|
31
|
+
"boxen": "^8.0.1",
|
|
32
|
+
"cli-table3": "^0.6.5",
|
|
33
|
+
"commander": "^15.0.0",
|
|
34
|
+
"js-yaml": "^4.1.1",
|
|
35
|
+
"ora": "^9.4.0",
|
|
36
|
+
"picocolors": "^1.1.1",
|
|
37
|
+
"pino": "^10.3.1",
|
|
38
|
+
"postgres": "^3.4.9",
|
|
39
|
+
"zod": "^4.4.2"
|
|
40
|
+
}
|
|
41
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export const MAX_DEPTH = 32;
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { ERROR_REGISTRY, type ErrorCodeType } from "./registry";
|
|
2
|
+
import { normalizeErrorType, WorkerError } from "./worker";
|
|
3
|
+
import type { WorkerErrorContext, WorkerErrorCategory, RegistryEntry } from "./types";
|
|
4
|
+
import { type WorkerValidationIssue } from "@/validation/zod";
|
|
5
|
+
|
|
6
|
+
type ExtractBaseCode<T extends string> = T extends ErrorCodeType
|
|
7
|
+
? T
|
|
8
|
+
: T extends `${string}_${infer Base extends ErrorCodeType}`
|
|
9
|
+
? Base
|
|
10
|
+
: never;
|
|
11
|
+
|
|
12
|
+
type DetailParams<Base extends ErrorCodeType> =
|
|
13
|
+
typeof ERROR_REGISTRY[Base] extends { detail: (...args: infer P) => string }
|
|
14
|
+
? P
|
|
15
|
+
: never;
|
|
16
|
+
|
|
17
|
+
type InferData<Base extends ErrorCodeType> =
|
|
18
|
+
DetailParams<Base> extends [infer D, ...any[]]
|
|
19
|
+
? D
|
|
20
|
+
: never;
|
|
21
|
+
|
|
22
|
+
type IsDataRequired<Base extends ErrorCodeType> = DetailParams<Base> extends []
|
|
23
|
+
? false
|
|
24
|
+
: DetailParams<Base> extends [never]
|
|
25
|
+
? false
|
|
26
|
+
: true;
|
|
27
|
+
|
|
28
|
+
type FailOptions<T extends string> = {
|
|
29
|
+
code: T;
|
|
30
|
+
title?: string;
|
|
31
|
+
category?: WorkerErrorCategory;
|
|
32
|
+
retryable?: boolean;
|
|
33
|
+
fatal?: boolean;
|
|
34
|
+
cause?: unknown;
|
|
35
|
+
context?: WorkerErrorContext | null;
|
|
36
|
+
issues?: WorkerValidationIssue[] | null;
|
|
37
|
+
} & (
|
|
38
|
+
[ExtractBaseCode<T>] extends [infer Base extends ErrorCodeType]
|
|
39
|
+
? (
|
|
40
|
+
| { detail: string; data?: InferData<Base> }
|
|
41
|
+
| (IsDataRequired<Base> extends true
|
|
42
|
+
? { detail?: never; data: InferData<Base> }
|
|
43
|
+
: { detail?: never; data?: InferData<Base> })
|
|
44
|
+
)
|
|
45
|
+
: {
|
|
46
|
+
title: string;
|
|
47
|
+
detail: string;
|
|
48
|
+
category: WorkerErrorCategory;
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
export function fail<T extends string>(options: FailOptions<T>): never {
|
|
52
|
+
const { code, title, category, retryable, fatal, cause, context, issues } = options;
|
|
53
|
+
|
|
54
|
+
let meta: RegistryEntry | undefined = ERROR_REGISTRY[code as ErrorCodeType];
|
|
55
|
+
let baseCode = code as string;
|
|
56
|
+
|
|
57
|
+
if (!meta) {
|
|
58
|
+
// Isolate the longest valid registry suffix matching the prefixed string
|
|
59
|
+
const baseMatch = (Object.keys(ERROR_REGISTRY) as ErrorCodeType[])
|
|
60
|
+
.filter((k) => code.endsWith(`_${k}`))
|
|
61
|
+
.sort((a, b) => b.length - a.length)[0];
|
|
62
|
+
|
|
63
|
+
if (baseMatch) {
|
|
64
|
+
meta = ERROR_REGISTRY[baseMatch];
|
|
65
|
+
baseCode = baseMatch;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (meta) {
|
|
70
|
+
let resolvedDetail: string;
|
|
71
|
+
|
|
72
|
+
if ('detail' in options && typeof options.detail === 'string') {
|
|
73
|
+
resolvedDetail = options.detail;
|
|
74
|
+
} else {
|
|
75
|
+
const targetDetail = 'detail' in meta ? meta.detail : undefined;
|
|
76
|
+
const inputData = (options as any).data ?? {};
|
|
77
|
+
|
|
78
|
+
resolvedDetail = typeof targetDetail === 'function'
|
|
79
|
+
? (targetDetail as Function)(inputData)
|
|
80
|
+
: String(targetDetail);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
throw new WorkerError({
|
|
84
|
+
code,
|
|
85
|
+
type: normalizeErrorType(baseCode),
|
|
86
|
+
title: title ?? meta.title,
|
|
87
|
+
category: category ?? meta.category,
|
|
88
|
+
fatal: fatal ?? meta.fatal,
|
|
89
|
+
retryable: retryable ?? meta.retryable,
|
|
90
|
+
detail: resolvedDetail,
|
|
91
|
+
cause: cause ?? null,
|
|
92
|
+
context: context ?? null,
|
|
93
|
+
issues: issues ?? null,
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Pure ad-hoc custom string signature layout engine
|
|
98
|
+
throw new WorkerError({
|
|
99
|
+
code,
|
|
100
|
+
type: normalizeErrorType(baseCode),
|
|
101
|
+
title: title ?? "Unhandled Application Fault",
|
|
102
|
+
category: category ?? "internal",
|
|
103
|
+
detail: (options as any).detail ?? "An unexpected runtime failure was triggered.",
|
|
104
|
+
retryable: retryable ?? false,
|
|
105
|
+
fatal: fatal ?? true,
|
|
106
|
+
cause: cause ?? null,
|
|
107
|
+
context: context ?? null,
|
|
108
|
+
issues: issues ?? null,
|
|
109
|
+
});
|
|
110
|
+
}
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import { ZodError } from "zod";
|
|
2
|
+
import type {
|
|
3
|
+
WorkerErrorCode,
|
|
4
|
+
WorkerErrorFallback,
|
|
5
|
+
WorkerErrorCategory,
|
|
6
|
+
} from "./types";
|
|
7
|
+
import { WorkerError } from "./worker";
|
|
8
|
+
import { summarizeZodError } from "@/validation/zod";
|
|
9
|
+
|
|
10
|
+
const RETRYABLE_POSTGRES_CODES = new Set([
|
|
11
|
+
"40001", // serialization_failure
|
|
12
|
+
"40P01", // deadlock_detected
|
|
13
|
+
"55P03", // lock_not_available
|
|
14
|
+
"57014", // query_canceled
|
|
15
|
+
"57P01", // admin_shutdown
|
|
16
|
+
"57P02", // crash_shutdown
|
|
17
|
+
"57P03", // cannot_connect_now
|
|
18
|
+
]);
|
|
19
|
+
|
|
20
|
+
function isAbortLikeError(value: unknown): value is Error {
|
|
21
|
+
return value instanceof Error && (value.name === "AbortError" || value.name === "TimeoutError");
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function isPostgresError(value: unknown): value is Error & { code: string } {
|
|
25
|
+
return value instanceof Error && typeof (value as { code?: unknown }).code === "string";
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function inferRetryability(error: unknown, fallback?: WorkerErrorFallback): boolean {
|
|
29
|
+
if (fallback?.retryable !== undefined) {
|
|
30
|
+
return fallback.retryable;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
if (isAbortLikeError(error)) {
|
|
34
|
+
return true;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
if (isPostgresError(error)) {
|
|
38
|
+
if (error.code.startsWith("08")) {
|
|
39
|
+
return true;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return RETRYABLE_POSTGRES_CODES.has(error.code);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return false;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function inferCategory(error: unknown, fallback?: WorkerErrorFallback): WorkerErrorCategory {
|
|
49
|
+
if (fallback?.category) {
|
|
50
|
+
return fallback.category;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
if (error instanceof ZodError) {
|
|
54
|
+
return "validation";
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (isAbortLikeError(error)) {
|
|
58
|
+
return "network";
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (isPostgresError(error)) {
|
|
62
|
+
if (error.code === "40001" || error.code === "40P01" || error.code === "55P03") {
|
|
63
|
+
return "concurrency";
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (error.code.startsWith("08") || error.code.startsWith("57")) {
|
|
67
|
+
return "database";
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return "database";
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return "internal";
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function inferFatal(error: unknown, fallback?: WorkerErrorFallback): boolean {
|
|
77
|
+
if (fallback?.fatal !== undefined) {
|
|
78
|
+
return fallback.fatal;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if (error instanceof WorkerError) {
|
|
82
|
+
return error.fatal;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return false;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function inferTitle(error: unknown, fallback?: WorkerErrorFallback): string {
|
|
89
|
+
if (fallback?.title) {
|
|
90
|
+
return fallback.title;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (error instanceof ZodError) {
|
|
94
|
+
return "Validation failed";
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
if (isAbortLikeError(error)) {
|
|
98
|
+
return "Network operation timed out";
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (isPostgresError(error) && error.code === "40001") {
|
|
102
|
+
return "Serialization failure";
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (error instanceof Error && error.name) {
|
|
106
|
+
return error.name;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return "Unexpected worker error";
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export function inferDetail(error: unknown, fallback?: WorkerErrorFallback): string {
|
|
113
|
+
if (fallback?.detail) {
|
|
114
|
+
return fallback.detail;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
if (error instanceof ZodError) {
|
|
118
|
+
return summarizeZodError(error);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
if (error instanceof Error && error.message.trim().length > 0) {
|
|
122
|
+
return error.message;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return "An unexpected worker error occurred.";
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export function inferCode(error: unknown, fallback?: WorkerErrorFallback): WorkerErrorCode {
|
|
129
|
+
if (fallback?.code) {
|
|
130
|
+
return fallback.code;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
if (error instanceof WorkerError) {
|
|
134
|
+
return error.code;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
if (error instanceof ZodError) {
|
|
138
|
+
return "VALIDATION_FAILED";
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
if (isAbortLikeError(error)) {
|
|
142
|
+
return "NETWORK_TIMEOUT";
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
if (isPostgresError(error)) {
|
|
146
|
+
if (error.code === "40001") {
|
|
147
|
+
return "DB_SERIALIZATION_FAILURE";
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
if (error.code === "40P01") {
|
|
151
|
+
return "DB_DEADLOCK_DETECTED";
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
if (error.code === "55P03") {
|
|
155
|
+
return "DB_LOCK_NOT_AVAILABLE";
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
if (error.code.startsWith("08")) {
|
|
159
|
+
return "DB_CONNECTION_ERROR";
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
return "DB_ERROR";
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
return "INTERNAL_UNEXPECTED";
|
|
166
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import type { RegistryEntry, WorkerErrorCategory } from "./types";
|
|
2
|
+
|
|
3
|
+
export type ErrorCodeType = keyof typeof ERROR_REGISTRY;
|
|
4
|
+
|
|
5
|
+
export interface ErrorRegistryTypes {
|
|
6
|
+
title: string;
|
|
7
|
+
detail: (data: unknown) => string;
|
|
8
|
+
category: WorkerErrorCategory;
|
|
9
|
+
retryable: boolean;
|
|
10
|
+
fatal: boolean;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Centralized Error Registry
|
|
15
|
+
*/
|
|
16
|
+
export const ERROR_REGISTRY = {
|
|
17
|
+
UNREGISTERED_ERROR_CODE: {
|
|
18
|
+
title: "Unknown Error",
|
|
19
|
+
detail: (data: { code: string }) => `An unregistered error occurred: ${data.code}`,
|
|
20
|
+
category: "internal",
|
|
21
|
+
retryable: false,
|
|
22
|
+
fatal: true,
|
|
23
|
+
},
|
|
24
|
+
CONFIG_SIGNATURE_MISSING: {
|
|
25
|
+
title: "Missing worker config signature",
|
|
26
|
+
detail: (data: { value: string }) =>
|
|
27
|
+
`Detached config signature ${data.value} is required when config signing is enabled.`,
|
|
28
|
+
category: "configuration",
|
|
29
|
+
retryable: false,
|
|
30
|
+
fatal: true,
|
|
31
|
+
},
|
|
32
|
+
CONFIG_SIGNATURE_INVALID: {
|
|
33
|
+
title: "Invalid worker config signature",
|
|
34
|
+
detail: (data: { value: string }) => `Detached config signature ${data.value} failed verification.`,
|
|
35
|
+
category: "integrity",
|
|
36
|
+
retryable: false,
|
|
37
|
+
fatal: true,
|
|
38
|
+
},
|
|
39
|
+
SECRET_ENV_MISSING: {
|
|
40
|
+
title: "Required secret is missing",
|
|
41
|
+
detail: (data: { keyName: string }) => `${data.keyName} is required.`,
|
|
42
|
+
category: "configuration",
|
|
43
|
+
retryable: false,
|
|
44
|
+
fatal: true,
|
|
45
|
+
},
|
|
46
|
+
SECRET_ENV_INVALID: {
|
|
47
|
+
title: "Invalid secret format",
|
|
48
|
+
detail: (data: { keyName: string, KEY_LENGTH: number }) =>
|
|
49
|
+
`${data.keyName} must resolve to exactly ${data.KEY_LENGTH} bytes. Supported formats: 64-char hex or base64.`,
|
|
50
|
+
category: "configuration",
|
|
51
|
+
retryable: false,
|
|
52
|
+
fatal: true,
|
|
53
|
+
},
|
|
54
|
+
KMS_SECRET_MISSING: {
|
|
55
|
+
title: "Runtime secret is missing",
|
|
56
|
+
category: "configuration",
|
|
57
|
+
retryable: false,
|
|
58
|
+
fatal: true,
|
|
59
|
+
},
|
|
60
|
+
KMS_RESPONSE_INVALID: {
|
|
61
|
+
title: "Key provider response invalid",
|
|
62
|
+
category: "external",
|
|
63
|
+
retryable: false,
|
|
64
|
+
fatal: true,
|
|
65
|
+
},
|
|
66
|
+
VAULT_NOT_FOUND: {
|
|
67
|
+
title: "Vault record not found",
|
|
68
|
+
detail: (
|
|
69
|
+
data: {
|
|
70
|
+
appSchema: string,
|
|
71
|
+
rootTable: string,
|
|
72
|
+
normalizedSubjectId: string
|
|
73
|
+
}
|
|
74
|
+
) => `Vault record not found for ${data.appSchema}.${data.rootTable}#${data.normalizedSubjectId}.`,
|
|
75
|
+
category: "validation",
|
|
76
|
+
retryable: false,
|
|
77
|
+
fatal: false
|
|
78
|
+
},
|
|
79
|
+
USER_ID_INVALID: {
|
|
80
|
+
title: "Invalid root identifier",
|
|
81
|
+
detail: () => "subjectId must be a non-empty string or number.",
|
|
82
|
+
category: "validation",
|
|
83
|
+
retryable: false,
|
|
84
|
+
},
|
|
85
|
+
OUTBOX_LEASE_LOST: {
|
|
86
|
+
title: "Outbox lease lost",
|
|
87
|
+
category: "concurrency",
|
|
88
|
+
retryable: true,
|
|
89
|
+
},
|
|
90
|
+
BLOB_URL_INVALID: {
|
|
91
|
+
title: "Invalid S3 URL",
|
|
92
|
+
category: "validation",
|
|
93
|
+
retryable: false,
|
|
94
|
+
},
|
|
95
|
+
BLOB_URL_UNSUPPORTED: {
|
|
96
|
+
title: "Unsupported blob URL protocol",
|
|
97
|
+
category: "validation",
|
|
98
|
+
retryable: false,
|
|
99
|
+
},
|
|
100
|
+
AWS_CREDENTIALS_INVALID: {
|
|
101
|
+
title: "AWS credentials invalid",
|
|
102
|
+
category: "configuration",
|
|
103
|
+
retryable: false,
|
|
104
|
+
fatal: true,
|
|
105
|
+
},
|
|
106
|
+
AWS_CREDENTIALS_UNAVAILABLE: {
|
|
107
|
+
title: "AWS IMDS unavailable",
|
|
108
|
+
category: "configuration",
|
|
109
|
+
retryable: true,
|
|
110
|
+
fatal: false,
|
|
111
|
+
}
|
|
112
|
+
} as const satisfies Record<string, RegistryEntry>;
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Type-safe map for error codes derived from @constant {ERROR_REGISTRY}
|
|
116
|
+
* It standardizes the code without writing it manually every time
|
|
117
|
+
*
|
|
118
|
+
* @example fail({ code: CODE.CONFIG_SIGNATURE_MISSING });
|
|
119
|
+
*/
|
|
120
|
+
export const CODE = Object.fromEntries(
|
|
121
|
+
Object.keys(ERROR_REGISTRY).map(k => [k, k])
|
|
122
|
+
) as { [k in keyof typeof ERROR_REGISTRY]: k }
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { type WorkerValidationIssue } from "@/validation/zod";
|
|
2
|
+
|
|
3
|
+
export type WorkerErrorCode = string;
|
|
4
|
+
|
|
5
|
+
export type WorkerErrorCategory =
|
|
6
|
+
| "configuration"
|
|
7
|
+
| "validation"
|
|
8
|
+
| "integrity"
|
|
9
|
+
| "concurrency"
|
|
10
|
+
| "database"
|
|
11
|
+
| "network"
|
|
12
|
+
| "crypto"
|
|
13
|
+
| "runtime"
|
|
14
|
+
| "external"
|
|
15
|
+
| "internal";
|
|
16
|
+
|
|
17
|
+
export interface WorkerErrorContext {
|
|
18
|
+
[key: string]: unknown
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export interface WorkerProblemDetails {
|
|
22
|
+
type: string;
|
|
23
|
+
title: string;
|
|
24
|
+
detail: string;
|
|
25
|
+
code: WorkerErrorCode;
|
|
26
|
+
category: WorkerErrorCategory;
|
|
27
|
+
retryable: boolean;
|
|
28
|
+
fatal: boolean;
|
|
29
|
+
instance?: string;
|
|
30
|
+
context?: WorkerErrorContext;
|
|
31
|
+
issues?: WorkerValidationIssue[];
|
|
32
|
+
cause?: WorkerProblemDetails;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface WorkerErrorOptions {
|
|
36
|
+
code: WorkerErrorCode;
|
|
37
|
+
title: string;
|
|
38
|
+
detail: string;
|
|
39
|
+
category: WorkerErrorCategory;
|
|
40
|
+
retryable?: boolean;
|
|
41
|
+
fatal?: boolean;
|
|
42
|
+
context?: WorkerErrorContext | null;
|
|
43
|
+
issues?: WorkerValidationIssue[] | null;
|
|
44
|
+
cause?: unknown;
|
|
45
|
+
type?: string;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface WorkerErrorFallback {
|
|
49
|
+
code?: WorkerErrorCode;
|
|
50
|
+
title?: string;
|
|
51
|
+
detail?: string;
|
|
52
|
+
category?: WorkerErrorCategory;
|
|
53
|
+
retryable?: boolean;
|
|
54
|
+
fatal?: boolean;
|
|
55
|
+
context?: WorkerErrorContext;
|
|
56
|
+
issues?: WorkerValidationIssue[];
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface RegistryEntry<T = any> {
|
|
60
|
+
title: string;
|
|
61
|
+
detail?: (data: T) => string;
|
|
62
|
+
category: WorkerErrorCategory;
|
|
63
|
+
retryable: boolean;
|
|
64
|
+
fatal?: boolean;
|
|
65
|
+
};
|