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