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,346 @@
|
|
|
1
|
+
import { sha256HexDigest } from "@/lib";
|
|
2
|
+
import type { Sql, Tsql } from "@/types";
|
|
3
|
+
import { canonicalJsonStringify } from "@/utils";
|
|
4
|
+
import { asWorkerError, CODE, fail } from "@/errors";
|
|
5
|
+
import type { OutboxEvent } from "./types";
|
|
6
|
+
import { calculateRetryDelayMs } from "./shared";
|
|
7
|
+
|
|
8
|
+
const DEFAULT_CHAIN_FINALIZATION_LIMIT = 1000;
|
|
9
|
+
|
|
10
|
+
interface ChainTailRow {
|
|
11
|
+
current_hash: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
interface UnfinalizedOutboxRow {
|
|
15
|
+
id: string;
|
|
16
|
+
idempotency_key: string;
|
|
17
|
+
payload: unknown;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
async function finalizePendingOutboxChain(
|
|
21
|
+
tx: Tsql,
|
|
22
|
+
engineSchema: string,
|
|
23
|
+
limit: number
|
|
24
|
+
): Promise<void> {
|
|
25
|
+
const [tail] = await tx<ChainTailRow[]>`
|
|
26
|
+
SELECT current_hash
|
|
27
|
+
FROM ${tx(engineSchema)}.outbox
|
|
28
|
+
WHERE chain_status = 'finalized'
|
|
29
|
+
ORDER BY created_at DESC, id DESC
|
|
30
|
+
LIMIT 1
|
|
31
|
+
`;
|
|
32
|
+
let previousHash = tail?.current_hash ?? "GENESIS";
|
|
33
|
+
|
|
34
|
+
const rows = await tx<UnfinalizedOutboxRow[]>`
|
|
35
|
+
SELECT id, idempotency_key, payload
|
|
36
|
+
FROM ${tx(engineSchema)}.outbox
|
|
37
|
+
WHERE chain_status = 'pending'
|
|
38
|
+
ORDER BY created_at ASC, id ASC
|
|
39
|
+
LIMIT ${limit}
|
|
40
|
+
FOR UPDATE
|
|
41
|
+
`;
|
|
42
|
+
|
|
43
|
+
for (const row of rows) {
|
|
44
|
+
const currentHash = await sha256HexDigest(
|
|
45
|
+
`${previousHash}${canonicalJsonStringify(row.payload)}${row.idempotency_key}`
|
|
46
|
+
);
|
|
47
|
+
|
|
48
|
+
await tx`
|
|
49
|
+
UPDATE ${tx(engineSchema)}.outbox
|
|
50
|
+
SET previous_hash = ${previousHash},
|
|
51
|
+
current_hash = ${currentHash},
|
|
52
|
+
chain_status = 'finalized',
|
|
53
|
+
updated_at = NOW()
|
|
54
|
+
WHERE id = ${row.id}
|
|
55
|
+
`;
|
|
56
|
+
previousHash = currentHash;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Claims one contiguous WORM-chain batch of due outbox events.
|
|
62
|
+
*
|
|
63
|
+
* The dispatcher cannot reorder events without invalidating the Control Plane audit chain.
|
|
64
|
+
* A short advisory lock serializes lease selection across worker containers, then the query
|
|
65
|
+
* walks `previous_hash -> current_hash` from the last processed event.
|
|
66
|
+
*
|
|
67
|
+
* @param sql - Postgres pool owning the outbox table.
|
|
68
|
+
* @param engineSchema - Worker engine schema.
|
|
69
|
+
* @param batchSize - Maximum rows to claim.
|
|
70
|
+
* @param leaseSeconds - Lease duration in seconds.
|
|
71
|
+
* @param now - Lease anchor timestamp.
|
|
72
|
+
* @returns Lease token plus claimed events.
|
|
73
|
+
*/
|
|
74
|
+
export async function claimOutboxBatch(
|
|
75
|
+
sql: Sql,
|
|
76
|
+
engineSchema: string,
|
|
77
|
+
batchSize: number,
|
|
78
|
+
leaseSeconds: number,
|
|
79
|
+
now: Date
|
|
80
|
+
): Promise<{ leaseToken: string; events: OutboxEvent[] }> {
|
|
81
|
+
return sql.begin(async (tx) => {
|
|
82
|
+
const leaseToken = globalThis.crypto.randomUUID();
|
|
83
|
+
const leaseExpiresAt = new Date(now.getTime() + leaseSeconds * 1000);
|
|
84
|
+
const events: OutboxEvent[] = [];
|
|
85
|
+
|
|
86
|
+
await tx`
|
|
87
|
+
SELECT pg_advisory_xact_lock(hashtext(${`${engineSchema}.outbox.dispatch_chain`}))
|
|
88
|
+
`;
|
|
89
|
+
|
|
90
|
+
await finalizePendingOutboxChain(
|
|
91
|
+
tx,
|
|
92
|
+
engineSchema,
|
|
93
|
+
Math.max(batchSize, DEFAULT_CHAIN_FINALIZATION_LIMIT)
|
|
94
|
+
);
|
|
95
|
+
|
|
96
|
+
const [activeLease] = await tx<{ count: string }[]>`
|
|
97
|
+
SELECT COUNT(*)::TEXT AS count
|
|
98
|
+
FROM ${tx(engineSchema)}.outbox
|
|
99
|
+
WHERE status = 'leased'
|
|
100
|
+
AND lease_expires_at IS NOT NULL
|
|
101
|
+
AND lease_expires_at > ${now}
|
|
102
|
+
`;
|
|
103
|
+
if (Number(activeLease?.count ?? "0") > 0) {
|
|
104
|
+
return {
|
|
105
|
+
leaseToken,
|
|
106
|
+
events,
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const [tail] = await tx<ChainTailRow[]>`
|
|
111
|
+
SELECT current_hash
|
|
112
|
+
FROM ${tx(engineSchema)}.outbox
|
|
113
|
+
WHERE status = 'processed'
|
|
114
|
+
AND chain_status = 'finalized'
|
|
115
|
+
ORDER BY created_at DESC, id DESC
|
|
116
|
+
LIMIT 1
|
|
117
|
+
`;
|
|
118
|
+
let expectedPreviousHash = tail?.current_hash ?? "GENESIS";
|
|
119
|
+
|
|
120
|
+
for (let index = 0; index < batchSize; index += 1) {
|
|
121
|
+
const [event] = await tx<OutboxEvent[]>`
|
|
122
|
+
SELECT *
|
|
123
|
+
FROM ${tx(engineSchema)}.outbox
|
|
124
|
+
WHERE status IN ('pending', 'leased')
|
|
125
|
+
AND chain_status = 'finalized'
|
|
126
|
+
AND previous_hash = ${expectedPreviousHash}
|
|
127
|
+
AND next_attempt_at <= ${now}
|
|
128
|
+
AND (status = 'pending' OR lease_expires_at IS NULL OR lease_expires_at <= ${now})
|
|
129
|
+
ORDER BY created_at ASC, id ASC
|
|
130
|
+
LIMIT 1
|
|
131
|
+
FOR UPDATE SKIP LOCKED
|
|
132
|
+
`;
|
|
133
|
+
|
|
134
|
+
if (!event) {
|
|
135
|
+
break;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
await tx`
|
|
139
|
+
UPDATE ${tx(engineSchema)}.outbox
|
|
140
|
+
SET status = 'leased',
|
|
141
|
+
lease_token = ${leaseToken},
|
|
142
|
+
lease_expires_at = ${leaseExpiresAt},
|
|
143
|
+
updated_at = ${now}
|
|
144
|
+
WHERE id = ${event.id}
|
|
145
|
+
`;
|
|
146
|
+
|
|
147
|
+
event.status = "leased";
|
|
148
|
+
event.lease_token = leaseToken;
|
|
149
|
+
event.lease_expires_at = leaseExpiresAt;
|
|
150
|
+
event.updated_at = now;
|
|
151
|
+
events.push(event);
|
|
152
|
+
expectedPreviousHash = event.current_hash;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
return {
|
|
156
|
+
leaseToken,
|
|
157
|
+
events,
|
|
158
|
+
};
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Marks a leased outbox event as processed.
|
|
164
|
+
*
|
|
165
|
+
* @param sql - Postgres pool owning the outbox table.
|
|
166
|
+
* @param engineSchema - Worker engine schema.
|
|
167
|
+
* @param eventId - Outbox event id.
|
|
168
|
+
* @param leaseToken - Current lease token.
|
|
169
|
+
* @param now - Update timestamp.
|
|
170
|
+
*/
|
|
171
|
+
export async function markOutboxEventProcessed(
|
|
172
|
+
sql: Sql,
|
|
173
|
+
engineSchema: string,
|
|
174
|
+
eventId: string,
|
|
175
|
+
leaseToken: string,
|
|
176
|
+
now: Date
|
|
177
|
+
): Promise<void> {
|
|
178
|
+
const updated = await sql`
|
|
179
|
+
UPDATE ${sql(engineSchema)}.outbox
|
|
180
|
+
SET status = 'processed',
|
|
181
|
+
processed_at = ${now},
|
|
182
|
+
lease_token = NULL,
|
|
183
|
+
lease_expires_at = NULL,
|
|
184
|
+
last_error = NULL,
|
|
185
|
+
updated_at = ${now}
|
|
186
|
+
WHERE id = ${eventId}
|
|
187
|
+
AND lease_token = ${leaseToken}
|
|
188
|
+
AND status = 'leased'
|
|
189
|
+
RETURNING id
|
|
190
|
+
`;
|
|
191
|
+
|
|
192
|
+
if (updated.length === 0) {
|
|
193
|
+
fail({
|
|
194
|
+
code: CODE.OUTBOX_LEASE_LOST,
|
|
195
|
+
detail: `Outbox lease for event ${eventId} was lost before it could be marked processed.`,
|
|
196
|
+
context: { eventId },
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Extends active leases for the current contiguous outbox batch.
|
|
203
|
+
*
|
|
204
|
+
* Long API/WORM append latency can make later events in a leased batch expire before
|
|
205
|
+
* the owning worker reaches them. Renewing the remaining batch before every dispatch
|
|
206
|
+
* prevents another worker from reclaiming the same chain segment and creating a
|
|
207
|
+
* false previous-hash conflict.
|
|
208
|
+
*
|
|
209
|
+
* @param sql - Postgres pool owning the outbox table.
|
|
210
|
+
* @param engineSchema - Worker engine schema.
|
|
211
|
+
* @param eventIds - Remaining leased event ids in the current batch.
|
|
212
|
+
* @param currentEventId - Event that is about to be dispatched.
|
|
213
|
+
* @param leaseToken - Current lease token.
|
|
214
|
+
* @param leaseExpiresAt - New lease expiry timestamp.
|
|
215
|
+
* @param now - Update timestamp.
|
|
216
|
+
*/
|
|
217
|
+
export async function extendOutboxLeases(
|
|
218
|
+
sql: Sql,
|
|
219
|
+
engineSchema: string,
|
|
220
|
+
eventIds: readonly string[],
|
|
221
|
+
currentEventId: string,
|
|
222
|
+
leaseToken: string,
|
|
223
|
+
leaseExpiresAt: Date,
|
|
224
|
+
now: Date
|
|
225
|
+
): Promise<void> {
|
|
226
|
+
if (eventIds.length === 0) {
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const rows = await sql<{ id: string }[]>`
|
|
231
|
+
UPDATE ${sql(engineSchema)}.outbox
|
|
232
|
+
SET lease_expires_at = ${leaseExpiresAt},
|
|
233
|
+
updated_at = ${now}
|
|
234
|
+
WHERE id = ANY(${eventIds})
|
|
235
|
+
AND lease_token = ${leaseToken}
|
|
236
|
+
AND status = 'leased'
|
|
237
|
+
RETURNING id
|
|
238
|
+
`;
|
|
239
|
+
|
|
240
|
+
if (!rows.some((row) => row.id === currentEventId)) {
|
|
241
|
+
fail({
|
|
242
|
+
code: CODE.OUTBOX_LEASE_LOST,
|
|
243
|
+
detail: `Outbox lease for event ${currentEventId} was lost before dispatch.`,
|
|
244
|
+
context: { eventId: currentEventId },
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* Marks a leased outbox event as failed or dead-lettered.
|
|
251
|
+
*
|
|
252
|
+
* @param sql - Postgres pool owning the outbox table.
|
|
253
|
+
* @param engineSchema - Worker engine schema.
|
|
254
|
+
* @param event - Leased outbox event.
|
|
255
|
+
* @param leaseToken - Current lease token.
|
|
256
|
+
* @param now - Update timestamp.
|
|
257
|
+
* @param maxAttempts - Retry ceiling before dead-lettering.
|
|
258
|
+
* @param baseBackoffMs - Initial exponential backoff.
|
|
259
|
+
* @param error - Original delivery error.
|
|
260
|
+
* @returns Resulting queue state.
|
|
261
|
+
*/
|
|
262
|
+
export async function markOutboxEventFailed(
|
|
263
|
+
sql: Sql,
|
|
264
|
+
engineSchema: string,
|
|
265
|
+
event: OutboxEvent,
|
|
266
|
+
leaseToken: string,
|
|
267
|
+
now: Date,
|
|
268
|
+
maxAttempts: number,
|
|
269
|
+
baseBackoffMs: number,
|
|
270
|
+
error: unknown
|
|
271
|
+
): Promise<"pending" | "dead_letter"> {
|
|
272
|
+
const nextAttemptCount = event.attempt_count + 1;
|
|
273
|
+
const deadLetter = nextAttemptCount >= maxAttempts;
|
|
274
|
+
const nextAttemptAt = new Date(
|
|
275
|
+
now.getTime() + calculateRetryDelayMs(nextAttemptCount, baseBackoffMs)
|
|
276
|
+
);
|
|
277
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
278
|
+
|
|
279
|
+
const updated = await sql`
|
|
280
|
+
UPDATE ${sql(engineSchema)}.outbox
|
|
281
|
+
SET status = ${deadLetter ? "dead_letter" : "pending"},
|
|
282
|
+
attempt_count = ${nextAttemptCount},
|
|
283
|
+
lease_token = NULL,
|
|
284
|
+
lease_expires_at = NULL,
|
|
285
|
+
next_attempt_at = ${deadLetter ? now : nextAttemptAt},
|
|
286
|
+
last_error = ${errorMessage.slice(0, 1024)},
|
|
287
|
+
updated_at = ${now}
|
|
288
|
+
WHERE id = ${event.id}
|
|
289
|
+
AND lease_token = ${leaseToken}
|
|
290
|
+
AND status = 'leased'
|
|
291
|
+
RETURNING id
|
|
292
|
+
`;
|
|
293
|
+
|
|
294
|
+
if (updated.length === 0) {
|
|
295
|
+
fail({
|
|
296
|
+
code: CODE.OUTBOX_LEASE_LOST,
|
|
297
|
+
detail: `Outbox lease for event ${event.id} was lost before it could be retried.`,
|
|
298
|
+
context: { eventId: event.id },
|
|
299
|
+
});
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
return deadLetter ? "dead_letter" : "pending";
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* Releases a leased outbox event back to pending state after a fatal delivery failure.
|
|
307
|
+
*
|
|
308
|
+
* @param sql - Postgres pool owning the outbox table.
|
|
309
|
+
* @param engineSchema - Worker engine schema.
|
|
310
|
+
* @param eventId - Outbox event id.
|
|
311
|
+
* @param leaseToken - Current lease token.
|
|
312
|
+
* @param now - Update timestamp.
|
|
313
|
+
* @param error - Fatal delivery error.
|
|
314
|
+
*/
|
|
315
|
+
export async function releaseOutboxLease(
|
|
316
|
+
sql: Sql,
|
|
317
|
+
engineSchema: string,
|
|
318
|
+
eventId: string,
|
|
319
|
+
leaseToken: string,
|
|
320
|
+
now: Date,
|
|
321
|
+
error: unknown
|
|
322
|
+
): Promise<void> {
|
|
323
|
+
const normalized = asWorkerError(error);
|
|
324
|
+
|
|
325
|
+
const updated = await sql`
|
|
326
|
+
UPDATE ${sql(engineSchema)}.outbox
|
|
327
|
+
SET status = 'pending',
|
|
328
|
+
lease_token = NULL,
|
|
329
|
+
lease_expires_at = NULL,
|
|
330
|
+
next_attempt_at = ${now},
|
|
331
|
+
last_error = ${normalized.detail.slice(0, 1024)},
|
|
332
|
+
updated_at = ${now}
|
|
333
|
+
WHERE id = ${eventId}
|
|
334
|
+
AND lease_token = ${leaseToken}
|
|
335
|
+
AND status = 'leased'
|
|
336
|
+
RETURNING id
|
|
337
|
+
`;
|
|
338
|
+
|
|
339
|
+
if (updated.length === 0) {
|
|
340
|
+
fail({
|
|
341
|
+
code: CODE.OUTBOX_LEASE_LOST,
|
|
342
|
+
detail: `Outbox lease for event ${eventId} was lost before it could be released.`,
|
|
343
|
+
context: { eventId },
|
|
344
|
+
});
|
|
345
|
+
}
|
|
346
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
// HTTP dispatcher configuration for pushing outbox events to the Control Plane.
|
|
2
|
+
export interface FetchDispatcherOptions {
|
|
3
|
+
url: string;
|
|
4
|
+
token?: string;
|
|
5
|
+
clientId?: string;
|
|
6
|
+
requestSigningSecret?: string;
|
|
7
|
+
timeoutMs?: number;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
// Worker-local outbox row appended inside mutation transactions.
|
|
11
|
+
export interface OutboxRow {
|
|
12
|
+
id: string;
|
|
13
|
+
idempotency_key: string;
|
|
14
|
+
user_uuid_hash: string;
|
|
15
|
+
event_type: string;
|
|
16
|
+
payload: unknown;
|
|
17
|
+
previous_hash: string;
|
|
18
|
+
current_hash: string;
|
|
19
|
+
chain_status: "pending" | "finalized";
|
|
20
|
+
status: "pending" | "leased" | "processed" | "dead_letter";
|
|
21
|
+
attempt_count: number;
|
|
22
|
+
lease_token: string | null;
|
|
23
|
+
lease_expires_at: Date | null;
|
|
24
|
+
next_attempt_at: Date;
|
|
25
|
+
processed_at: Date | null;
|
|
26
|
+
last_error: string | null;
|
|
27
|
+
created_at: Date;
|
|
28
|
+
updated_at: Date;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Aggregated counters from one outbox processing cycle.
|
|
33
|
+
*/
|
|
34
|
+
export interface ProcessOutboxResult {
|
|
35
|
+
claimed: number;
|
|
36
|
+
processed: number;
|
|
37
|
+
failed: number;
|
|
38
|
+
deadLettered: number;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Runtime controls for outbox claim and retry behavior.
|
|
43
|
+
*/
|
|
44
|
+
export interface ProcessOutboxOptions {
|
|
45
|
+
engineSchema?: string;
|
|
46
|
+
batchSize?: number;
|
|
47
|
+
leaseSeconds?: number;
|
|
48
|
+
maxAttempts?: number;
|
|
49
|
+
baseBackoffMs?: number;
|
|
50
|
+
now?: Date;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Outbox row type exposed by the relay pipeline.
|
|
54
|
+
export interface OutboxEvent extends OutboxRow { };
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
const textEncoder = new TextEncoder();
|
|
2
|
+
const signingKeyCache = new Map<string, Promise<CryptoKey>>();
|
|
3
|
+
const MAX_SIGNING_KEY_CACHE_ENTRIES = 128;
|
|
4
|
+
|
|
5
|
+
function bytesToHex(bytes: Uint8Array): string {
|
|
6
|
+
return Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("");
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
async function importSigningKey(secret: string): Promise<CryptoKey> {
|
|
10
|
+
const cached = signingKeyCache.get(secret);
|
|
11
|
+
if (cached) {
|
|
12
|
+
return cached;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
if (signingKeyCache.size >= MAX_SIGNING_KEY_CACHE_ENTRIES) {
|
|
16
|
+
signingKeyCache.delete(signingKeyCache.keys().next().value as string);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const imported = globalThis.crypto.subtle.importKey(
|
|
20
|
+
"raw",
|
|
21
|
+
textEncoder.encode(secret),
|
|
22
|
+
{
|
|
23
|
+
name: "HMAC",
|
|
24
|
+
hash: "SHA-256",
|
|
25
|
+
},
|
|
26
|
+
false,
|
|
27
|
+
["sign"]
|
|
28
|
+
);
|
|
29
|
+
signingKeyCache.set(secret, imported);
|
|
30
|
+
return imported;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Computes the canonical worker/API HMAC request signature.
|
|
35
|
+
*
|
|
36
|
+
* @param secret - Shared HMAC secret.
|
|
37
|
+
* @param method - HTTP method.
|
|
38
|
+
* @param path - URL pathname.
|
|
39
|
+
* @param clientId - Worker client identifier.
|
|
40
|
+
* @param timestamp - Unix epoch milliseconds string.
|
|
41
|
+
* @param nonce - Optional per-request nonce used to prevent same-millisecond multi-worker collisions.
|
|
42
|
+
* @param bodyText - Exact request body text.
|
|
43
|
+
* @returns Lowercase hex digest.
|
|
44
|
+
*/
|
|
45
|
+
export async function computeRequestSignature(
|
|
46
|
+
secret: string,
|
|
47
|
+
method: string,
|
|
48
|
+
path: string,
|
|
49
|
+
clientId: string,
|
|
50
|
+
timestamp: string,
|
|
51
|
+
bodyText: string,
|
|
52
|
+
nonce: string = ""
|
|
53
|
+
): Promise<string> {
|
|
54
|
+
const key = await importSigningKey(secret);
|
|
55
|
+
const parts = nonce
|
|
56
|
+
? [method.toUpperCase(), path, clientId, timestamp, nonce, bodyText]
|
|
57
|
+
: [method.toUpperCase(), path, clientId, timestamp, bodyText];
|
|
58
|
+
const payload = textEncoder.encode(parts.join("\n"));
|
|
59
|
+
const signature = await globalThis.crypto.subtle.sign("HMAC", key, payload);
|
|
60
|
+
return bytesToHex(new Uint8Array(signature));
|
|
61
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { fail, workerError } from "@/errors";
|
|
2
|
+
import type { ApiClient, TaskAckPayload, WorkerTask } from "./types";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Resolves a valid subject identifier from a worker task payload.
|
|
6
|
+
* Prioritizes a non-empty string `subject_opaque_id` first, falling back to a positive integer `userId`.
|
|
7
|
+
*
|
|
8
|
+
* @param task - The worker task containing payload.
|
|
9
|
+
* @returns The resolved identifier as a string or a number.
|
|
10
|
+
* @throws Invokes `fail()` if no valid identifier is found.
|
|
11
|
+
*/
|
|
12
|
+
export function resolveTaskSubject(task: WorkerTask): string | number {
|
|
13
|
+
if (
|
|
14
|
+
typeof task.payload.subject_opaque_id === "string"
|
|
15
|
+
&& task.payload.subject_opaque_id.trim().length > 0
|
|
16
|
+
) {
|
|
17
|
+
return task.payload.subject_opaque_id.trim();
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
if (
|
|
21
|
+
typeof task.payload.userId === "number"
|
|
22
|
+
&& Number.isInteger(task.payload.userId)
|
|
23
|
+
&& task.payload.userId > 0
|
|
24
|
+
) {
|
|
25
|
+
return task.payload.userId;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
fail({
|
|
29
|
+
code: "TASK_PAYLOAD_INVALID",
|
|
30
|
+
title: "Invalid task payload",
|
|
31
|
+
detail: `Task ${task.id} requires a non-empty subject_opaque_id or numeric userId for ${task.task_type}.`,
|
|
32
|
+
category: "validation",
|
|
33
|
+
retryable: false,
|
|
34
|
+
context: { taskId: task.id, taskType: task.task_type },
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export async function acknowledgeTask(
|
|
39
|
+
apiClient: ApiClient,
|
|
40
|
+
taskId: string,
|
|
41
|
+
status: "completed" | "failed",
|
|
42
|
+
result: TaskAckPayload
|
|
43
|
+
): Promise<void> {
|
|
44
|
+
const acknowledged = await apiClient.ackTask(taskId, status, result);
|
|
45
|
+
if (!acknowledged) {
|
|
46
|
+
throw workerError({
|
|
47
|
+
code: "TASK_ACK_FAILED",
|
|
48
|
+
title: "Task acknowledgement failed",
|
|
49
|
+
detail: `Control Plane did not acknowledge task ${taskId}.`,
|
|
50
|
+
category: "network",
|
|
51
|
+
retryable: true,
|
|
52
|
+
context: {
|
|
53
|
+
taskId,
|
|
54
|
+
status,
|
|
55
|
+
},
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import type { Sql } from "@/types";
|
|
2
|
+
import type { WorkerProblemDetails } from "@/errors";
|
|
3
|
+
import type {
|
|
4
|
+
DispatchNoticeResult,
|
|
5
|
+
ShredUserResult,
|
|
6
|
+
VaultUserResult,
|
|
7
|
+
WorkerSecrets,
|
|
8
|
+
MockMailer
|
|
9
|
+
} from "@modules/engine";
|
|
10
|
+
import type { OutboxEvent, S3Client } from "@modules/network";
|
|
11
|
+
import type { WorkerConfig } from "@modules/config";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Normalizes task payload accepted from Control Plane.
|
|
15
|
+
*/
|
|
16
|
+
export interface WorkerTaskPayload {
|
|
17
|
+
request_id?: string;
|
|
18
|
+
subject_opaque_id?: string;
|
|
19
|
+
idempotency_key?: string;
|
|
20
|
+
trigger_source?: string;
|
|
21
|
+
actor_opaque_id?: string;
|
|
22
|
+
legal_framework?: string;
|
|
23
|
+
request_timestamp?: string;
|
|
24
|
+
tenant_id?: string;
|
|
25
|
+
cooldown_days?: number;
|
|
26
|
+
shadow_mode?: boolean;
|
|
27
|
+
webhook_url?: string;
|
|
28
|
+
userId?: number;
|
|
29
|
+
now?: string;
|
|
30
|
+
shadowMode?: boolean;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Leased task envelope returned by Control Plan sync.
|
|
35
|
+
*/
|
|
36
|
+
export interface WorkerTask {
|
|
37
|
+
id: string;
|
|
38
|
+
task_type: "COMPILE_DAG" | "VAULT_USER" | "NOTIFY_USER" | "SHRED_USER" | string;
|
|
39
|
+
payload: WorkerTaskPayload;
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Control Plane sync response shape.
|
|
43
|
+
*/
|
|
44
|
+
export interface SyncTaskResponse {
|
|
45
|
+
pending: boolean;
|
|
46
|
+
task?: WorkerTask;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface CompileDagResult {
|
|
50
|
+
action: "compiled_dag";
|
|
51
|
+
userHash: null;
|
|
52
|
+
dryRun: false;
|
|
53
|
+
compiledTargetCount: number;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export type TaskExecutionResult = CompileDagResult | VaultUserResult | DispatchNoticeResult | ShredUserResult;
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Failed-task acknowledgement payload.
|
|
60
|
+
*/
|
|
61
|
+
export interface TaskFailureResult {
|
|
62
|
+
error: WorkerProblemDetails;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export type TaskAckPayload = TaskExecutionResult | TaskFailureResult;
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Network contract required by the worker loop.
|
|
69
|
+
*/
|
|
70
|
+
export interface ApiClient {
|
|
71
|
+
syncTask(): Promise<SyncTaskResponse>;
|
|
72
|
+
ackTask(taskId: string, status: "completed" | "failed", result: TaskAckPayload): Promise<boolean>;
|
|
73
|
+
heartbeatTask?(taskId: string): Promise<boolean>;
|
|
74
|
+
pushOutboxEvent(event: OutboxEvent): Promise<boolean>;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Dependencies required to construct the compliance worker.
|
|
79
|
+
*/
|
|
80
|
+
export interface ComplianceWorkerOptions {
|
|
81
|
+
sql: Sql;
|
|
82
|
+
sqlReplica?: Sql;
|
|
83
|
+
secrets: WorkerSecrets;
|
|
84
|
+
config: WorkerConfig;
|
|
85
|
+
apiClient: ApiClient;
|
|
86
|
+
mailer: MockMailer;
|
|
87
|
+
s3Client?: S3Client;
|
|
88
|
+
taskHeartbeatIntervalMs?: number;
|
|
89
|
+
}
|