UNPKG

@kya-os/mcp-i

Version:

The TypeScript MCP framework with identity features built-in

411 lines (410 loc) 16.8 kB
"use strict"; /** * Proof Batch Queue * * Collects proofs in memory and submits them in batches to KTA and AgentShield. * This prevents blocking tool execution while ensuring proofs are eventually submitted. * * Performance: * - Batch size: 10 proofs (configurable) * - Flush interval: 5 seconds (configurable) * - Fire-and-forget submission (doesn't block tool execution) * * Retry Strategy: * - Exponential backoff: 1s, 2s, 4s, 8s, 16s * - Max retries: 5 * - Failed proofs logged and dropped after max retries * * Related: PHASE_1_XMCP_I_SERVER.md Epic 3 (Proof Batching) */ Object.defineProperty(exports, "__esModule", { value: true }); exports.ProofBatchQueue = exports.AgentShieldProofDestination = exports.KTAProofDestination = exports.PartialProofSubmissionError = void 0; exports.createProofBatchQueue = createProofBatchQueue; /** * Thrown by a ProofDestination whose submit() made independent per-item * requests (rather than one atomic request for the whole array) when only * some of them failed. Callers that retry on failure MUST retry only * `failed`, not the original full array — retrying everything would * resubmit proofs the destination already accepted. */ class PartialProofSubmissionError extends Error { failed; succeededCount; causes; constructor(message, failed, succeededCount, /** The per-item rejection reason for each entry in `failed`, same order. */ causes) { super(message); this.failed = failed; this.succeededCount = succeededCount; this.causes = causes; this.name = "PartialProofSubmissionError"; } } exports.PartialProofSubmissionError = PartialProofSubmissionError; /** * KTA proof submission destination * * The live KTA API (POST /api/v1/proofs) accepts exactly one proof per * request — there is no /batch endpoint — so a "batch" here means firing * one request per submission, not one request for the whole array. Since * each request is independent, submit() reports PARTIAL failure via * PartialProofSubmissionError rather than treating the whole array as * atomic — a caller that blindly retried everything on any rejection * would resubmit proofs that already succeeded. */ class KTAProofDestination { name = "KTA"; apiUrl; apiKey; constructor(apiUrl, apiKey) { this.apiUrl = apiUrl.replace(/\/$/, ""); this.apiKey = apiKey; } async submit(submissions) { const results = await Promise.allSettled(submissions.map((submission) => this.submitOne(submission))); const failures = results .map((result, index) => ({ result, submission: submissions[index] })) .filter((entry) => entry.result.status === "rejected"); if (failures.length > 0) { const causes = failures.map((entry) => entry.result.reason); const reasons = causes .map((reason) => reason instanceof Error ? reason.message : String(reason)) .join("; "); throw new PartialProofSubmissionError(`KTA proof submission failed for ${failures.length}/${submissions.length} proof(s): ${reasons}`, failures.map((entry) => entry.submission), submissions.length - failures.length, causes); } } async submitOne(submission) { const { proof, toolName, outcome } = submission; const headers = { "Content-Type": "application/json", }; if (this.apiKey) { headers["Authorization"] = `Bearer ${this.apiKey}`; } const response = await fetch(`${this.apiUrl}/api/v1/proofs`, { method: "POST", headers, body: JSON.stringify({ proof, toolName, outcome, // The live API wants these as top-level fields; both are already // present on the proof itself (ts is Unix-epoch seconds, audience // is documented server-side as the business DID), so derive them // rather than requiring the caller to pass duplicates. timestamp: proof.meta.ts, businessDid: proof.meta.audience, }), }); if (!response.ok) { // The server overloads 409 for two distinct conditions: a duplicate // proofJws (code CONFLICT) means this exact proof is already stored -- // almost always a client-side timeout after the server accepted a // prior attempt, and is idempotent success. A replayed nonce (code // NONCE_REUSED) is also a 409 but means the server REFUSED the proof; // treating it as success would silently drop a real replay-protection // signal. Distinguish via the response body's machine-readable code // rather than assuming every 409 means "already stored". let code; if (response.status === 409) { const body = await response.json().catch(() => ({})); code = body.code; if (code === "CONFLICT") { return; } } throw new Error(`KTA proof submission failed: ${response.status} ${response.statusText}` + (code ? ` (${code})` : "")); } } } exports.KTAProofDestination = KTAProofDestination; /** * AgentShield proof submission destination * * Submits proofs to AgentShield's /api/v1/bouncer/proofs endpoint * with proper authentication and session grouping. */ class AgentShieldProofDestination { name = "AgentShield"; apiUrl; apiKey; constructor(apiUrl, apiKey) { this.apiUrl = apiUrl.replace(/\/$/, ""); this.apiKey = apiKey; } async submit(submissions) { if (submissions.length === 0) { return; } const proofs = submissions.map((submission) => submission.proof); // Extract session_id from first proof for AgentShield session grouping // AgentShield uses this for analytics and detection monitoring const sessionId = proofs[0]?.meta?.sessionId || "unknown"; // AgentShield API format requires delegation_id and session_id wrapper const requestBody = { delegation_id: null, // null for proofs without delegation context session_id: sessionId, // AgentShield session grouping (same as meta.sessionId) proofs: proofs, }; const response = await fetch(`${this.apiUrl}/api/v1/bouncer/proofs`, { method: "POST", headers: { "Content-Type": "application/json", Authorization: `Bearer ${this.apiKey}`, // Bearer token format }, body: JSON.stringify(requestBody), }); if (!response.ok) { // Include response body in error for debugging const errorBody = await response .text() .catch(() => "Unable to read error body"); throw new Error(`AgentShield proof submission failed: ${response.status} ${response.statusText}\n${errorBody}`); } } } exports.AgentShieldProofDestination = AgentShieldProofDestination; /** * Proof Batch Queue * * Collects proofs and submits them in batches to multiple destinations */ class ProofBatchQueue { queue = []; pendingBatches = []; config; flushTimer; retryTimer; closed = false; // Stats stats = { queued: 0, submitted: 0, failed: 0, batchesSubmitted: 0, }; constructor(config) { this.config = { destinations: config.destinations, maxBatchSize: config.maxBatchSize || 10, flushIntervalMs: config.flushIntervalMs || 5000, maxRetries: config.maxRetries || 5, debug: config.debug || false, }; // Start flush timer this.startFlushTimer(); // Start retry timer (check every second) this.startRetryTimer(); } /** * Add a proof submission to the queue. * * Accepts a bare DetachedProof for backward compatibility with the * pre-#650 API: a plain-JS (or un-upgraded TS) consumer built against * the old `enqueue(proof: DetachedProof)` signature would otherwise have * `toolName`/`outcome` silently come through as `undefined`. */ enqueue(item) { const submission = "proof" in item ? item : this.submissionFromBareProof(item); if (this.closed) { console.warn("[ProofBatchQueue] Queue is closed, dropping proof"); return; } this.queue.push(submission); this.stats.queued++; if (this.config.debug) { console.error(`[ProofBatchQueue] Enqueued proof (queue size: ${this.queue.length})`); } // Flush immediately if batch size reached if (this.queue.length >= this.config.maxBatchSize) { this.flush(); } } /** * Normalize a bare DetachedProof (the pre-#650 enqueue() shape) into a * ProofSubmission. Derives toolName/outcome from the proof's own signed * meta when present, rather than a hardcoded literal -- a destination's * server-side check compares the submitted toolName/outcome against what * was actually signed into the JWS payload, so a literal that disagrees * with the real signed value is guaranteed to fail verification. When the * proof was never signed with these fields at all (a genuinely pre-#651 * proof), no fallback can make the submission succeed, so this warns * rather than silently queueing a doomed submission. */ submissionFromBareProof(proof) { // The published @kya-os/mcp package's DetachedProof/ProofMeta types // predate the toolName/submissionOutcome fields added to // @kya-os/contracts/proof's ProofMeta (#651) -- read them structurally // with a runtime guard rather than waiting on that package to catch up. const meta = proof.meta; const toolName = meta !== null && typeof meta === "object" && "toolName" in meta && typeof meta.toolName === "string" ? meta.toolName : undefined; const submissionOutcome = meta !== null && typeof meta === "object" && "submissionOutcome" in meta && (meta.submissionOutcome === "success" || meta.submissionOutcome === "failure" || meta.submissionOutcome === "denied") ? meta.submissionOutcome : undefined; if (toolName === undefined || submissionOutcome === undefined) { console.warn("[ProofBatchQueue] Enqueued a DetachedProof with no signed toolName/submissionOutcome -- " + "the destination's server-side JWS payload check will reject this submission. " + "Generate the proof with { toolName, submissionOutcome } options instead."); } return { proof, toolName: toolName ?? "unknown", outcome: submissionOutcome ?? "success", }; } /** * Flush queue immediately (submit all queued proofs) */ async flush() { if (this.queue.length === 0) { return; } const submissions = this.queue.splice(0, this.config.maxBatchSize); if (this.config.debug) { console.error(`[ProofBatchQueue] Flushing ${submissions.length} proofs to ${this.config.destinations.length} destinations`); } // Submit to each destination (fire-and-forget) for (const destination of this.config.destinations) { const batch = { submissions, destination, retryCount: 0, }; this.submitBatch(batch); // Fire-and-forget } } /** * Submit batch to destination (with retries) */ async submitBatch(batch) { try { await batch.destination.submit(batch.submissions); this.stats.submitted += batch.submissions.length; this.stats.batchesSubmitted++; if (this.config.debug) { console.error(`[ProofBatchQueue] Successfully submitted ${batch.submissions.length} proofs to ${batch.destination.name}`); } } catch (error) { console.error(`[ProofBatchQueue] Failed to submit to ${batch.destination.name}:`, error); // A destination that makes independent per-item requests (KTA) can // fail partially. Only retry the submissions that actually failed — // retrying the whole batch would resubmit ones already accepted, // and would let one permanently-failing proof block good ones from // ever being marked submitted. const isPartialFailure = error instanceof PartialProofSubmissionError; const submissionsToRetry = isPartialFailure ? error.failed : batch.submissions; if (isPartialFailure && error.succeededCount > 0) { this.stats.submitted += error.succeededCount; } // Retry with exponential backoff if (batch.retryCount < this.config.maxRetries) { batch.retryCount++; const backoffMs = Math.min(1000 * Math.pow(2, batch.retryCount - 1), 16000); batch.nextRetryAt = Date.now() + backoffMs; batch.submissions = submissionsToRetry; this.pendingBatches.push(batch); if (this.config.debug) { console.error(`[ProofBatchQueue] Scheduling retry ${batch.retryCount}/${this.config.maxRetries} in ${backoffMs}ms for ${batch.submissions.length} proof(s)`); } } else { // Max retries exceeded, drop the still-failing submissions this.stats.failed += submissionsToRetry.length; console.error(`[ProofBatchQueue] Max retries exceeded for ${batch.destination.name}, dropping ${submissionsToRetry.length} proofs`); } } } /** * Start flush timer */ startFlushTimer() { this.flushTimer = setInterval(() => { if (this.queue.length > 0) { this.flush(); } }, this.config.flushIntervalMs); // Prevent timer from keeping process alive if (typeof this.flushTimer.unref === "function") { this.flushTimer.unref(); } } /** * Start retry timer */ startRetryTimer() { this.retryTimer = setInterval(() => { const now = Date.now(); // Find batches ready for retry const retryBatches = this.pendingBatches.filter((batch) => batch.nextRetryAt && batch.nextRetryAt <= now); if (retryBatches.length > 0) { // Remove from pending this.pendingBatches = this.pendingBatches.filter((batch) => !retryBatches.includes(batch)); // Retry each batch for (const batch of retryBatches) { this.submitBatch(batch); // Fire-and-forget } } }, 1000); // Prevent timer from keeping process alive if (typeof this.retryTimer.unref === "function") { this.retryTimer.unref(); } } /** * Close queue and flush remaining proofs */ async close() { this.closed = true; // Clear timers if (this.flushTimer) { clearInterval(this.flushTimer); } if (this.retryTimer) { clearInterval(this.retryTimer); } // Flush remaining proofs await this.flush(); // Wait for pending retries (with timeout) const maxWaitMs = 30000; // 30 seconds const startTime = Date.now(); while (this.pendingBatches.length > 0 && Date.now() - startTime < maxWaitMs) { await new Promise((resolve) => setTimeout(resolve, 100)); } if (this.pendingBatches.length > 0) { console.warn(`[ProofBatchQueue] Closing with ${this.pendingBatches.length} pending batches (timed out)`); } } /** * Get queue statistics */ getStats() { return { ...this.stats, queueSize: this.queue.length, pendingBatches: this.pendingBatches.length, }; } } exports.ProofBatchQueue = ProofBatchQueue; /** * Create proof batch queue from config */ function createProofBatchQueue(config) { return new ProofBatchQueue(config); }