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,190 @@
|
|
|
1
|
+
import { bytesToHex } from "@/lib";
|
|
2
|
+
import type { S3AwsCredentials } from "./type";
|
|
3
|
+
|
|
4
|
+
const textEncoder = new TextEncoder();
|
|
5
|
+
const signingKeyCache = new Map<string, Promise<CryptoKey>>();
|
|
6
|
+
const MAX_SIGNING_KEY_CACHE_ENTRIES = 256;
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
export interface AwsSignedRequestInput {
|
|
10
|
+
method: string;
|
|
11
|
+
url: URL;
|
|
12
|
+
region: string;
|
|
13
|
+
service: string;
|
|
14
|
+
headers: Headers;
|
|
15
|
+
body?: Uint8Array | string;
|
|
16
|
+
credentials: S3AwsCredentials;
|
|
17
|
+
now?: Date;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function copyBytes(bytes: Uint8Array): Uint8Array {
|
|
21
|
+
const copy = new Uint8Array(bytes.length);
|
|
22
|
+
copy.set(bytes);
|
|
23
|
+
return copy;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function encodeBody(body: Uint8Array | string | undefined): Uint8Array {
|
|
27
|
+
if (body === undefined) {
|
|
28
|
+
return new Uint8Array(0);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
return typeof body === "string" ? copyBytes(textEncoder.encode(body)) : copyBytes(body);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async function sha256Hex(input: Uint8Array | string): Promise<string> {
|
|
35
|
+
const bytes = typeof input === "string" ? textEncoder.encode(input) : input;
|
|
36
|
+
const digest = await globalThis.crypto.subtle.digest("SHA-256", bytes.slice().buffer as ArrayBuffer);
|
|
37
|
+
return bytesToHex(new Uint8Array(digest));
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async function hmacSha256(key: Uint8Array, value: string): Promise<Uint8Array> {
|
|
41
|
+
const keyBytes = key.slice();
|
|
42
|
+
const cryptoKey = await globalThis.crypto.subtle.importKey(
|
|
43
|
+
"raw",
|
|
44
|
+
keyBytes.buffer as ArrayBuffer,
|
|
45
|
+
{ name: "HMAC", hash: "SHA-256" },
|
|
46
|
+
false,
|
|
47
|
+
["sign"]
|
|
48
|
+
);
|
|
49
|
+
const data = textEncoder.encode(value).slice();
|
|
50
|
+
const signature = await globalThis.crypto.subtle.sign("HMAC", cryptoKey, data.buffer as ArrayBuffer);
|
|
51
|
+
return new Uint8Array(signature);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async function hmacSha256WithKey(key: CryptoKey, value: string): Promise<Uint8Array> {
|
|
55
|
+
const data = textEncoder.encode(value);
|
|
56
|
+
const signature = await globalThis.crypto.subtle.sign("HMAC", key, data);
|
|
57
|
+
return new Uint8Array(signature);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function signingCacheKey(input: AwsSignedRequestInput, dateStamp: string): string {
|
|
61
|
+
return [
|
|
62
|
+
input.credentials.accessKeyId,
|
|
63
|
+
input.credentials.sessionToken ?? "",
|
|
64
|
+
input.credentials.expiration?.getTime() ?? "",
|
|
65
|
+
dateStamp,
|
|
66
|
+
input.region,
|
|
67
|
+
input.service,
|
|
68
|
+
].join("|");
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async function importSigningKey(bytes: Uint8Array): Promise<CryptoKey> {
|
|
72
|
+
const keyBytes = bytes.slice();
|
|
73
|
+
try {
|
|
74
|
+
return await globalThis.crypto.subtle.importKey(
|
|
75
|
+
"raw",
|
|
76
|
+
keyBytes,
|
|
77
|
+
{ name: "HMAC", hash: "SHA-256" },
|
|
78
|
+
false,
|
|
79
|
+
["sign"]
|
|
80
|
+
);
|
|
81
|
+
} finally {
|
|
82
|
+
keyBytes.fill(0);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async function deriveSigningKey(input: AwsSignedRequestInput, dateStamp: string): Promise<CryptoKey> {
|
|
87
|
+
const cacheKey = signingCacheKey(input, dateStamp);
|
|
88
|
+
const cached = signingKeyCache.get(cacheKey);
|
|
89
|
+
if (cached) {
|
|
90
|
+
return cached;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (signingKeyCache.size >= MAX_SIGNING_KEY_CACHE_ENTRIES) {
|
|
94
|
+
signingKeyCache.delete(signingKeyCache.keys().next().value as string);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const derived = (async () => {
|
|
98
|
+
const secretSeed = textEncoder.encode(`AWS4${input.credentials.secretAccessKey}`);
|
|
99
|
+
let dateKey: Uint8Array = new Uint8Array(0);
|
|
100
|
+
let regionKey: Uint8Array = new Uint8Array(0);
|
|
101
|
+
let serviceKey: Uint8Array = new Uint8Array(0);
|
|
102
|
+
let signingKey: Uint8Array = new Uint8Array(0);
|
|
103
|
+
try {
|
|
104
|
+
dateKey = await hmacSha256(secretSeed, dateStamp);
|
|
105
|
+
regionKey = await hmacSha256(dateKey, input.region);
|
|
106
|
+
serviceKey = await hmacSha256(regionKey, input.service);
|
|
107
|
+
signingKey = await hmacSha256(serviceKey, "aws4_request");
|
|
108
|
+
return importSigningKey(signingKey);
|
|
109
|
+
} finally {
|
|
110
|
+
secretSeed.fill(0);
|
|
111
|
+
dateKey.fill(0);
|
|
112
|
+
regionKey.fill(0);
|
|
113
|
+
serviceKey.fill(0);
|
|
114
|
+
signingKey.fill(0);
|
|
115
|
+
}
|
|
116
|
+
})();
|
|
117
|
+
signingKeyCache.set(cacheKey, derived);
|
|
118
|
+
return derived;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function buildCanonicalQuery(searchParams: URLSearchParams): string {
|
|
122
|
+
return Array.from(searchParams.entries())
|
|
123
|
+
.sort(([leftName, leftValue], [rightName, rightValue]) => {
|
|
124
|
+
const byName = leftName.localeCompare(rightName);
|
|
125
|
+
return byName === 0 ? leftValue.localeCompare(rightValue) : byName;
|
|
126
|
+
})
|
|
127
|
+
.map(([name, value]) => `${encodeURIComponent(name)}=${encodeURIComponent(value)}`)
|
|
128
|
+
.join("&");
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function normalizeAmzDate(now: Date): { amzDate: string; dateStamp: string } {
|
|
132
|
+
const amzDate = now.toISOString().replace(/[:-]|\.\d{3}/g, "");
|
|
133
|
+
return { amzDate, dateStamp: amzDate.slice(0, 8) };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Signs an AWS REST request with Signature Version 4 using Web Crypto HMAC-SHA256.
|
|
138
|
+
*
|
|
139
|
+
* @param input - Request method, URL, headers, body, service, region, and credentials.
|
|
140
|
+
* @returns Headers containing SigV4 authorization fields.
|
|
141
|
+
*/
|
|
142
|
+
export async function signAwsRequest(input: AwsSignedRequestInput): Promise<Headers> {
|
|
143
|
+
const bodyBytes = encodeBody(input.body);
|
|
144
|
+
const payloadHash = await sha256Hex(bodyBytes);
|
|
145
|
+
const { amzDate, dateStamp } = normalizeAmzDate(input.now ?? new Date());
|
|
146
|
+
const headers = new Headers(input.headers);
|
|
147
|
+
|
|
148
|
+
headers.set("host", input.url.host);
|
|
149
|
+
headers.set("x-amz-content-sha256", payloadHash);
|
|
150
|
+
headers.set("x-amz-date", amzDate);
|
|
151
|
+
if (input.credentials.sessionToken) {
|
|
152
|
+
headers.set("x-amz-security-token", input.credentials.sessionToken);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const sortedHeaders = Array.from(headers.entries())
|
|
156
|
+
.map(([name, value]) => [name.toLowerCase(), value.trim().replace(/\s+/g, " ")] as const)
|
|
157
|
+
.sort(([left], [right]) => left.localeCompare(right));
|
|
158
|
+
const canonicalHeaders = sortedHeaders.map(([name, value]) => `${name}:${value}\n`).join("");
|
|
159
|
+
const signedHeaders = sortedHeaders.map(([name]) => name).join(";");
|
|
160
|
+
const canonicalRequest = [
|
|
161
|
+
input.method.toUpperCase(),
|
|
162
|
+
input.url.pathname || "/",
|
|
163
|
+
buildCanonicalQuery(input.url.searchParams),
|
|
164
|
+
canonicalHeaders,
|
|
165
|
+
signedHeaders,
|
|
166
|
+
payloadHash,
|
|
167
|
+
].join("\n");
|
|
168
|
+
|
|
169
|
+
const credentialScope = `${dateStamp}/${input.region}/${input.service}/aws4_request`;
|
|
170
|
+
const stringToSign = [
|
|
171
|
+
"AWS4-HMAC-SHA256",
|
|
172
|
+
amzDate,
|
|
173
|
+
credentialScope,
|
|
174
|
+
await sha256Hex(canonicalRequest),
|
|
175
|
+
].join("\n");
|
|
176
|
+
|
|
177
|
+
let signatureBytes: Uint8Array = new Uint8Array(0);
|
|
178
|
+
|
|
179
|
+
try {
|
|
180
|
+
const signingKey = await deriveSigningKey(input, dateStamp);
|
|
181
|
+
signatureBytes = await hmacSha256WithKey(signingKey, stringToSign);
|
|
182
|
+
headers.set(
|
|
183
|
+
"authorization",
|
|
184
|
+
`AWS4-HMAC-SHA256 Credential=${input.credentials.accessKeyId}/${credentialScope}, SignedHeaders=${signedHeaders}, Signature=${bytesToHex(signatureBytes)}`
|
|
185
|
+
);
|
|
186
|
+
return headers;
|
|
187
|
+
} finally {
|
|
188
|
+
signatureBytes.fill(0);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from "./aws";
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
import { outboxLogger } from "@/utils";
|
|
2
|
+
import { fail, workerError } from "@/errors";
|
|
3
|
+
import { computeRequestSignature } from "../request-signing";
|
|
4
|
+
import type { FetchDispatcherOptions, OutboxEvent } from "./types";
|
|
5
|
+
|
|
6
|
+
interface ControlPlaneOutboxPayload {
|
|
7
|
+
request_id?: string | null;
|
|
8
|
+
subject_opaque_id?: string | null;
|
|
9
|
+
event_timestamp?: string | null;
|
|
10
|
+
[key: string]: unknown;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
function isRetryableProblemBody(body: unknown): boolean {
|
|
15
|
+
if (!body || typeof body !== "object") {
|
|
16
|
+
return false;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const record = body as Record<string, unknown>;
|
|
20
|
+
if (record.retryable === true || record.code === "API_OUTBOX_PREVIOUS_HASH_INVALID") {
|
|
21
|
+
return true;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const error = record.error;
|
|
25
|
+
if (!error || typeof error !== "object") {
|
|
26
|
+
return false;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const errorRecord = error as Record<string, unknown>;
|
|
30
|
+
return errorRecord.retryable === true || errorRecord.code === "API_OUTBOX_PREVIOUS_HASH_INVALID";
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async function readRetryableProblem(response: Response): Promise<boolean> {
|
|
34
|
+
try {
|
|
35
|
+
const body = await response.clone().json() as unknown;
|
|
36
|
+
return isRetryableProblemBody(body);
|
|
37
|
+
} catch {
|
|
38
|
+
return false;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function buildControlPlaneRequestBody(event: OutboxEvent) {
|
|
43
|
+
if (!event.payload || typeof event.payload !== "object" || Array.isArray(event.payload)) {
|
|
44
|
+
fail({
|
|
45
|
+
code: "OUTBOX_PAYLOAD_INVALID",
|
|
46
|
+
title: "Invalid outbox payload",
|
|
47
|
+
detail: `Outbox payload for event ${event.id} must be an object.`,
|
|
48
|
+
category: "integrity",
|
|
49
|
+
retryable: false,
|
|
50
|
+
fatal: true,
|
|
51
|
+
context: { eventId: event.id },
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const payload = event.payload as ControlPlaneOutboxPayload;
|
|
56
|
+
if (!payload.request_id || !payload.subject_opaque_id || !payload.event_timestamp) {
|
|
57
|
+
fail({
|
|
58
|
+
code: "OUTBOX_PROTOCOL_REJECTED",
|
|
59
|
+
title: "Outbox payload missing control-plane envelope",
|
|
60
|
+
detail: `Outbox event ${event.id} is missing request_id, subject_opaque_id, or event_timestamp.`,
|
|
61
|
+
category: "integrity",
|
|
62
|
+
retryable: false,
|
|
63
|
+
fatal: true,
|
|
64
|
+
context: { eventId: event.id },
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return {
|
|
69
|
+
idempotency_key: event.idempotency_key,
|
|
70
|
+
request_id: payload.request_id,
|
|
71
|
+
subject_opaque_id: payload.subject_opaque_id,
|
|
72
|
+
event_type: event.event_type,
|
|
73
|
+
payload,
|
|
74
|
+
previous_hash: event.previous_hash,
|
|
75
|
+
current_hash: event.current_hash,
|
|
76
|
+
event_timestamp: payload.event_timestamp,
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* No-op dispatcher used by tests and local execution when no HTTP transport is injected.
|
|
82
|
+
*
|
|
83
|
+
* @param event - Outbox event to "send".
|
|
84
|
+
* @returns Always `true` after logging.
|
|
85
|
+
*/
|
|
86
|
+
export async function sendToAPI(event: OutboxEvent): Promise<boolean> {
|
|
87
|
+
outboxLogger.info({ eventId: event.id, eventType: event.event_type }, "Outbox event synced");
|
|
88
|
+
return true;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Creates an HTTP dispatcher that publishes worker outbox events to the Control Plane.
|
|
93
|
+
*
|
|
94
|
+
* @param options - Endpoint URL, auth headers, and timeout configuration.
|
|
95
|
+
* @returns Dispatcher function compatible with `processOutbox`.
|
|
96
|
+
*/
|
|
97
|
+
export function createFetchDispatcher(options: FetchDispatcherOptions) {
|
|
98
|
+
const timeoutMs = options.timeoutMs ?? 10_000;
|
|
99
|
+
|
|
100
|
+
return async function dispatch(event: OutboxEvent): Promise<boolean> {
|
|
101
|
+
const controller = new AbortController();
|
|
102
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
103
|
+
const body = buildControlPlaneRequestBody(event);
|
|
104
|
+
|
|
105
|
+
try {
|
|
106
|
+
const bodyText = JSON.stringify(body);
|
|
107
|
+
const requestSigningSecret = (options as FetchDispatcherOptions).requestSigningSecret;
|
|
108
|
+
const clientId = options.clientId ?? fail({
|
|
109
|
+
code: "DISPATCHER_CONFIG_INVALID",
|
|
110
|
+
title: "Missing Client Identifier",
|
|
111
|
+
detail: "Cannot create fetch dispatcher because clientId is missing from options.",
|
|
112
|
+
category: "integrity",
|
|
113
|
+
retryable: false,
|
|
114
|
+
fatal: true,
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
const signingHeaders = requestSigningSecret
|
|
118
|
+
? (async () => {
|
|
119
|
+
const timestamp = String(Date.now());
|
|
120
|
+
return await computeRequestSignature(
|
|
121
|
+
requestSigningSecret,
|
|
122
|
+
"POST",
|
|
123
|
+
new URL(options.url).pathname,
|
|
124
|
+
clientId,
|
|
125
|
+
timestamp,
|
|
126
|
+
bodyText
|
|
127
|
+
).then((signature) => ({
|
|
128
|
+
"x-dpdp-timestamp": timestamp,
|
|
129
|
+
"x-dpdp-signature": signature
|
|
130
|
+
}));
|
|
131
|
+
})()
|
|
132
|
+
: Promise.resolve({});
|
|
133
|
+
|
|
134
|
+
const signedHeaders = await signingHeaders;
|
|
135
|
+
const response = await fetch(options.url, {
|
|
136
|
+
method: "POST",
|
|
137
|
+
headers: {
|
|
138
|
+
"content-type": "application/json",
|
|
139
|
+
...(options.clientId ? { "x-client-id": options.clientId } : {}),
|
|
140
|
+
...(options.token ? { authorization: `Bearer ${options.token}` } : {}),
|
|
141
|
+
...signedHeaders,
|
|
142
|
+
},
|
|
143
|
+
body: bodyText,
|
|
144
|
+
signal: controller.signal,
|
|
145
|
+
redirect: "error",
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
if (!response.ok) {
|
|
149
|
+
const retryableProblem = await readRetryableProblem(response);
|
|
150
|
+
const retryable = response.status >= 500 || response.status === 429 || retryableProblem;
|
|
151
|
+
throw workerError({
|
|
152
|
+
code:
|
|
153
|
+
response.status === 401 || response.status === 403
|
|
154
|
+
? "OUTBOX_AUTH_REJECTED"
|
|
155
|
+
: retryable
|
|
156
|
+
? "OUTBOX_DELIVERY_FAILED"
|
|
157
|
+
: "OUTBOX_PROTOCOL_REJECTED",
|
|
158
|
+
title:
|
|
159
|
+
response.status === 401 || response.status === 403
|
|
160
|
+
? "Control Plane authentication rejected outbox event"
|
|
161
|
+
: "Control Plane rejected outbox event",
|
|
162
|
+
detail: `Brain API responded with HTTP ${response.status}.`,
|
|
163
|
+
category:
|
|
164
|
+
response.status === 401 || response.status === 403
|
|
165
|
+
? "configuration"
|
|
166
|
+
: retryable
|
|
167
|
+
? "network"
|
|
168
|
+
: "external",
|
|
169
|
+
retryable,
|
|
170
|
+
fatal: !retryable,
|
|
171
|
+
context: {
|
|
172
|
+
status: response.status,
|
|
173
|
+
url: options.url,
|
|
174
|
+
},
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
return true;
|
|
179
|
+
} finally {
|
|
180
|
+
clearTimeout(timer);
|
|
181
|
+
}
|
|
182
|
+
};
|
|
183
|
+
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import type { Sql } from "@/types";
|
|
2
|
+
import { workerError } from "@/errors";
|
|
3
|
+
import { assertIdentifier, logError, outboxLogger } from "@/utils";
|
|
4
|
+
import type { OutboxEvent, ProcessOutboxOptions, ProcessOutboxResult } from "./types";
|
|
5
|
+
import {
|
|
6
|
+
DEFAULT_BASE_BACKOFF_MS,
|
|
7
|
+
DEFAULT_BATCH_SIZE,
|
|
8
|
+
DEFAULT_ENGINE_SCHEMA,
|
|
9
|
+
DEFAULT_LEASE_SECONDS,
|
|
10
|
+
DEFAULT_MAX_ATTEMPTS,
|
|
11
|
+
resolvePositiveInteger
|
|
12
|
+
} from "./shared";
|
|
13
|
+
import { sendToAPI } from "./dispatcher";
|
|
14
|
+
import { claimOutboxBatch, extendOutboxLeases, markOutboxEventFailed, markOutboxEventProcessed, releaseOutboxLease } from "./store";
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Claims due outbox events, dispatches them, and applies processed/retry/dead-letter state transitions.
|
|
18
|
+
*
|
|
19
|
+
* Fatal delivery failures are rethrown after lease release so the worker loop can fail closed.
|
|
20
|
+
*
|
|
21
|
+
* @param sql - Postgres pool owning the outbox table.
|
|
22
|
+
* @param syncFn - Event delivery function, usually an HTTP dispatcher.
|
|
23
|
+
* @param options - Lease and retry tuning values.
|
|
24
|
+
* @returns Aggregate processing counters for the claimed batch.
|
|
25
|
+
* @throws {WorkerError} On fatal protocol or configuration errors, or lease invariants.
|
|
26
|
+
*/
|
|
27
|
+
export async function processOutbox(
|
|
28
|
+
sql: Sql,
|
|
29
|
+
syncFn: (event: OutboxEvent) => Promise<boolean> = sendToAPI,
|
|
30
|
+
options: ProcessOutboxOptions = {}
|
|
31
|
+
): Promise<ProcessOutboxResult> {
|
|
32
|
+
const engineSchema = assertIdentifier(
|
|
33
|
+
options.engineSchema ?? DEFAULT_ENGINE_SCHEMA,
|
|
34
|
+
"engine schema name"
|
|
35
|
+
);
|
|
36
|
+
const batchSize = resolvePositiveInteger(
|
|
37
|
+
options.batchSize,
|
|
38
|
+
DEFAULT_BATCH_SIZE,
|
|
39
|
+
"batchSize"
|
|
40
|
+
);
|
|
41
|
+
const leaseSeconds = resolvePositiveInteger(
|
|
42
|
+
options.leaseSeconds,
|
|
43
|
+
DEFAULT_LEASE_SECONDS,
|
|
44
|
+
"leaseSeconds"
|
|
45
|
+
);
|
|
46
|
+
const maxAttempts = resolvePositiveInteger(
|
|
47
|
+
options.maxAttempts,
|
|
48
|
+
DEFAULT_MAX_ATTEMPTS,
|
|
49
|
+
"maxAttempts"
|
|
50
|
+
);
|
|
51
|
+
const baseBackoffMs = resolvePositiveInteger(
|
|
52
|
+
options.baseBackoffMs,
|
|
53
|
+
DEFAULT_BASE_BACKOFF_MS,
|
|
54
|
+
"baseBackoffMs"
|
|
55
|
+
);
|
|
56
|
+
const clock = () => (options.now ? new Date(options.now) : new Date());
|
|
57
|
+
const now = clock();
|
|
58
|
+
|
|
59
|
+
const { leaseToken, events } = await claimOutboxBatch(
|
|
60
|
+
sql,
|
|
61
|
+
engineSchema,
|
|
62
|
+
batchSize,
|
|
63
|
+
leaseSeconds,
|
|
64
|
+
now
|
|
65
|
+
);
|
|
66
|
+
|
|
67
|
+
const result: ProcessOutboxResult = {
|
|
68
|
+
claimed: events.length,
|
|
69
|
+
processed: 0,
|
|
70
|
+
failed: 0,
|
|
71
|
+
deadLettered: 0,
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
for (let index = 0; index < events.length; index += 1) {
|
|
75
|
+
const event = events[index]!;
|
|
76
|
+
try {
|
|
77
|
+
const leaseNow = clock();
|
|
78
|
+
await extendOutboxLeases(
|
|
79
|
+
sql,
|
|
80
|
+
engineSchema,
|
|
81
|
+
events.slice(index).map((candidate) => candidate.id),
|
|
82
|
+
event.id,
|
|
83
|
+
leaseToken,
|
|
84
|
+
new Date(leaseNow.getTime() + leaseSeconds * 1000),
|
|
85
|
+
leaseNow
|
|
86
|
+
);
|
|
87
|
+
|
|
88
|
+
const delivered = await syncFn(event);
|
|
89
|
+
if (!delivered) {
|
|
90
|
+
throw workerError({
|
|
91
|
+
code: "OUTBOX_DELIVERY_RESULT_INVALID",
|
|
92
|
+
title: "Outbox dispatcher returned an invalid result",
|
|
93
|
+
detail: `Dispatcher returned a falsy delivery result for event ${event.id}.`,
|
|
94
|
+
category: "network",
|
|
95
|
+
retryable: true,
|
|
96
|
+
context: { eventId: event.id },
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
await markOutboxEventProcessed(sql, engineSchema, event.id, leaseToken, clock());
|
|
101
|
+
result.processed += 1;
|
|
102
|
+
} catch (error) {
|
|
103
|
+
const normalized = logError(outboxLogger, error, "Failed to sync outbox event", {
|
|
104
|
+
eventId: event.id,
|
|
105
|
+
eventType: event.event_type,
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
if (normalized.fatal) {
|
|
109
|
+
await releaseOutboxLease(sql, engineSchema, event.id, leaseToken, clock(), normalized);
|
|
110
|
+
throw normalized;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const failureState = await markOutboxEventFailed(
|
|
114
|
+
sql,
|
|
115
|
+
engineSchema,
|
|
116
|
+
event,
|
|
117
|
+
leaseToken,
|
|
118
|
+
clock(),
|
|
119
|
+
maxAttempts,
|
|
120
|
+
baseBackoffMs,
|
|
121
|
+
error
|
|
122
|
+
);
|
|
123
|
+
|
|
124
|
+
result.failed += 1;
|
|
125
|
+
if (failureState === "dead_letter") {
|
|
126
|
+
result.deadLettered += 1;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
return result;
|
|
132
|
+
}
|
|
133
|
+
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { fail } from "@/errors";
|
|
2
|
+
|
|
3
|
+
export const DEFAULT_ENGINE_SCHEMA = "dpdp_engine";
|
|
4
|
+
export const DEFAULT_BATCH_SIZE = 10;
|
|
5
|
+
export const DEFAULT_LEASE_SECONDS = 60;
|
|
6
|
+
export const DEFAULT_MAX_ATTEMPTS = 10;
|
|
7
|
+
export const DEFAULT_BASE_BACKOFF_MS = 1000;
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Validates that an outbox tuning value is a positive integer.
|
|
11
|
+
*
|
|
12
|
+
* @param value - Optional runtime override.
|
|
13
|
+
* @param fallback - Default value when the override is absent.
|
|
14
|
+
* @param label - Human-readable option name for error details.
|
|
15
|
+
* @returns Validated positive integer.
|
|
16
|
+
*/
|
|
17
|
+
export function resolvePositiveInteger(
|
|
18
|
+
value: number | undefined,
|
|
19
|
+
fallback: number,
|
|
20
|
+
label: string
|
|
21
|
+
): number {
|
|
22
|
+
if (value === undefined) {
|
|
23
|
+
return fallback;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
if (!Number.isInteger(value) || value < 1) {
|
|
27
|
+
fail({
|
|
28
|
+
code: "OUTBOX_OPTION_INVALID",
|
|
29
|
+
title: "Invalid outbox option",
|
|
30
|
+
detail: `${label} must be an integer greater than 0.`,
|
|
31
|
+
category: "validation",
|
|
32
|
+
retryable: false,
|
|
33
|
+
context: { label },
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return value;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Computes exponential retry delay capped at five minutes.
|
|
42
|
+
*
|
|
43
|
+
* @param attemptNumber - 1-based attempt count.
|
|
44
|
+
* @param baseBackoffMs - Initial backoff duration in milliseconds.
|
|
45
|
+
* @returns Retry delay in milliseconds.
|
|
46
|
+
*/
|
|
47
|
+
export function calculateRetryDelayMs(
|
|
48
|
+
attemptNumber: number,
|
|
49
|
+
baseBackoffMs: number = DEFAULT_BASE_BACKOFF_MS
|
|
50
|
+
): number {
|
|
51
|
+
return Math.min(
|
|
52
|
+
baseBackoffMs * 2 ** Math.max(0, attemptNumber - 1),
|
|
53
|
+
5 * 60 * 1000
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
|