UNPKG

run402-mcp

Version:

MCP server for Run402 — full-stack backend infrastructure for AI agents: Postgres, auth, storage, serverless functions and atomic deploys. Paid with x402/MPP. Includes $0.03 image generation.

262 lines • 13.5 kB
/** * The Lightning rail's buyer: a `fetch` that selects Run402's MPP Lightning * safety profile on every request, pays the one fixed BOLT11 challenge a 402 * carries from the agent's own wallet (the budgeted sub-wallet on Run402's * Hub, over NWC), presents the preimage as the credential on a byte-identical * retry, and falls back to the x402 buyer when the seller offers no Lightning * challenge (the rail is unavailable, or the surface is not charged over it). * * The all-in debit cap (invoice plus routing fee, in msat and USD) is checked * against the wallet's remaining budget before paying. The pairing secret, * the preimage, and the invoice never reach a log or an error. * * Once the invoice is paid the money has moved, so the gateway's own "come * back for the fulfilment" answers on the paid retry (`PAYMENT_RECOVERY_PENDING`, * `PAYMENT_INTENT_PENDING`, `PAYMENT_STATE_UNAVAILABLE`, `PAYMENT_EVIDENCE_UNAVAILABLE`, * each with a `retry` next action and a `Retry-After`) are honoured here: the * byte-identical paid retry is repeated after the wait, a bounded number of * times. It lands on the same payment intent and is fulfilled without a second * payment; a settled intent never receives a fresh challenge. */ import { createHash } from "node:crypto"; import { loadLightningStack } from "./_paid-stack.js"; import { NwcError, NwcWallet } from "./nwc.js"; export const RUN402_MPP_LIGHTNING_PROFILE = "run402-mpp-lightning-charge-draft00-safety-v1"; const ACCEPT_PAYMENT = `lightning/charge;profile=${RUN402_MPP_LIGHTNING_PROFILE}, tempo/charge;q=0.5`; /** Routing-fee headroom on top of the invoice, in sats (internal Hub payments cost 0). */ const FEE_HEADROOM_SATS = 10; /** A single Run402 charge above this is refused before paying (the spec's conservative USD ceiling in sats). */ export const LIGHTNING_MAX_DEBIT_SATS = 250_000; /** * The gateway's post-settlement "not yet" answers. Each names the retained * intent, carries a `retry` next action and a `Retry-After`, and is answered * by repeating the identical paid request — the same Idempotency-Key and the * same credential — never by paying again. Terminal answers (`PAYMENT_CREDITED`, * `PAYMENT_MANUAL_REVIEW`) carry a different action and are returned as-is. */ const POST_SETTLEMENT_RETRY_CODES = new Set([ "PAYMENT_RECOVERY_PENDING", "PAYMENT_INTENT_PENDING", "PAYMENT_STATE_UNAVAILABLE", "PAYMENT_EVIDENCE_UNAVAILABLE", ]); /** Bounded: the gateway credits a charge it cannot fulfil after three recovery cycles, well inside this. */ export const POST_SETTLEMENT_RETRY_LIMIT = 6; const POST_SETTLEMENT_RETRY_MIN_MS = 1_000; const POST_SETTLEMENT_RETRY_MAX_MS = 10_000; const POST_SETTLEMENT_RETRY_DEFAULT_MS = 2_000; /** * The paid retry's answer, when it is one of the gateway's retryable * post-settlement states from the origin that was asked: the code and how * long to wait before repeating the identical request. Null for everything * else, including a terminal 409 and any answer from another origin. */ export async function readPostSettlementRetry(response, url) { if ((response.status !== 409 && response.status !== 503) || response.redirected) return null; if (response.url) { try { if (new URL(response.url).origin !== new URL(url).origin) return null; } catch { return null; } } if (!response.headers.get("content-type")?.includes("application/json")) return null; let body; try { body = await response.clone().json(); } catch { return null; } if (!body || typeof body !== "object" || Array.isArray(body)) return null; const { code, next_actions: nextActions } = body; if (typeof code !== "string" || !POST_SETTLEMENT_RETRY_CODES.has(code)) return null; const action = Array.isArray(nextActions) ? nextActions[0] : undefined; if (!action || typeof action !== "object" || action.type !== "retry") return null; const raw = response.headers.get("retry-after"); const seconds = raw && /^\d+$/.test(raw) ? Number(raw) : null; const delayMs = seconds === null ? POST_SETTLEMENT_RETRY_DEFAULT_MS : Math.min(POST_SETTLEMENT_RETRY_MAX_MS, Math.max(POST_SETTLEMENT_RETRY_MIN_MS, seconds * 1_000)); return { code, delayMs }; } export class LightningPaymentError extends Error { code; details; constructor(code, message, details = {}) { super(message); this.code = code; this.details = details; this.name = "LightningPaymentError"; } } /** The `WWW-Authenticate: Payment …` challenge for lightning/charge, or null. */ export function readLightningChallenge(response, stack) { if (response.status !== 402) return null; const header = response.headers.get("www-authenticate"); if (!header || !/^payment\s/i.test(header)) return null; let challenge; try { challenge = stack.Challenge.deserialize(header); } catch { return null; } if (challenge.method !== "lightning" || challenge.intent !== "charge") return null; const request = (challenge.request ?? {}); const details = (request.methodDetails ?? {}); const invoice = typeof details.invoice === "string" ? details.invoice : ""; const paymentHash = typeof details.paymentHash === "string" ? details.paymentHash.toLowerCase() : ""; const amountSats = Number(request.amount); if (!invoice || !/^[0-9a-f]{64}$/.test(paymentHash) || !Number.isFinite(amountSats) || amountSats <= 0) return null; return { header, invoice, paymentHash, amountSats, expires: typeof challenge.expires === "string" ? challenge.expires : null }; } function withLightningHeaders(init, idempotencyKey) { const headers = new Headers(init?.headers ?? {}); headers.set("run402-payment-profile", RUN402_MPP_LIGHTNING_PROFILE); headers.set("accept-payment", ACCEPT_PAYMENT); if (!headers.has("idempotency-key")) headers.set("idempotency-key", idempotencyKey); return { ...init, headers }; } function sha256Hex(hex) { return createHash("sha256").update(Buffer.from(hex, "hex")).digest("hex"); } /** The Run402 surfaces charged over Lightning (`/.well-known/x402` → `paymentCapabilities[].resources`). */ const CHARGED_PATHS = [/^\/tiers\/v1\/[^/]+$/, /^\/generate-image\/v1$/]; function requestUrl(input) { return typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; } function requestMethod(input, init) { return (init?.method ?? (typeof input === "object" && "method" in input ? input.method : "GET")).toUpperCase(); } /** * Only a POST to a charged surface gets the Lightning negotiation headers * and a default Idempotency-Key. Every other request is left byte-for-byte * alone: an Idempotency-Key on an ordinary write (a room message, a project * provision) would turn a deliberate repeat into a replay. */ export function isLightningChargedRequest(input, init) { if (requestMethod(input, init) !== "POST") return false; let pathname; try { pathname = new URL(requestUrl(input)).pathname; } catch { return false; } return CHARGED_PATHS.some((pattern) => pattern.test(pathname)); } function defaultIdempotencyKey(input, init) { const url = requestUrl(input); const method = requestMethod(input, init); const body = typeof init?.body === "string" ? init.body : init?.body instanceof Uint8Array ? Buffer.from(init.body).toString("utf8") : ""; const digest = createHash("sha256").update(`${method}\n${url}\n${body}`).digest("hex"); return `lnc_${digest.slice(0, 32)}`; } export function createLightningFetch(options) { const baseFetch = options.baseFetch ?? ((input, init) => globalThis.fetch(input, init)); const wallet = options.wallet ?? new NwcWallet(options.pairingUri); const loadStack = options.stack ?? loadLightningStack; const sleep = options.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms))); let fallbackFetch; const fallback = async () => { if (fallbackFetch === undefined) fallbackFetch = await options.fallback(); return fallbackFetch; }; return async (input, init) => { if (!isLightningChargedRequest(input, init)) { // Not a Lightning surface: the x402 buyer (or the plain fetch) owns it. const other = (await fallback()) ?? baseFetch; return other(input, init); } // Deterministic over the request bytes: a retry of the identical call // (after a crash, a timeout, or a gateway hiccup between payment and // fulfilment) lands on the same payment intent and is fulfilled without // a second payment. A deliberate second identical purchase passes its // own Idempotency-Key. const idempotencyKey = defaultIdempotencyKey(input, init); const firstInit = withLightningHeaders(init, idempotencyKey); const first = await baseFetch(input, firstInit); if (first.status !== 402) return first; const stack = await loadStack(); const challenge = readLightningChallenge(first, stack); if (!challenge) { // No Lightning offer: the seller wants x402 (or the rail is paused). const x402 = await fallback(); if (!x402) return first; await first.body?.cancel().catch(() => undefined); return x402(input, init); } await first.body?.cancel().catch(() => undefined); const allIn = challenge.amountSats + FEE_HEADROOM_SATS; if (allIn > LIGHTNING_MAX_DEBIT_SATS) { throw new LightningPaymentError("LIGHTNING_DEBIT_ABOVE_CAP", `A Lightning charge of ${challenge.amountSats} sats exceeds the ${LIGHTNING_MAX_DEBIT_SATS}-sat ceiling.`, { amount_sats: challenge.amountSats, cap_sats: LIGHTNING_MAX_DEBIT_SATS, next_action: "pay over x402 (run402 init --switch-rail)" }); } const budget = await wallet.getBudgetSats().catch(() => null); if (budget && budget.totalSats !== null && budget.totalSats - budget.usedSats < allIn) { throw new LightningPaymentError("LIGHTNING_BUDGET_EXHAUSTED", `The Lightning wallet's remaining budget (${budget.totalSats - budget.usedSats} sats) cannot cover ${allIn} sats.`, { remaining_sats: budget.totalSats - budget.usedSats, required_sats: allIn, next_action: "pay over x402 (run402 init --switch-rail), or ask the platform for a new wallet" }); } let preimage; try { preimage = (await wallet.payInvoice(challenge.invoice)).preimage; } catch (error) { // Unknown outcome: resolve by hash before ever paying again. if (error instanceof NwcError && (error.code === "TIMEOUT" || error.code === "RELAY_CLOSED" || error.code === "RELAY_UNREACHABLE")) { const looked = await wallet.lookupInvoice(challenge.paymentHash).catch(() => null); if (looked?.preimage) preimage = looked.preimage; else throw new LightningPaymentError("LIGHTNING_PAYMENT_OUTCOME_UNKNOWN", "The Lightning payment's outcome is unknown; the same request may be retried with the same Idempotency-Key once the wallet is reachable.", { payment_hash: challenge.paymentHash, idempotency_key: idempotencyKey }); } else if (error instanceof NwcError) { throw new LightningPaymentError(`LIGHTNING_${error.code}`, `The Lightning wallet refused the payment (${error.code}).`, { payment_hash: challenge.paymentHash, next_action: "pay over x402 (run402 init --switch-rail)" }); } else { throw error; } } if (sha256Hex(preimage) !== challenge.paymentHash) { throw new LightningPaymentError("LIGHTNING_PREIMAGE_MISMATCH", "The wallet returned a preimage that does not hash to the challenge's payment hash.", { payment_hash: challenge.paymentHash }); } options.onPaid?.(challenge.paymentHash); const credential = stack.Credential.serialize(stack.Credential.from({ challenge: stack.Challenge.deserialize(challenge.header), payload: { preimage }, })); const retryHeaders = new Headers(firstInit.headers); retryHeaders.set("authorization", credential); // Byte-identical retry: same body, same Idempotency-Key, plus the credential. const paidInit = { ...firstInit, headers: retryHeaders }; let response = await baseFetch(input, paidInit); // The money has moved. While the gateway says the settled charge's // fulfilment is still pending, repeat the identical paid request after // its Retry-After: the same intent, no second payment, a bounded wait. for (let waits = 0; waits < POST_SETTLEMENT_RETRY_LIMIT; waits += 1) { const pending = await readPostSettlementRetry(response, requestUrl(input)); if (!pending) break; await response.body?.cancel().catch(() => undefined); await sleep(pending.delayMs); response = await baseFetch(input, paidInit); } return response; }; } //# sourceMappingURL=lightning-paid-fetch.js.map