@kya-os/mcp-i-cloudflare 1.12.3 → 1.13.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/README.md +5 -1
- package/dist/agent.d.ts +1 -1
- package/dist/agent.d.ts.map +1 -1
- package/dist/agent.js +23 -24
- package/dist/agent.js.map +1 -1
- package/dist/app.d.ts.map +1 -1
- package/dist/app.js +23 -9
- package/dist/app.js.map +1 -1
- package/dist/audit/assurance.d.ts +12 -0
- package/dist/audit/assurance.d.ts.map +1 -0
- package/dist/audit/assurance.js +21 -0
- package/dist/audit/assurance.js.map +1 -0
- package/dist/audit/deployment-binding.d.ts +513 -0
- package/dist/audit/deployment-binding.d.ts.map +1 -0
- package/dist/audit/deployment-binding.js +364 -0
- package/dist/audit/deployment-binding.js.map +1 -0
- package/dist/audit/durable-producer.d.ts +151 -0
- package/dist/audit/durable-producer.d.ts.map +1 -0
- package/dist/audit/durable-producer.js +953 -0
- package/dist/audit/durable-producer.js.map +1 -0
- package/dist/audit/verified-recorder-client.d.ts +34 -0
- package/dist/audit/verified-recorder-client.d.ts.map +1 -0
- package/dist/audit/verified-recorder-client.js +192 -0
- package/dist/audit/verified-recorder-client.js.map +1 -0
- package/dist/config.d.ts +8 -3
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +9 -3
- package/dist/config.js.map +1 -1
- package/dist/delegation-http/consent-anchor-bridge.d.ts +18 -1
- package/dist/delegation-http/consent-anchor-bridge.d.ts.map +1 -1
- package/dist/delegation-http/consent-anchor-bridge.js +18 -5
- package/dist/delegation-http/consent-anchor-bridge.js.map +1 -1
- package/dist/delegation-http/pickup-routes.d.ts.map +1 -1
- package/dist/delegation-http/pickup-routes.js +36 -3
- package/dist/delegation-http/pickup-routes.js.map +1 -1
- package/dist/delegation-http/well-known-capability.d.ts +2 -1
- package/dist/delegation-http/well-known-capability.d.ts.map +1 -1
- package/dist/delegation-http/well-known-capability.js +2 -1
- package/dist/delegation-http/well-known-capability.js.map +1 -1
- package/dist/helpers/env-mapper.d.ts.map +1 -1
- package/dist/helpers/env-mapper.js +27 -0
- package/dist/helpers/env-mapper.js.map +1 -1
- package/dist/index.d.ts +8 -6
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +14 -2
- package/dist/index.js.map +1 -1
- package/dist/runtime/invoke-tool-handler.d.ts +10 -0
- package/dist/runtime/invoke-tool-handler.d.ts.map +1 -0
- package/dist/runtime/invoke-tool-handler.js +11 -0
- package/dist/runtime/invoke-tool-handler.js.map +1 -0
- package/dist/runtime.d.ts +1 -6
- package/dist/runtime.d.ts.map +1 -1
- package/dist/runtime.js +72 -72
- package/dist/runtime.js.map +1 -1
- package/dist/services/consent-audit.service.d.ts +3 -10
- package/dist/services/consent-audit.service.d.ts.map +1 -1
- package/dist/services/consent-audit.service.js +44 -77
- package/dist/services/consent-audit.service.js.map +1 -1
- package/dist/services/consent-templates/template-renderer.d.ts.map +1 -1
- package/dist/services/consent-templates/template-renderer.js +6 -4
- package/dist/services/consent-templates/template-renderer.js.map +1 -1
- package/dist/services/consent.service.d.ts +17 -2
- package/dist/services/consent.service.d.ts.map +1 -1
- package/dist/services/consent.service.js +53 -30
- package/dist/services/consent.service.js.map +1 -1
- package/dist/services/proof.service.d.ts.map +1 -1
- package/dist/services/proof.service.js +20 -4
- package/dist/services/proof.service.js.map +1 -1
- package/dist/services/vault-resolver.d.ts +0 -8
- package/dist/services/vault-resolver.d.ts.map +1 -1
- package/dist/services/vault-resolver.js +0 -22
- package/dist/services/vault-resolver.js.map +1 -1
- package/dist/types.d.ts +19 -0
- package/dist/types.d.ts.map +1 -1
- package/package.json +7 -5
- package/dist/runtime/audit-logger.d.ts +0 -96
- package/dist/runtime/audit-logger.d.ts.map +0 -1
- package/dist/runtime/audit-logger.js +0 -276
- package/dist/runtime/audit-logger.js.map +0 -1
|
@@ -0,0 +1,953 @@
|
|
|
1
|
+
import { canonicalize } from "json-canonicalize";
|
|
2
|
+
import { CompactJwsAuditSignatureVerifier, CryptoProviderAuditHasher, createAuditTrail, parseAuditProducerEvent, parseSignedAuditEntry, } from "@kya-os/mcp/audit";
|
|
3
|
+
import { WebCryptoProvider } from "../providers/crypto";
|
|
4
|
+
import { VerifiedAuditRecorderClient } from "./verified-recorder-client";
|
|
5
|
+
const INTERNAL_ORIGIN = "https://audit-producer.internal";
|
|
6
|
+
const INTERNAL_TOKEN_HEADER = "x-kya-audit-internal-token";
|
|
7
|
+
const MAX_ERROR_LENGTH = 512;
|
|
8
|
+
const MAX_INSPECTION_LIMIT = 100;
|
|
9
|
+
export const AUDIT_PRODUCER_BINDING = "AUDIT_PRODUCER";
|
|
10
|
+
export const AUDIT_PRODUCER_CLASS_NAME = "AuditProducer";
|
|
11
|
+
class SourceConstructionPendingError extends Error {
|
|
12
|
+
constructor(sourceId) {
|
|
13
|
+
super(`Previous audit source event for ${sourceId} has not finished construction`);
|
|
14
|
+
this.name = "SourceConstructionPendingError";
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
class AuditProducerRequestError extends Error {
|
|
18
|
+
status;
|
|
19
|
+
constructor(status, detail) {
|
|
20
|
+
super(`Audit producer request failed: ${detail}`);
|
|
21
|
+
this.status = status;
|
|
22
|
+
this.name = "AuditProducerRequestError";
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
function boundedInteger(value, fallback, minimum, maximum) {
|
|
26
|
+
if (value === undefined || !/^\d+$/.test(value))
|
|
27
|
+
return fallback;
|
|
28
|
+
return Math.min(maximum, Math.max(minimum, Number(value)));
|
|
29
|
+
}
|
|
30
|
+
export function resolveAuditProducerRetryPolicy(env) {
|
|
31
|
+
const baseDelayMs = boundedInteger(env.KYA_OS_AUDIT_RETRY_BASE_MS, 1_000, 250, 60_000);
|
|
32
|
+
return {
|
|
33
|
+
maxAttempts: boundedInteger(env.KYA_OS_AUDIT_MAX_ATTEMPTS, 8, 1, 20),
|
|
34
|
+
baseDelayMs,
|
|
35
|
+
maxDelayMs: boundedInteger(env.KYA_OS_AUDIT_RETRY_MAX_MS, 300_000, baseDelayMs, 86_400_000),
|
|
36
|
+
batchSize: boundedInteger(env.KYA_OS_AUDIT_DELIVERY_BATCH_SIZE, 25, 1, 100),
|
|
37
|
+
deadLetterRetentionMs: boundedInteger(env.KYA_OS_AUDIT_DEAD_LETTER_RETENTION_MS, 30 * 86_400_000, 3_600_000, 90 * 86_400_000),
|
|
38
|
+
receiptRetentionMs: boundedInteger(env.KYA_OS_AUDIT_RECEIPT_RETENTION_MS, 30 * 86_400_000, 3_600_000, 90 * 86_400_000),
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
export function auditRetryDelayMs(attempts, policy) {
|
|
42
|
+
const exponent = Math.max(0, Math.min(30, attempts - 1));
|
|
43
|
+
return Math.min(policy.maxDelayMs, policy.baseDelayMs * 2 ** exponent);
|
|
44
|
+
}
|
|
45
|
+
function errorMessage(error) {
|
|
46
|
+
const value = error instanceof Error ? error.message : String(error);
|
|
47
|
+
return value.slice(0, MAX_ERROR_LENGTH);
|
|
48
|
+
}
|
|
49
|
+
function bytesToBase64(value) {
|
|
50
|
+
let binary = "";
|
|
51
|
+
for (let offset = 0; offset < value.byteLength; offset += 0x8000) {
|
|
52
|
+
binary += String.fromCharCode(...value.subarray(offset, offset + 0x8000));
|
|
53
|
+
}
|
|
54
|
+
return btoa(binary);
|
|
55
|
+
}
|
|
56
|
+
function base64ToBytes(value) {
|
|
57
|
+
if (!/^[A-Za-z0-9+/]*={0,2}$/.test(value)) {
|
|
58
|
+
throw new TypeError("Audit evidence ciphertext is not valid base64");
|
|
59
|
+
}
|
|
60
|
+
const binary = atob(value);
|
|
61
|
+
return Uint8Array.from(binary, (character) => character.charCodeAt(0));
|
|
62
|
+
}
|
|
63
|
+
function toWireSubmission(submission) {
|
|
64
|
+
return {
|
|
65
|
+
ledgerId: submission.ledgerId,
|
|
66
|
+
...(submission.expectedLedgerEpochId === undefined
|
|
67
|
+
? {}
|
|
68
|
+
: { expectedLedgerEpochId: submission.expectedLedgerEpochId }),
|
|
69
|
+
producerEvent: submission.producerEvent,
|
|
70
|
+
encryptedEvidence: submission.encryptedEvidence.map((item) => ({
|
|
71
|
+
ref: item.ref,
|
|
72
|
+
ciphertextBase64: bytesToBase64(item.ciphertext),
|
|
73
|
+
})),
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
function isRecord(value) {
|
|
77
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
78
|
+
}
|
|
79
|
+
function parseWireSubmission(value) {
|
|
80
|
+
if (!isRecord(value) ||
|
|
81
|
+
typeof value.ledgerId !== "string" ||
|
|
82
|
+
value.ledgerId.trim() === "") {
|
|
83
|
+
throw new TypeError("Audit recorder submission ledgerId is required");
|
|
84
|
+
}
|
|
85
|
+
if (value.expectedLedgerEpochId !== undefined &&
|
|
86
|
+
typeof value.expectedLedgerEpochId !== "string") {
|
|
87
|
+
throw new TypeError("Audit recorder expectedLedgerEpochId must be a string");
|
|
88
|
+
}
|
|
89
|
+
if (!Array.isArray(value.encryptedEvidence)) {
|
|
90
|
+
throw new TypeError("Audit recorder encryptedEvidence must be an array");
|
|
91
|
+
}
|
|
92
|
+
const encryptedEvidence = value.encryptedEvidence.map((candidate) => {
|
|
93
|
+
if (!isRecord(candidate) ||
|
|
94
|
+
!isRecord(candidate.ref) ||
|
|
95
|
+
typeof candidate.ciphertextBase64 !== "string") {
|
|
96
|
+
throw new TypeError("Audit recorder encrypted evidence is malformed");
|
|
97
|
+
}
|
|
98
|
+
return {
|
|
99
|
+
ref: candidate.ref,
|
|
100
|
+
ciphertext: base64ToBytes(candidate.ciphertextBase64),
|
|
101
|
+
};
|
|
102
|
+
});
|
|
103
|
+
return {
|
|
104
|
+
ledgerId: value.ledgerId,
|
|
105
|
+
...(value.expectedLedgerEpochId === undefined
|
|
106
|
+
? {}
|
|
107
|
+
: { expectedLedgerEpochId: value.expectedLedgerEpochId }),
|
|
108
|
+
producerEvent: parseAuditProducerEvent(value.producerEvent),
|
|
109
|
+
encryptedEvidence,
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
function toWireItem(item) {
|
|
113
|
+
return {
|
|
114
|
+
eventId: item.eventId,
|
|
115
|
+
submission: toWireSubmission(item.submission),
|
|
116
|
+
enqueuedAt: item.enqueuedAt,
|
|
117
|
+
attempts: item.attempts,
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
function parseWireItem(value) {
|
|
121
|
+
if (!isRecord(value) ||
|
|
122
|
+
typeof value.eventId !== "string" ||
|
|
123
|
+
!Number.isFinite(value.enqueuedAt) ||
|
|
124
|
+
!Number.isInteger(value.attempts) ||
|
|
125
|
+
Number(value.attempts) < 0) {
|
|
126
|
+
throw new TypeError("Audit outbox item is malformed");
|
|
127
|
+
}
|
|
128
|
+
const submission = parseWireSubmission(value.submission);
|
|
129
|
+
if (submission.producerEvent.eventId !== value.eventId) {
|
|
130
|
+
throw new TypeError("Audit outbox eventId does not match its frozen event");
|
|
131
|
+
}
|
|
132
|
+
return {
|
|
133
|
+
eventId: value.eventId,
|
|
134
|
+
submission,
|
|
135
|
+
enqueuedAt: Number(value.enqueuedAt),
|
|
136
|
+
attempts: Number(value.attempts),
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
function parseDelivery(value) {
|
|
140
|
+
if (value !== "best-effort" && value !== "buffered" && value !== "required") {
|
|
141
|
+
throw new TypeError("Unknown audit delivery mode");
|
|
142
|
+
}
|
|
143
|
+
return value;
|
|
144
|
+
}
|
|
145
|
+
function json(value, status = 200) {
|
|
146
|
+
return new Response(JSON.stringify(value), {
|
|
147
|
+
status,
|
|
148
|
+
headers: { "Content-Type": "application/json; charset=utf-8" },
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
function problem(status, code, detail) {
|
|
152
|
+
return json({
|
|
153
|
+
type: `urn:kya-os:problem:${code.toLowerCase()}`,
|
|
154
|
+
title: code,
|
|
155
|
+
status,
|
|
156
|
+
detail,
|
|
157
|
+
}, status);
|
|
158
|
+
}
|
|
159
|
+
function sameSigner(left, right) {
|
|
160
|
+
return (left.did === right.did && left.kid === right.kid && left.alg === right.alg);
|
|
161
|
+
}
|
|
162
|
+
function requiredEnv(env, key) {
|
|
163
|
+
const value = env[key];
|
|
164
|
+
if (typeof value !== "string" || value.trim() === "") {
|
|
165
|
+
throw new Error(`Audit producer is not configured: ${String(key)} is required`);
|
|
166
|
+
}
|
|
167
|
+
return value;
|
|
168
|
+
}
|
|
169
|
+
function recorderTrustFromConfig(env) {
|
|
170
|
+
let config;
|
|
171
|
+
try {
|
|
172
|
+
config = JSON.parse(requiredEnv(env, "KYA_OS_AUDIT_CONFIG"));
|
|
173
|
+
}
|
|
174
|
+
catch (error) {
|
|
175
|
+
throw new Error(`KYA_OS_AUDIT_CONFIG is invalid: ${errorMessage(error)}`);
|
|
176
|
+
}
|
|
177
|
+
if (!isRecord(config) ||
|
|
178
|
+
!isRecord(config.recorderSigner) ||
|
|
179
|
+
!isRecord(config.recorderPublicJwk)) {
|
|
180
|
+
throw new Error("KYA_OS_AUDIT_CONFIG must contain recorderSigner and recorderPublicJwk");
|
|
181
|
+
}
|
|
182
|
+
const signer = config.recorderSigner;
|
|
183
|
+
if (typeof signer.did !== "string" ||
|
|
184
|
+
typeof signer.kid !== "string" ||
|
|
185
|
+
(signer.alg !== "EdDSA" && signer.alg !== "ES256")) {
|
|
186
|
+
throw new Error("KYA_OS_AUDIT_CONFIG recorderSigner is malformed");
|
|
187
|
+
}
|
|
188
|
+
if (typeof config.recorderPublicJwk.kty !== "string") {
|
|
189
|
+
throw new Error("KYA_OS_AUDIT_CONFIG recorderPublicJwk is malformed");
|
|
190
|
+
}
|
|
191
|
+
return {
|
|
192
|
+
expectedRecorder: { did: signer.did, kid: signer.kid, alg: signer.alg },
|
|
193
|
+
key: config.recorderPublicJwk,
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
function buildVerifiedRecorder(env) {
|
|
197
|
+
const { expectedRecorder, key } = recorderTrustFromConfig(env);
|
|
198
|
+
const verifier = new CompactJwsAuditSignatureVerifier({
|
|
199
|
+
resolve: async (signer) => sameSigner(signer, expectedRecorder) ? key : null,
|
|
200
|
+
});
|
|
201
|
+
return new VerifiedAuditRecorderClient({
|
|
202
|
+
endpoint: requiredEnv(env, "KYA_OS_AUDIT_RECORDER_URL"),
|
|
203
|
+
...(env.KYA_OS_AUDIT_RECORDER_SERVICE === undefined
|
|
204
|
+
? {}
|
|
205
|
+
: { transport: env.KYA_OS_AUDIT_RECORDER_SERVICE }),
|
|
206
|
+
...(env.KYA_OS_AUDIT_INGEST_KEY === undefined
|
|
207
|
+
? {}
|
|
208
|
+
: {
|
|
209
|
+
headers: { Authorization: `Bearer ${env.KYA_OS_AUDIT_INGEST_KEY}` },
|
|
210
|
+
}),
|
|
211
|
+
hasher: new CryptoProviderAuditHasher(new WebCryptoProvider()),
|
|
212
|
+
signatureVerifier: verifier,
|
|
213
|
+
expectedRecorder,
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
/** Pure delivery coordinator; persistence and alarms are injected for deterministic tests. */
|
|
217
|
+
export class AuditProducerDeliveryEngine {
|
|
218
|
+
store;
|
|
219
|
+
recorder;
|
|
220
|
+
alarms;
|
|
221
|
+
policy;
|
|
222
|
+
clock;
|
|
223
|
+
constructor(store, recorder, alarms, policy, clock = { now: () => Date.now() }) {
|
|
224
|
+
this.store = store;
|
|
225
|
+
this.recorder = recorder;
|
|
226
|
+
this.alarms = alarms;
|
|
227
|
+
this.policy = policy;
|
|
228
|
+
this.clock = clock;
|
|
229
|
+
}
|
|
230
|
+
async enqueue(item) {
|
|
231
|
+
const now = this.clock.now();
|
|
232
|
+
this.store.enqueue(item, now);
|
|
233
|
+
await this.alarms.setAlarm(now + 1);
|
|
234
|
+
}
|
|
235
|
+
async flush() {
|
|
236
|
+
let delivered = 0;
|
|
237
|
+
let failed = 0;
|
|
238
|
+
let deadLettered = 0;
|
|
239
|
+
const blockedSources = new Set();
|
|
240
|
+
this.store.prune(this.clock.now(), this.policy);
|
|
241
|
+
while (delivered + failed < this.policy.batchSize) {
|
|
242
|
+
const items = this.store.pending(this.clock.now(), this.policy.batchSize - delivered - failed, blockedSources);
|
|
243
|
+
if (items.length === 0)
|
|
244
|
+
break;
|
|
245
|
+
for (const item of items) {
|
|
246
|
+
const sourceId = item.submission.producerEvent.source.sourceId;
|
|
247
|
+
try {
|
|
248
|
+
const entry = await this.recorder().submit(item.submission);
|
|
249
|
+
this.store.markDelivered(item.eventId, entry, this.clock.now());
|
|
250
|
+
delivered += 1;
|
|
251
|
+
}
|
|
252
|
+
catch (error) {
|
|
253
|
+
const outcome = this.store.markFailed(item.eventId, error, this.clock.now(), this.policy);
|
|
254
|
+
blockedSources.add(sourceId);
|
|
255
|
+
failed += 1;
|
|
256
|
+
if (outcome.state === "dead")
|
|
257
|
+
deadLettered += 1;
|
|
258
|
+
}
|
|
259
|
+
if (delivered + failed >= this.policy.batchSize)
|
|
260
|
+
break;
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
await this.scheduleNext();
|
|
264
|
+
return { delivered, failed, deadLettered };
|
|
265
|
+
}
|
|
266
|
+
async scheduleNext() {
|
|
267
|
+
const now = this.clock.now();
|
|
268
|
+
const next = this.store.nextAlarmAt(now, this.policy);
|
|
269
|
+
if (next === null) {
|
|
270
|
+
await this.alarms.deleteAlarm();
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
273
|
+
await this.alarms.setAlarm(Math.max(now + 1, next));
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
class SqliteAuditProducerStore {
|
|
277
|
+
state;
|
|
278
|
+
sql;
|
|
279
|
+
constructor(state) {
|
|
280
|
+
this.state = state;
|
|
281
|
+
this.sql = state.storage.sql;
|
|
282
|
+
}
|
|
283
|
+
initialize() {
|
|
284
|
+
this.sql.exec(`CREATE TABLE IF NOT EXISTS audit_outbox (
|
|
285
|
+
enqueue_order INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
286
|
+
event_id TEXT NOT NULL UNIQUE,
|
|
287
|
+
source_id TEXT NOT NULL,
|
|
288
|
+
source_sequence TEXT,
|
|
289
|
+
submission_json TEXT NOT NULL,
|
|
290
|
+
enqueued_at INTEGER NOT NULL,
|
|
291
|
+
attempts INTEGER NOT NULL,
|
|
292
|
+
available_at INTEGER NOT NULL,
|
|
293
|
+
state TEXT NOT NULL CHECK (state IN ('pending', 'delivered', 'dead')),
|
|
294
|
+
last_error TEXT,
|
|
295
|
+
dead_lettered_at INTEGER,
|
|
296
|
+
updated_at INTEGER NOT NULL
|
|
297
|
+
)`);
|
|
298
|
+
this.sql.exec("CREATE INDEX IF NOT EXISTS audit_outbox_due ON audit_outbox(state, available_at, enqueue_order)");
|
|
299
|
+
this.sql.exec("CREATE INDEX IF NOT EXISTS audit_outbox_source ON audit_outbox(source_id, enqueue_order)");
|
|
300
|
+
this.sql.exec(`CREATE TABLE IF NOT EXISTS audit_receipts (
|
|
301
|
+
event_id TEXT PRIMARY KEY,
|
|
302
|
+
entry_json TEXT NOT NULL,
|
|
303
|
+
entry_digest TEXT NOT NULL,
|
|
304
|
+
recorded_at INTEGER NOT NULL,
|
|
305
|
+
received_at INTEGER NOT NULL
|
|
306
|
+
)`);
|
|
307
|
+
this.sql.exec(`CREATE TABLE IF NOT EXISTS audit_source_heads (
|
|
308
|
+
source_id TEXT PRIMARY KEY,
|
|
309
|
+
next_sequence TEXT NOT NULL,
|
|
310
|
+
receipted_watermark TEXT NOT NULL,
|
|
311
|
+
last_event_digest TEXT
|
|
312
|
+
)`);
|
|
313
|
+
this.sql.exec(`CREATE TABLE IF NOT EXISTS audit_source_claims (
|
|
314
|
+
source_id TEXT NOT NULL,
|
|
315
|
+
event_id TEXT NOT NULL,
|
|
316
|
+
sequence TEXT NOT NULL,
|
|
317
|
+
previous_event_digest TEXT,
|
|
318
|
+
event_digest TEXT,
|
|
319
|
+
receipt_digest TEXT,
|
|
320
|
+
PRIMARY KEY (source_id, event_id),
|
|
321
|
+
UNIQUE (source_id, sequence)
|
|
322
|
+
)`);
|
|
323
|
+
}
|
|
324
|
+
enqueue(item, now) {
|
|
325
|
+
const submissionJson = canonicalize(toWireSubmission(item.submission));
|
|
326
|
+
const existing = this.sql
|
|
327
|
+
.exec("SELECT submission_json FROM audit_outbox WHERE event_id = ?", item.eventId)
|
|
328
|
+
.toArray()[0];
|
|
329
|
+
if (existing !== undefined) {
|
|
330
|
+
if (existing.submission_json !== submissionJson) {
|
|
331
|
+
throw new Error(`Audit outbox event identity collision: ${item.eventId}`);
|
|
332
|
+
}
|
|
333
|
+
return;
|
|
334
|
+
}
|
|
335
|
+
this.sql.exec(`INSERT INTO audit_outbox (
|
|
336
|
+
event_id, source_id, source_sequence, submission_json, enqueued_at,
|
|
337
|
+
attempts, available_at, state, updated_at
|
|
338
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, 'pending', ?)`, item.eventId, item.submission.producerEvent.source.sourceId, item.submission.producerEvent.source.sourceSequence ?? null, submissionJson, item.enqueuedAt, item.attempts, now, now);
|
|
339
|
+
}
|
|
340
|
+
pending(now, limit, blockedSources) {
|
|
341
|
+
const rows = this.sql
|
|
342
|
+
.exec(`SELECT current.event_id, current.submission_json, current.enqueued_at,
|
|
343
|
+
current.attempts, current.available_at, current.source_id
|
|
344
|
+
FROM audit_outbox current
|
|
345
|
+
WHERE current.state = 'pending' AND current.available_at <= ?
|
|
346
|
+
AND NOT EXISTS (
|
|
347
|
+
SELECT 1 FROM audit_outbox earlier
|
|
348
|
+
WHERE earlier.source_id = current.source_id
|
|
349
|
+
AND earlier.state = 'pending'
|
|
350
|
+
AND earlier.enqueue_order < current.enqueue_order
|
|
351
|
+
)
|
|
352
|
+
ORDER BY current.enqueue_order ASC
|
|
353
|
+
LIMIT 1000`, now)
|
|
354
|
+
.toArray();
|
|
355
|
+
const result = [];
|
|
356
|
+
for (const row of rows) {
|
|
357
|
+
if (blockedSources.has(row.source_id))
|
|
358
|
+
continue;
|
|
359
|
+
result.push({
|
|
360
|
+
eventId: row.event_id,
|
|
361
|
+
submission: parseWireSubmission(JSON.parse(row.submission_json)),
|
|
362
|
+
enqueuedAt: row.enqueued_at,
|
|
363
|
+
attempts: row.attempts,
|
|
364
|
+
availableAt: row.available_at,
|
|
365
|
+
});
|
|
366
|
+
if (result.length >= limit)
|
|
367
|
+
break;
|
|
368
|
+
}
|
|
369
|
+
return result;
|
|
370
|
+
}
|
|
371
|
+
saveReceipt(eventId, entry, now) {
|
|
372
|
+
this.sql.exec(`INSERT INTO audit_receipts (event_id, entry_json, entry_digest, recorded_at, received_at)
|
|
373
|
+
VALUES (?, ?, ?, ?, ?)
|
|
374
|
+
ON CONFLICT(event_id) DO UPDATE SET
|
|
375
|
+
entry_json = excluded.entry_json,
|
|
376
|
+
entry_digest = excluded.entry_digest,
|
|
377
|
+
recorded_at = excluded.recorded_at,
|
|
378
|
+
received_at = excluded.received_at`, eventId, canonicalize(entry), entry.entryDigest, entry.core.recordedAt, now);
|
|
379
|
+
}
|
|
380
|
+
markDelivered(eventId, entry, now) {
|
|
381
|
+
const outbox = this.sql
|
|
382
|
+
.exec("SELECT source_id, source_sequence FROM audit_outbox WHERE event_id = ?", eventId)
|
|
383
|
+
.toArray()[0];
|
|
384
|
+
this.state.storage.transactionSync(() => {
|
|
385
|
+
this.saveReceipt(eventId, entry, now);
|
|
386
|
+
this.sql.exec("UPDATE audit_outbox SET state = 'delivered', last_error = NULL, updated_at = ? WHERE event_id = ?", now, eventId);
|
|
387
|
+
});
|
|
388
|
+
// Alarm delivery bypasses AuditTrailService.flush(), so advance the same
|
|
389
|
+
// durable source watermark here after the authoritative receipt is saved.
|
|
390
|
+
if (outbox?.source_sequence !== null &&
|
|
391
|
+
outbox?.source_sequence !== undefined) {
|
|
392
|
+
try {
|
|
393
|
+
this.markReceipted(outbox.source_id, outbox.source_sequence, entry.entryDigest);
|
|
394
|
+
}
|
|
395
|
+
catch (error) {
|
|
396
|
+
// The recorder commit is authoritative. Preserve the local reconciliation
|
|
397
|
+
// failure without relabelling a verified delivery as failed/retryable.
|
|
398
|
+
this.sql.exec("UPDATE audit_outbox SET last_error = ? WHERE event_id = ?", `SOURCE_STATE: ${errorMessage(error)}`, eventId);
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
markDeliveredFromReceipt(eventId, now) {
|
|
403
|
+
if (this.receipt(eventId) === undefined)
|
|
404
|
+
return false;
|
|
405
|
+
this.sql.exec("UPDATE audit_outbox SET state = 'delivered', last_error = NULL, updated_at = ? WHERE event_id = ?", now, eventId);
|
|
406
|
+
return (this.sql
|
|
407
|
+
.exec("SELECT state FROM audit_outbox WHERE event_id = ?", eventId)
|
|
408
|
+
.toArray()[0]?.state === "delivered");
|
|
409
|
+
}
|
|
410
|
+
markFailed(eventId, error, now, policy) {
|
|
411
|
+
const row = this.sql
|
|
412
|
+
.exec("SELECT attempts, state FROM audit_outbox WHERE event_id = ?", eventId)
|
|
413
|
+
.toArray()[0];
|
|
414
|
+
if (row === undefined || row.state !== "pending") {
|
|
415
|
+
throw new RangeError(`Unknown pending audit outbox item: ${eventId}`);
|
|
416
|
+
}
|
|
417
|
+
const attempts = row.attempts + 1;
|
|
418
|
+
if (attempts >= policy.maxAttempts) {
|
|
419
|
+
this.sql.exec(`UPDATE audit_outbox SET attempts = ?, state = 'dead', last_error = ?,
|
|
420
|
+
dead_lettered_at = ?, updated_at = ? WHERE event_id = ?`, attempts, errorMessage(error), now, now, eventId);
|
|
421
|
+
return { state: "dead", attempts };
|
|
422
|
+
}
|
|
423
|
+
const availableAt = now + auditRetryDelayMs(attempts, policy);
|
|
424
|
+
this.sql.exec(`UPDATE audit_outbox SET attempts = ?, available_at = ?, last_error = ?,
|
|
425
|
+
updated_at = ? WHERE event_id = ?`, attempts, availableAt, errorMessage(error), now, eventId);
|
|
426
|
+
return { state: "pending", attempts, availableAt };
|
|
427
|
+
}
|
|
428
|
+
nextAlarmAt(_now, policy) {
|
|
429
|
+
const pending = this.sql
|
|
430
|
+
.exec("SELECT MIN(available_at) AS value FROM audit_outbox WHERE state = 'pending'")
|
|
431
|
+
.toArray()[0]?.value;
|
|
432
|
+
const dead = this.sql
|
|
433
|
+
.exec("SELECT MIN(dead_lettered_at) AS value FROM audit_outbox WHERE state = 'dead'")
|
|
434
|
+
.toArray()[0]?.value;
|
|
435
|
+
const receipt = this.sql
|
|
436
|
+
.exec("SELECT MIN(received_at) AS value FROM audit_receipts")
|
|
437
|
+
.toArray()[0]?.value;
|
|
438
|
+
const candidates = [
|
|
439
|
+
pending,
|
|
440
|
+
dead === null || dead === undefined
|
|
441
|
+
? null
|
|
442
|
+
: dead + policy.deadLetterRetentionMs,
|
|
443
|
+
receipt === null || receipt === undefined
|
|
444
|
+
? null
|
|
445
|
+
: receipt + policy.receiptRetentionMs,
|
|
446
|
+
].filter((value) => value !== null && value !== undefined);
|
|
447
|
+
return candidates.length === 0 ? null : Math.min(...candidates);
|
|
448
|
+
}
|
|
449
|
+
prune(now, policy) {
|
|
450
|
+
this.state.storage.transactionSync(() => {
|
|
451
|
+
this.sql.exec("DELETE FROM audit_outbox WHERE state = 'dead' AND dead_lettered_at < ?", now - policy.deadLetterRetentionMs);
|
|
452
|
+
this.sql.exec("DELETE FROM audit_outbox WHERE state = 'delivered' AND updated_at < ?", now - policy.receiptRetentionMs);
|
|
453
|
+
this.sql.exec("DELETE FROM audit_receipts WHERE received_at < ?", now - policy.receiptRetentionMs);
|
|
454
|
+
});
|
|
455
|
+
}
|
|
456
|
+
deadLetters(limit) {
|
|
457
|
+
return this.sql
|
|
458
|
+
.exec(`SELECT event_id, source_id, source_sequence, attempts, enqueued_at,
|
|
459
|
+
dead_lettered_at, last_error
|
|
460
|
+
FROM audit_outbox WHERE state = 'dead'
|
|
461
|
+
ORDER BY dead_lettered_at DESC LIMIT ?`, limit)
|
|
462
|
+
.toArray()
|
|
463
|
+
.map((row) => ({
|
|
464
|
+
eventId: row.event_id,
|
|
465
|
+
sourceId: row.source_id,
|
|
466
|
+
...(row.source_sequence === null
|
|
467
|
+
? {}
|
|
468
|
+
: { sourceSequence: row.source_sequence }),
|
|
469
|
+
attempts: row.attempts,
|
|
470
|
+
enqueuedAt: row.enqueued_at,
|
|
471
|
+
deadLetteredAt: row.dead_lettered_at,
|
|
472
|
+
lastError: row.last_error,
|
|
473
|
+
}));
|
|
474
|
+
}
|
|
475
|
+
redrive(eventId, now) {
|
|
476
|
+
this.sql.exec(`UPDATE audit_outbox SET state = 'pending', attempts = 0, available_at = ?,
|
|
477
|
+
last_error = NULL, dead_lettered_at = NULL, updated_at = ?
|
|
478
|
+
WHERE event_id = ? AND state = 'dead'`, now, now, eventId);
|
|
479
|
+
return (this.sql
|
|
480
|
+
.exec("SELECT state FROM audit_outbox WHERE event_id = ?", eventId)
|
|
481
|
+
.toArray()[0]?.state === "pending");
|
|
482
|
+
}
|
|
483
|
+
receipt(eventId) {
|
|
484
|
+
const row = this.sql
|
|
485
|
+
.exec("SELECT entry_json FROM audit_receipts WHERE event_id = ?", eventId)
|
|
486
|
+
.toArray()[0];
|
|
487
|
+
return row === undefined
|
|
488
|
+
? undefined
|
|
489
|
+
: parseSignedAuditEntry(JSON.parse(row.entry_json));
|
|
490
|
+
}
|
|
491
|
+
claimEvent(sourceId, eventId) {
|
|
492
|
+
return this.state.storage.transactionSync(() => {
|
|
493
|
+
const existing = this.sql
|
|
494
|
+
.exec("SELECT sequence, previous_event_digest FROM audit_source_claims WHERE source_id = ? AND event_id = ?", sourceId, eventId)
|
|
495
|
+
.toArray()[0];
|
|
496
|
+
if (existing !== undefined) {
|
|
497
|
+
return {
|
|
498
|
+
sequence: existing.sequence,
|
|
499
|
+
...(existing.previous_event_digest === null
|
|
500
|
+
? {}
|
|
501
|
+
: {
|
|
502
|
+
previousSourceEventDigest: existing.previous_event_digest,
|
|
503
|
+
}),
|
|
504
|
+
};
|
|
505
|
+
}
|
|
506
|
+
const head = this.sql
|
|
507
|
+
.exec("SELECT next_sequence, last_event_digest FROM audit_source_heads WHERE source_id = ?", sourceId)
|
|
508
|
+
.toArray()[0];
|
|
509
|
+
if (head !== undefined) {
|
|
510
|
+
const current = this.sql
|
|
511
|
+
.exec("SELECT event_digest FROM audit_source_claims WHERE source_id = ? AND sequence = ?", sourceId, head.next_sequence)
|
|
512
|
+
.toArray()[0];
|
|
513
|
+
if (current?.event_digest === null) {
|
|
514
|
+
throw new SourceConstructionPendingError(sourceId);
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
const sequence = (BigInt(head?.next_sequence ?? "0") + 1n).toString();
|
|
518
|
+
const previous = head?.last_event_digest ?? null;
|
|
519
|
+
this.sql.exec(`INSERT INTO audit_source_heads (source_id, next_sequence, receipted_watermark, last_event_digest)
|
|
520
|
+
VALUES (?, ?, '0', ?)
|
|
521
|
+
ON CONFLICT(source_id) DO UPDATE SET next_sequence = excluded.next_sequence`, sourceId, sequence, previous);
|
|
522
|
+
this.sql.exec(`INSERT INTO audit_source_claims
|
|
523
|
+
(source_id, event_id, sequence, previous_event_digest)
|
|
524
|
+
VALUES (?, ?, ?, ?)`, sourceId, eventId, sequence, previous);
|
|
525
|
+
return {
|
|
526
|
+
sequence,
|
|
527
|
+
...(previous === null
|
|
528
|
+
? {}
|
|
529
|
+
: { previousSourceEventDigest: previous }),
|
|
530
|
+
};
|
|
531
|
+
});
|
|
532
|
+
}
|
|
533
|
+
markEmitted(sourceId, eventId, sequence, eventDigest) {
|
|
534
|
+
this.state.storage.transactionSync(() => {
|
|
535
|
+
const claim = this.sql
|
|
536
|
+
.exec("SELECT sequence, event_digest FROM audit_source_claims WHERE source_id = ? AND event_id = ?", sourceId, eventId)
|
|
537
|
+
.toArray()[0];
|
|
538
|
+
if (claim === undefined || claim.sequence !== sequence) {
|
|
539
|
+
throw new RangeError("Unknown source event claim");
|
|
540
|
+
}
|
|
541
|
+
if (claim.event_digest !== null && claim.event_digest !== eventDigest) {
|
|
542
|
+
throw new Error(`Source event identity collision: ${eventId}`);
|
|
543
|
+
}
|
|
544
|
+
this.sql.exec("UPDATE audit_source_claims SET event_digest = ? WHERE source_id = ? AND event_id = ?", eventDigest, sourceId, eventId);
|
|
545
|
+
this.sql.exec("UPDATE audit_source_heads SET last_event_digest = ? WHERE source_id = ? AND next_sequence = ?", eventDigest, sourceId, sequence);
|
|
546
|
+
});
|
|
547
|
+
}
|
|
548
|
+
markReceipted(sourceId, sequence, entryDigest) {
|
|
549
|
+
this.state.storage.transactionSync(() => {
|
|
550
|
+
const claim = this.sql
|
|
551
|
+
.exec("SELECT event_id FROM audit_source_claims WHERE source_id = ? AND sequence = ?", sourceId, sequence)
|
|
552
|
+
.toArray()[0];
|
|
553
|
+
if (claim === undefined)
|
|
554
|
+
throw new RangeError("Unknown source sequence");
|
|
555
|
+
this.sql.exec("UPDATE audit_source_claims SET receipt_digest = ? WHERE source_id = ? AND sequence = ?", entryDigest, sourceId, sequence);
|
|
556
|
+
const head = this.sql
|
|
557
|
+
.exec("SELECT receipted_watermark FROM audit_source_heads WHERE source_id = ?", sourceId)
|
|
558
|
+
.toArray()[0];
|
|
559
|
+
let watermark = BigInt(head?.receipted_watermark ?? "0");
|
|
560
|
+
const receipted = new Set(this.sql
|
|
561
|
+
.exec("SELECT sequence FROM audit_source_claims WHERE source_id = ? AND receipt_digest IS NOT NULL", sourceId)
|
|
562
|
+
.toArray()
|
|
563
|
+
.map((row) => row.sequence));
|
|
564
|
+
while (receipted.has((watermark + 1n).toString()))
|
|
565
|
+
watermark += 1n;
|
|
566
|
+
this.sql.exec("UPDATE audit_source_heads SET receipted_watermark = ? WHERE source_id = ?", watermark.toString(), sourceId);
|
|
567
|
+
for (const row of this.sql
|
|
568
|
+
.exec("SELECT event_id, sequence FROM audit_source_claims WHERE source_id = ?", sourceId)
|
|
569
|
+
.toArray()) {
|
|
570
|
+
if (BigInt(row.sequence) < watermark) {
|
|
571
|
+
this.sql.exec("DELETE FROM audit_source_claims WHERE source_id = ? AND event_id = ?", sourceId, row.event_id);
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
});
|
|
575
|
+
}
|
|
576
|
+
getSourceState(sourceId) {
|
|
577
|
+
const head = this.sql
|
|
578
|
+
.exec("SELECT next_sequence, receipted_watermark FROM audit_source_heads WHERE source_id = ?", sourceId)
|
|
579
|
+
.toArray()[0];
|
|
580
|
+
if (head === undefined) {
|
|
581
|
+
return {
|
|
582
|
+
sourceId,
|
|
583
|
+
highestEmitted: "0",
|
|
584
|
+
highestReceipted: "0",
|
|
585
|
+
pendingSequences: [],
|
|
586
|
+
};
|
|
587
|
+
}
|
|
588
|
+
const pendingSequences = this.sql
|
|
589
|
+
.exec("SELECT sequence FROM audit_source_claims WHERE source_id = ? AND receipt_digest IS NULL", sourceId)
|
|
590
|
+
.toArray()
|
|
591
|
+
.map((row) => row.sequence)
|
|
592
|
+
.sort((left, right) => {
|
|
593
|
+
const a = BigInt(left);
|
|
594
|
+
const b = BigInt(right);
|
|
595
|
+
return a < b ? -1 : a > b ? 1 : 0;
|
|
596
|
+
});
|
|
597
|
+
return {
|
|
598
|
+
sourceId,
|
|
599
|
+
highestEmitted: head.next_sequence,
|
|
600
|
+
highestReceipted: head.receipted_watermark,
|
|
601
|
+
pendingSequences,
|
|
602
|
+
};
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
function constantTimeTokenEqual(expected, actual) {
|
|
606
|
+
if (actual === null)
|
|
607
|
+
return false;
|
|
608
|
+
const left = new TextEncoder().encode(expected);
|
|
609
|
+
const right = new TextEncoder().encode(actual);
|
|
610
|
+
let difference = left.byteLength ^ right.byteLength;
|
|
611
|
+
const length = Math.max(left.byteLength, right.byteLength);
|
|
612
|
+
for (let index = 0; index < length; index += 1) {
|
|
613
|
+
difference |= (left[index] ?? 0) ^ (right[index] ?? 0);
|
|
614
|
+
}
|
|
615
|
+
return difference === 0;
|
|
616
|
+
}
|
|
617
|
+
/**
|
|
618
|
+
* SQLite-backed producer boundary. It is deliberately separate from the MCP
|
|
619
|
+
* agent Durable Object, so recorder latency and retry alarms cannot mutate or
|
|
620
|
+
* stall authorization, consent, delegation, or session state.
|
|
621
|
+
*/
|
|
622
|
+
export class AuditProducer {
|
|
623
|
+
state;
|
|
624
|
+
env;
|
|
625
|
+
store;
|
|
626
|
+
policy;
|
|
627
|
+
delivery;
|
|
628
|
+
ready;
|
|
629
|
+
recorderClient;
|
|
630
|
+
constructor(state, env) {
|
|
631
|
+
this.state = state;
|
|
632
|
+
this.env = env;
|
|
633
|
+
this.store = new SqliteAuditProducerStore(state);
|
|
634
|
+
this.policy = resolveAuditProducerRetryPolicy(env);
|
|
635
|
+
this.ready = state.blockConcurrencyWhile(async () => this.store.initialize());
|
|
636
|
+
this.delivery = new AuditProducerDeliveryEngine(this.store, () => this.recorder(), state.storage, this.policy);
|
|
637
|
+
}
|
|
638
|
+
async fetch(request) {
|
|
639
|
+
await this.ready;
|
|
640
|
+
const expectedInternalToken = this.env.KYA_OS_AUDIT_PRODUCER_INTERNAL_TOKEN;
|
|
641
|
+
if (typeof expectedInternalToken !== "string" ||
|
|
642
|
+
expectedInternalToken.trim().length === 0) {
|
|
643
|
+
return problem(503, "AUDIT_PRODUCER_MISCONFIGURED", "Internal audit producer authentication is not configured");
|
|
644
|
+
}
|
|
645
|
+
if (!constantTimeTokenEqual(expectedInternalToken, request.headers.get(INTERNAL_TOKEN_HEADER))) {
|
|
646
|
+
return problem(401, "AUDIT_PRODUCER_UNAUTHORIZED", "Invalid internal audit producer token");
|
|
647
|
+
}
|
|
648
|
+
const url = new URL(request.url);
|
|
649
|
+
try {
|
|
650
|
+
if (request.method === "POST" && url.pathname === "/v1/record") {
|
|
651
|
+
const body = await request.json();
|
|
652
|
+
if (!isRecord(body))
|
|
653
|
+
throw new TypeError("Audit producer request must be an object");
|
|
654
|
+
parseDelivery(body.delivery);
|
|
655
|
+
const submission = parseWireSubmission(body.submission);
|
|
656
|
+
const entry = await this.recorder().submit(submission);
|
|
657
|
+
this.store.saveReceipt(submission.producerEvent.eventId, entry, Date.now());
|
|
658
|
+
return json(entry);
|
|
659
|
+
}
|
|
660
|
+
if (request.method === "POST" && url.pathname === "/v1/outbox") {
|
|
661
|
+
const body = await request.json();
|
|
662
|
+
if (!isRecord(body) || parseDelivery(body.delivery) !== "buffered") {
|
|
663
|
+
throw new TypeError("Audit outbox requires buffered delivery");
|
|
664
|
+
}
|
|
665
|
+
await this.delivery.enqueue(parseWireItem(body.item));
|
|
666
|
+
return json({ status: "pending" }, 202);
|
|
667
|
+
}
|
|
668
|
+
if (request.method === "GET" && url.pathname === "/v1/outbox/pending") {
|
|
669
|
+
const limit = boundedInteger(url.searchParams.get("limit") ?? undefined, 100, 1, 100);
|
|
670
|
+
const items = this.store.pending(Date.now(), limit, new Set());
|
|
671
|
+
return json({ items: items.map(toWireItem) });
|
|
672
|
+
}
|
|
673
|
+
if (request.method === "POST" && url.pathname === "/v1/outbox/flush") {
|
|
674
|
+
return json(await this.delivery.flush());
|
|
675
|
+
}
|
|
676
|
+
if (request.method === "POST" &&
|
|
677
|
+
url.pathname === "/v1/outbox/delivered") {
|
|
678
|
+
const body = await request.json();
|
|
679
|
+
if (!isRecord(body) || typeof body.eventId !== "string") {
|
|
680
|
+
throw new TypeError("eventId is required");
|
|
681
|
+
}
|
|
682
|
+
if (!this.store.markDeliveredFromReceipt(body.eventId, Date.now())) {
|
|
683
|
+
return problem(409, "AUDIT_RECEIPT_MISSING", "A verified receipt is required before delivery can complete");
|
|
684
|
+
}
|
|
685
|
+
await this.delivery.scheduleNext();
|
|
686
|
+
return json({ status: "delivered" });
|
|
687
|
+
}
|
|
688
|
+
if (request.method === "POST" && url.pathname === "/v1/outbox/failed") {
|
|
689
|
+
const body = await request.json();
|
|
690
|
+
if (!isRecord(body) || typeof body.eventId !== "string") {
|
|
691
|
+
throw new TypeError("eventId is required");
|
|
692
|
+
}
|
|
693
|
+
const outcome = this.store.markFailed(body.eventId, typeof body.error === "string" ? body.error : "Audit delivery failed", Date.now(), this.policy);
|
|
694
|
+
await this.delivery.scheduleNext();
|
|
695
|
+
return json(outcome);
|
|
696
|
+
}
|
|
697
|
+
if (request.method === "GET" && url.pathname === "/v1/dead-letters") {
|
|
698
|
+
const limit = boundedInteger(url.searchParams.get("limit") ?? undefined, 25, 1, MAX_INSPECTION_LIMIT);
|
|
699
|
+
return json({ deadLetters: this.store.deadLetters(limit) });
|
|
700
|
+
}
|
|
701
|
+
const redrive = url.pathname.match(/^\/v1\/dead-letters\/([^/]+)\/redrive$/);
|
|
702
|
+
if (request.method === "POST" && redrive !== null) {
|
|
703
|
+
const eventId = decodeURIComponent(redrive[1]);
|
|
704
|
+
if (!this.store.redrive(eventId, Date.now())) {
|
|
705
|
+
return problem(404, "AUDIT_DEAD_LETTER_NOT_FOUND", "Dead-lettered event was not found");
|
|
706
|
+
}
|
|
707
|
+
await this.delivery.scheduleNext();
|
|
708
|
+
return json({ status: "pending", eventId });
|
|
709
|
+
}
|
|
710
|
+
const receipt = url.pathname.match(/^\/v1\/receipts\/([^/]+)$/);
|
|
711
|
+
if (request.method === "GET" && receipt !== null) {
|
|
712
|
+
const eventId = decodeURIComponent(receipt[1]);
|
|
713
|
+
const entry = this.store.receipt(eventId);
|
|
714
|
+
return entry === undefined
|
|
715
|
+
? problem(404, "AUDIT_RECEIPT_NOT_FOUND", "Verified receipt was not found")
|
|
716
|
+
: json(entry);
|
|
717
|
+
}
|
|
718
|
+
if (request.method === "POST" && url.pathname === "/v1/source/claim") {
|
|
719
|
+
const body = await request.json();
|
|
720
|
+
if (!isRecord(body) ||
|
|
721
|
+
typeof body.sourceId !== "string" ||
|
|
722
|
+
typeof body.eventId !== "string") {
|
|
723
|
+
throw new TypeError("sourceId and eventId are required");
|
|
724
|
+
}
|
|
725
|
+
try {
|
|
726
|
+
return json(this.store.claimEvent(body.sourceId, body.eventId));
|
|
727
|
+
}
|
|
728
|
+
catch (error) {
|
|
729
|
+
if (error instanceof SourceConstructionPendingError) {
|
|
730
|
+
return problem(409, "AUDIT_SOURCE_CONSTRUCTION_PENDING", error.message);
|
|
731
|
+
}
|
|
732
|
+
throw error;
|
|
733
|
+
}
|
|
734
|
+
}
|
|
735
|
+
if (request.method === "POST" && url.pathname === "/v1/source/emitted") {
|
|
736
|
+
const body = await request.json();
|
|
737
|
+
if (!isRecord(body) ||
|
|
738
|
+
typeof body.sourceId !== "string" ||
|
|
739
|
+
typeof body.eventId !== "string" ||
|
|
740
|
+
typeof body.sequence !== "string" ||
|
|
741
|
+
typeof body.eventDigest !== "string") {
|
|
742
|
+
throw new TypeError("Source emission is malformed");
|
|
743
|
+
}
|
|
744
|
+
this.store.markEmitted(body.sourceId, body.eventId, body.sequence, body.eventDigest);
|
|
745
|
+
return json({ status: "emitted" });
|
|
746
|
+
}
|
|
747
|
+
if (request.method === "POST" &&
|
|
748
|
+
url.pathname === "/v1/source/receipted") {
|
|
749
|
+
const body = await request.json();
|
|
750
|
+
if (!isRecord(body) ||
|
|
751
|
+
typeof body.sourceId !== "string" ||
|
|
752
|
+
typeof body.sequence !== "string" ||
|
|
753
|
+
typeof body.entryDigest !== "string") {
|
|
754
|
+
throw new TypeError("Source receipt is malformed");
|
|
755
|
+
}
|
|
756
|
+
this.store.markReceipted(body.sourceId, body.sequence, body.entryDigest);
|
|
757
|
+
return json({ status: "receipted" });
|
|
758
|
+
}
|
|
759
|
+
if (request.method === "GET" && url.pathname === "/v1/source") {
|
|
760
|
+
const sourceId = url.searchParams.get("sourceId");
|
|
761
|
+
if (sourceId === null || sourceId.trim() === "") {
|
|
762
|
+
throw new TypeError("sourceId is required");
|
|
763
|
+
}
|
|
764
|
+
return json(this.store.getSourceState(sourceId));
|
|
765
|
+
}
|
|
766
|
+
return problem(404, "AUDIT_PRODUCER_ROUTE_NOT_FOUND", "Audit producer route was not found");
|
|
767
|
+
}
|
|
768
|
+
catch (error) {
|
|
769
|
+
return problem(400, "AUDIT_PRODUCER_INVALID_REQUEST", errorMessage(error));
|
|
770
|
+
}
|
|
771
|
+
}
|
|
772
|
+
async alarm() {
|
|
773
|
+
await this.ready;
|
|
774
|
+
await this.delivery.flush();
|
|
775
|
+
}
|
|
776
|
+
recorder() {
|
|
777
|
+
this.recorderClient ??= buildVerifiedRecorder(this.env);
|
|
778
|
+
return this.recorderClient;
|
|
779
|
+
}
|
|
780
|
+
}
|
|
781
|
+
/** Client-side port used by AuditTrailService in the MCP worker. */
|
|
782
|
+
export class CloudflareAuditProducerBinding {
|
|
783
|
+
options;
|
|
784
|
+
capabilities = {
|
|
785
|
+
durability: "durable",
|
|
786
|
+
fifoPerSource: true,
|
|
787
|
+
atomicClaim: true,
|
|
788
|
+
};
|
|
789
|
+
stub;
|
|
790
|
+
constructor(options) {
|
|
791
|
+
this.options = options;
|
|
792
|
+
if (options.instanceName.trim() === "") {
|
|
793
|
+
throw new TypeError("Audit producer instanceName must be non-empty");
|
|
794
|
+
}
|
|
795
|
+
const id = options.namespace.idFromName(options.instanceName);
|
|
796
|
+
this.stub = options.namespace.get(id);
|
|
797
|
+
}
|
|
798
|
+
async submit(input) {
|
|
799
|
+
const response = await this.request("/v1/record", {
|
|
800
|
+
method: "POST",
|
|
801
|
+
body: JSON.stringify({
|
|
802
|
+
delivery: this.options.delivery,
|
|
803
|
+
submission: toWireSubmission(input),
|
|
804
|
+
}),
|
|
805
|
+
});
|
|
806
|
+
return parseSignedAuditEntry(await response.json());
|
|
807
|
+
}
|
|
808
|
+
async enqueue(item) {
|
|
809
|
+
await this.request("/v1/outbox", {
|
|
810
|
+
method: "POST",
|
|
811
|
+
body: JSON.stringify({ delivery: "buffered", item: toWireItem(item) }),
|
|
812
|
+
});
|
|
813
|
+
}
|
|
814
|
+
async *pending(limit = 100) {
|
|
815
|
+
const response = await this.request(`/v1/outbox/pending?limit=${Math.max(1, Math.min(100, limit))}`);
|
|
816
|
+
const body = await response.json();
|
|
817
|
+
if (!isRecord(body) || !Array.isArray(body.items)) {
|
|
818
|
+
throw new Error("Audit producer returned a malformed pending response");
|
|
819
|
+
}
|
|
820
|
+
for (const item of body.items)
|
|
821
|
+
yield parseWireItem(item);
|
|
822
|
+
}
|
|
823
|
+
async markDelivered(eventId) {
|
|
824
|
+
await this.request("/v1/outbox/delivered", {
|
|
825
|
+
method: "POST",
|
|
826
|
+
body: JSON.stringify({ eventId }),
|
|
827
|
+
});
|
|
828
|
+
}
|
|
829
|
+
async markFailed(eventId, error) {
|
|
830
|
+
await this.request("/v1/outbox/failed", {
|
|
831
|
+
method: "POST",
|
|
832
|
+
body: JSON.stringify({ eventId, error: errorMessage(error) }),
|
|
833
|
+
});
|
|
834
|
+
}
|
|
835
|
+
async claimEvent(sourceId, eventId) {
|
|
836
|
+
for (let attempt = 0;; attempt += 1) {
|
|
837
|
+
try {
|
|
838
|
+
const response = await this.request("/v1/source/claim", {
|
|
839
|
+
method: "POST",
|
|
840
|
+
body: JSON.stringify({ sourceId, eventId }),
|
|
841
|
+
});
|
|
842
|
+
return (await response.json());
|
|
843
|
+
}
|
|
844
|
+
catch (error) {
|
|
845
|
+
if (!(error instanceof AuditProducerRequestError) ||
|
|
846
|
+
error.status !== 409 ||
|
|
847
|
+
attempt >= 9) {
|
|
848
|
+
throw error;
|
|
849
|
+
}
|
|
850
|
+
await new Promise((resolve) => setTimeout(resolve, Math.min(50, 5 * 2 ** attempt)));
|
|
851
|
+
}
|
|
852
|
+
}
|
|
853
|
+
}
|
|
854
|
+
async markEmitted(sourceId, eventId, sequence, eventDigest) {
|
|
855
|
+
await this.request("/v1/source/emitted", {
|
|
856
|
+
method: "POST",
|
|
857
|
+
body: JSON.stringify({ sourceId, eventId, sequence, eventDigest }),
|
|
858
|
+
});
|
|
859
|
+
}
|
|
860
|
+
async markReceipted(sourceId, sequence, entryDigest) {
|
|
861
|
+
await this.request("/v1/source/receipted", {
|
|
862
|
+
method: "POST",
|
|
863
|
+
body: JSON.stringify({ sourceId, sequence, entryDigest }),
|
|
864
|
+
});
|
|
865
|
+
}
|
|
866
|
+
async getState(sourceId) {
|
|
867
|
+
const response = await this.request(`/v1/source?sourceId=${encodeURIComponent(sourceId)}`);
|
|
868
|
+
return (await response.json());
|
|
869
|
+
}
|
|
870
|
+
/** Return only a locally verified recorder entry retained by the producer. */
|
|
871
|
+
async receipt(eventId) {
|
|
872
|
+
try {
|
|
873
|
+
const response = await this.request(`/v1/receipts/${encodeURIComponent(eventId)}`);
|
|
874
|
+
return parseSignedAuditEntry(await response.json());
|
|
875
|
+
}
|
|
876
|
+
catch (error) {
|
|
877
|
+
if (error instanceof AuditProducerRequestError && error.status === 404)
|
|
878
|
+
return undefined;
|
|
879
|
+
throw error;
|
|
880
|
+
}
|
|
881
|
+
}
|
|
882
|
+
async request(path, init = {}) {
|
|
883
|
+
const requestHeaders = new Headers(init.headers);
|
|
884
|
+
if (init.body !== undefined)
|
|
885
|
+
requestHeaders.set("Content-Type", "application/json");
|
|
886
|
+
if (this.options.internalToken !== undefined) {
|
|
887
|
+
requestHeaders.set(INTERNAL_TOKEN_HEADER, this.options.internalToken);
|
|
888
|
+
}
|
|
889
|
+
const response = await this.stub.fetch(`${INTERNAL_ORIGIN}${path}`, {
|
|
890
|
+
...init,
|
|
891
|
+
headers: requestHeaders,
|
|
892
|
+
});
|
|
893
|
+
if (!response.ok) {
|
|
894
|
+
let detail = `HTTP ${response.status}`;
|
|
895
|
+
try {
|
|
896
|
+
const body = await response.json();
|
|
897
|
+
if (isRecord(body) && typeof body.detail === "string")
|
|
898
|
+
detail = body.detail;
|
|
899
|
+
}
|
|
900
|
+
catch {
|
|
901
|
+
// Preserve the status-only detail for malformed error responses.
|
|
902
|
+
}
|
|
903
|
+
throw new AuditProducerRequestError(response.status, detail);
|
|
904
|
+
}
|
|
905
|
+
return response;
|
|
906
|
+
}
|
|
907
|
+
}
|
|
908
|
+
/** Compose the core audit trail with durable Cloudflare producer mechanics. */
|
|
909
|
+
export function createCloudflareAuditTrail(options) {
|
|
910
|
+
if (options.env.AUDIT_PRODUCER === undefined) {
|
|
911
|
+
throw new Error("AUDIT_PRODUCER Durable Object binding is required for Cloudflare audit");
|
|
912
|
+
}
|
|
913
|
+
const internalToken = options.env.KYA_OS_AUDIT_PRODUCER_INTERNAL_TOKEN;
|
|
914
|
+
if (typeof internalToken !== "string" || internalToken.trim().length === 0) {
|
|
915
|
+
throw new Error("KYA_OS_AUDIT_PRODUCER_INTERNAL_TOKEN is required for Cloudflare audit");
|
|
916
|
+
}
|
|
917
|
+
const producer = new CloudflareAuditProducerBinding({
|
|
918
|
+
namespace: options.env.AUDIT_PRODUCER,
|
|
919
|
+
instanceName: options.instanceName ?? `${options.ledgerId}\0${options.sourceId}`,
|
|
920
|
+
delivery: options.delivery,
|
|
921
|
+
internalToken,
|
|
922
|
+
});
|
|
923
|
+
return createAuditTrail({
|
|
924
|
+
recorder: producer,
|
|
925
|
+
delivery: options.delivery,
|
|
926
|
+
hasher: options.hasher ?? new CryptoProviderAuditHasher(new WebCryptoProvider()),
|
|
927
|
+
ledgerId: options.ledgerId,
|
|
928
|
+
...(options.expectedLedgerEpochId === undefined
|
|
929
|
+
? {}
|
|
930
|
+
: { expectedLedgerEpochId: options.expectedLedgerEpochId }),
|
|
931
|
+
tenantRef: options.tenantRef,
|
|
932
|
+
producer: options.producer,
|
|
933
|
+
sourceId: options.sourceId,
|
|
934
|
+
binding: options.binding,
|
|
935
|
+
privacy: options.privacy,
|
|
936
|
+
clock: options.clock ?? { now: () => Date.now() },
|
|
937
|
+
sourceState: producer,
|
|
938
|
+
...(options.delivery === "buffered" ? { outbox: producer } : {}),
|
|
939
|
+
...(options.eventIdFactory === undefined
|
|
940
|
+
? {}
|
|
941
|
+
: { eventIdFactory: options.eventIdFactory }),
|
|
942
|
+
...(options.onDeliveryFailure === undefined
|
|
943
|
+
? {}
|
|
944
|
+
: { onDeliveryFailure: options.onDeliveryFailure }),
|
|
945
|
+
...(options.onSourceStateFailure === undefined
|
|
946
|
+
? {}
|
|
947
|
+
: { onSourceStateFailure: options.onSourceStateFailure }),
|
|
948
|
+
...(options.capabilities === undefined
|
|
949
|
+
? {}
|
|
950
|
+
: { capabilities: options.capabilities }),
|
|
951
|
+
});
|
|
952
|
+
}
|
|
953
|
+
//# sourceMappingURL=durable-producer.js.map
|