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
JavaScript
/**
* 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