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.

271 lines • 13.1 kB
/** * `billing` namespace — organizations and Stripe checkouts. * * Organizations are addressed by their canonical `org_id` (UUID). A * wallet or email is resolved to that id through the * `GET /orgs/v1/lookup?wallet=|?email=` lookup; `getOrganization` / `history` * accept any of the three identifier forms and resolve internally. Organization * reads require SIWX from a wallet linked to the organization (or matching the * looked-up `?wallet`), or an admin key; email lookups are admin-only. * Mutations such as link-wallet, checkout creation, and auto-recharge require * an org credential (or admin). */ import { LocalError } from "../errors.js"; import { assertEmailAddress, assertEvmAddress, assertNonEmptyString, assertPositiveSafeInteger, assertStringInSet, } from "../validation.js"; const BILLING_TIERS = ["prototype", "hobby", "team"]; const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; /** * Classify a billing identifier as a canonical organization id (UUID), an EVM * wallet address, or an email. Wallets are lowercased; organization ids and emails * are returned verbatim. Throws {@link LocalError} for anything else. */ function classifyBillingIdentifier(identifier, context) { assertNonEmptyString(identifier, "identifier", context); if (UUID_RE.test(identifier)) { return { kind: "org_id", value: identifier }; } if (/^0x/i.test(identifier)) { assertEvmAddress(identifier, "identifier", context); return { kind: "wallet", value: identifier.toLowerCase() }; } assertEmailAddress(identifier, "identifier", context); return { kind: "email", value: identifier }; } /** Read organization billing detail by canonical id: `GET /orgs/v1/:org_id/billing`. */ function fetchOrganizationById(client, organizationId, context) { return client.request(`/orgs/v1/${encodeURIComponent(organizationId)}/billing`, { context }); } /** * Resolve a wallet / email to its organization detail via the lookup endpoint * `GET /orgs/v1/lookup?wallet=|?email=`. The lookup returns the same * detail shape as the by-id read, including the resolved `org_id`. */ function fetchOrganizationByLookup(client, kind, value, context) { return client.request(`/orgs/v1/lookup?${kind}=${encodeURIComponent(value)}`, { context }); } /** * Resolve any identifier form (organization id / wallet / email) to the organization * detail. Organization ids hit the canonical by-id read; wallet/email go through * the `?wallet=`/`?email=` lookup. All paths send SIWX via the kernel default. */ async function resolveOrganizationDetail(client, identifier, context) { const id = classifyBillingIdentifier(identifier, context); return id.kind === "org_id" ? fetchOrganizationById(client, id.value, context) : fetchOrganizationByLookup(client, id.kind, id.value, context); } function assertNonNegativeSafeInteger(value, name, context) { if (!Number.isSafeInteger(value) || value < 0) { throw new LocalError(`${name} must be a non-negative safe integer.`, context); } } function assertUsdMicrosAmount(value, name, context) { if (!Number.isSafeInteger(value) || value < 500_000 || value % 10_000 !== 0) { throw new LocalError(`${name} must be a safe integer USD-micros amount of at least 500000 and whole-cent aligned.`, context); } } function checkoutRequestBody(request, context) { if (!request || typeof request !== "object" || Array.isArray(request)) { throw new LocalError("checkout must be an object with a product.", context); } if ("rail" in request) { if (request.rail !== "lightning") { throw new LocalError('rail must be "lightning" (omit it for a Stripe checkout).', context); } if (!Number.isSafeInteger(request.amountSats) || request.amountSats <= 0) { throw new LocalError("amountSats must be a positive integer number of satoshis.", context); } return { product: "balance_topup", rail: "lightning", amount_sats: request.amountSats }; } if (request.product === "balance_topup") { assertUsdMicrosAmount(request.amountUsdMicros, "amountUsdMicros", context); return { product: "balance_topup", amount_usd_micros: request.amountUsdMicros, ...(request.successUrl !== undefined ? { success_url: request.successUrl } : {}), ...(request.cancelUrl !== undefined ? { cancel_url: request.cancelUrl } : {}), }; } if (request.product === "tier") { assertStringInSet(request.tier, BILLING_TIERS, "tier", context); return { product: "tier", tier: request.tier, ...(request.successUrl !== undefined ? { success_url: request.successUrl } : {}), ...(request.cancelUrl !== undefined ? { cancel_url: request.cancelUrl } : {}), }; } if (request.product === "email_pack") { return { product: "email_pack", ...(request.successUrl !== undefined ? { success_url: request.successUrl } : {}), ...(request.cancelUrl !== undefined ? { cancel_url: request.cancelUrl } : {}), }; } throw new LocalError("product must be one of: balance_topup, tier, email_pack.", context); } export class Billing { client; balance; createEmail; autoRecharge; constructor(client) { this.client = client; this.balance = this.checkBalance.bind(this); this.createEmail = this.createEmailOrganization.bind(this); this.autoRecharge = this.setAutoRecharge.bind(this); } /** Check a organization by organization id (UUID), wallet, or email. */ async checkBalance(identifier) { return this.getOrganization(identifier); } /** * Read a organization's financial detail by organization id (UUID), wallet, * or email. An organization id reads `GET /orgs/v1/:org_id/billing` * directly; a wallet/email is resolved through the * `GET /orgs/v1/lookup?wallet=|?email=` lookup. Requires SIWX from a * wallet linked to the organization (or matching the looked-up `?wallet`), or an * admin key; email lookups are admin-only. */ async getOrganization(identifier) { return resolveOrganizationDetail(this.client, identifier, "checking balance"); } /** * Resolve a wallet or email to its organization detail — including the * canonical `org_id` — via `GET /orgs/v1/lookup?wallet=|?email=`. * An org-id (UUID) argument is read directly instead. SIWX must match the * `?wallet`; email lookups are admin-only. */ async lookupOrganization(identifier) { return resolveOrganizationDetail(this.client, identifier, "looking up organization"); } /** * Fetch billing history by organization id (UUID), wallet, or email. Pass * `{ limit, after }` to page; `after` is the opaque keyset cursor * (`next_cursor` from a prior page). */ async history(identifier, opts = {}) { return this.getHistory(identifier, opts); } /** * Fetch ledger history for a organization. History is keyed by organization id * (UUID): a wallet/email identifier is first resolved to its organization via the * lookup, then `GET /orgs/v1/:org_id/billing/history` is read. * Requires SIWX from a wallet linked to the organization, or an admin key. * Pass `{ limit, after }` to page; `after` is the opaque keyset cursor * (`next_cursor` from a prior page). Returns `{ org_id, entries, has_more, * next_cursor }`. */ async getHistory(identifier, opts = {}) { if (opts.limit !== undefined) { assertPositiveSafeInteger(opts.limit, "limit", "fetching billing history"); } const id = classifyBillingIdentifier(identifier, "fetching billing history"); const organizationId = id.kind === "org_id" ? id.value : (await fetchOrganizationByLookup(this.client, id.kind, id.value, "fetching billing history")) .org_id; const params = new URLSearchParams(); if (opts.limit !== undefined) params.set("limit", String(opts.limit)); if (opts.after !== undefined) params.set("after", opts.after); const query = params.toString(); const path = `/orgs/v1/${encodeURIComponent(organizationId)}/billing/history${query ? `?${query}` : ""}`; return this.client.request(path, { context: "fetching billing history", }); } /** Create a Stripe checkout URL for an organization. */ async createCheckout(organizationId, checkout) { assertNonEmptyString(organizationId, "organizationId", "creating checkout"); const body = checkoutRequestBody(checkout, "creating checkout"); return this.client.request(`/orgs/v1/${encodeURIComponent(organizationId)}/checkouts`, { method: "POST", body, context: "creating checkout", }); } /** * lightning-cash-topup: mint a bolt11 invoice that tops up the org's cash * balance when paid. No funds move at creation; any active org member or a * grant key for one of the org's projects may call it. */ async createLightningTopup(organizationId, options) { assertNonEmptyString(organizationId, "organizationId", "creating Lightning top-up"); const body = checkoutRequestBody({ rail: "lightning", amountSats: options.amountSats }, "creating Lightning top-up"); return this.client.request(`/orgs/v1/${encodeURIComponent(organizationId)}/checkouts`, { method: "POST", body, ...(options.idempotencyKey ? { headers: { "Idempotency-Key": options.idempotencyKey } } : {}), context: "creating Lightning top-up", }); } /** Read one top-up (the waiting client's poll). */ async getTopup(organizationId, topupId) { assertNonEmptyString(organizationId, "organizationId", "reading top-up"); assertNonEmptyString(topupId, "topupId", "reading top-up"); return this.client.request(`/orgs/v1/${encodeURIComponent(organizationId)}/checkouts/${encodeURIComponent(topupId)}`, { context: "reading top-up" }); } /** * Poll a top-up until it is paid, paid late, or expired (or the timeout * elapses, in which case the last observed state is returned). */ async waitForTopup(organizationId, topupId, options = {}) { const pollMs = options.pollMs ?? 2_000; const deadline = Date.now() + (options.timeoutMs ?? 600_000); for (;;) { const state = await this.getTopup(organizationId, topupId); options.onPoll?.(state); if (state.status !== "pending" || Date.now() >= deadline) return state; await new Promise((resolve) => setTimeout(resolve, pollMs)); } } /** Create an email-only (no-wallet) organization. Sends a verification email. */ async createEmailOrganization(email) { assertEmailAddress(email, "email", "creating email organization"); return this.client.request("/orgs/v1/email", { method: "POST", body: { email }, context: "creating email organization", withAuth: false, }); } /** * Link a wallet to an existing email organization to enable hybrid * Stripe + x402 payments. `organizationId` is the canonical * `org_id` (UUID) returned by `createEmailOrganization` / * `lookupOrganization`; the gateway addresses the route as * `POST /orgs/v1/:org_id/wallets`. Returns the gateway * response; v1.46+ gateways include a {@link LinkWalletPoolImplications} * block describing the freshly-shared pool's tier, current usage, and limits * so callers can warn before the merge pushes usage `over_limit`. */ async linkWallet(organizationId, wallet) { assertNonEmptyString(organizationId, "organizationId", "linking wallet"); assertEvmAddress(wallet, "wallet", "linking wallet"); return this.client.request(`/orgs/v1/${encodeURIComponent(organizationId)}/wallets`, { method: "POST", body: { wallet: wallet.toLowerCase() }, context: "linking wallet", }); } /** Enable/disable email-pack auto-recharge. */ async setAutoRecharge(opts) { assertNonEmptyString(opts.organizationId, "organizationId", "setting auto-recharge"); const body = { enabled: opts.enabled, }; if (opts.threshold !== undefined) { assertNonNegativeSafeInteger(opts.threshold, "threshold", "setting auto-recharge"); body.threshold = opts.threshold; } await this.client.request(`/orgs/v1/${encodeURIComponent(opts.organizationId)}/billing/auto-recharge`, { method: "PATCH", body, context: "setting auto-recharge", }); } } //# sourceMappingURL=billing.js.map