@kya-os/mcp-i
Version:
The TypeScript MCP framework with identity features built-in
411 lines (410 loc) • 16.8 kB
JavaScript
;
/**
* 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);
}