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.
408 lines • 19.6 kB
JavaScript
/**
* `org` namespace — the org-owned control plane (gateway v1.77+, first-class in
* v1.82). Mirrors the `r.projects` / `r.project(id)` idiom:
*
* - `r.orgs` — collection + identity: `create`, `list`, `whoami`.
* - `r.org(id)` — a resource-scoped sub-client (id pre-bound): `get`,
* `rename`, `members.*`, `invites.*`, `audit`.
*
* All routes use the existing SIWX (or control-plane-session) auth; authorization
* is a membership lookup, never `wallet == signer`. Mutations (`create`,
* `rename`, member/invite changes) are step-up gated server-side and may return
* `STEP_UP_REQUIRED` when driven by a stale control-plane session.
*/
import { gateSecret } from "../secret-gate.js";
import { LocalError } from "../errors.js";
/** Isomorphic random idempotency key (Node + browser + V8 isolate) — no Node-only crypto import. */
function randomIdempotencyKey() {
const crypto = globalThis.crypto;
if (crypto?.randomUUID)
return `org-slug-${crypto.randomUUID()}`;
const bytes = new Uint8Array(16);
if (crypto?.getRandomValues)
crypto.getRandomValues(bytes);
else
for (let i = 0; i < bytes.length; i += 1)
bytes[i] = Math.floor(Math.random() * 256);
return `org-slug-${[...bytes].map((b) => b.toString(16).padStart(2, "0")).join("")}`;
}
/** Member management — `r.org(id).members.*`. The org id is bound at construction. */
export class OrgMembers {
client;
orgId;
constructor(client, orgId) {
this.client = client;
this.orgId = orgId;
}
/** List members of the org (`GET /orgs/v1/:org_id/members`). Any active member. */
async list() {
const res = await this.client.request(`/orgs/v1/${encodeURIComponent(this.orgId)}/members`, { context: "listing org members" });
return res.members ?? [];
}
/**
* Add a member by wallet (`POST /orgs/v1/:org_id/members`). Requires an active
* `owner` membership. A brand-new wallet is provisioned as a `human` principal;
* `role` defaults to `"developer"` server-side.
*/
async add(input) {
if (!input?.wallet) {
throw new LocalError("org members.add requires { wallet }", "adding org member");
}
const body = { wallet: input.wallet };
if (input.role !== undefined)
body.role = input.role;
return this.client.request(`/orgs/v1/${encodeURIComponent(this.orgId)}/members`, { method: "POST", body, context: "adding org member" });
}
/**
* Change a member's role (`PATCH …/members/:principal_id`). Requires `owner`;
* demoting the org's only active owner fails with `409 LAST_OWNER`.
*/
async setRole(principalId, opts) {
const role = opts.role;
if (!principalId)
throw new LocalError("org members.setRole requires a principalId", "setting member role");
if (!role)
throw new LocalError("org members.setRole requires a role", "setting member role");
return this.client.request(`/orgs/v1/${encodeURIComponent(this.orgId)}/members/${encodeURIComponent(principalId)}`, { method: "PATCH", body: { role }, context: "setting member role" });
}
/**
* Revoke a member (`DELETE …/members/:principal_id`) — one row, status
* `revoked`, no key rotation. Requires `owner`; revoking the org's only active
* owner fails with `409 LAST_OWNER`.
*/
async revoke(principalId) {
if (!principalId)
throw new LocalError("org members.revoke requires a principalId", "revoking org member");
return this.client.request(`/orgs/v1/${encodeURIComponent(this.orgId)}/members/${encodeURIComponent(principalId)}`, { method: "DELETE", context: "revoking org member" });
}
/**
* vault-agent-envelopes D3 — an OWNER revokes a member's current vault
* encryption key (`DELETE /orgs/v1/:org_id/members/:principal_id/encryption-key`,
* owner + step-up). The independent-credential rotation path: a member whose
* keystore was lost or rebuilt cannot self-rotate (the asking credential is
* the one a thief would hold); after this, the member's next vault
* operation enrolls its current keystore key afresh and a key-holder's next
* operation wraps each vault to it. Audited, org-feed row + mandatory
* security notification (`gitvault_encryption_key_revoked`).
*/
async revokeEncryptionKey(principalId, input = {}) {
if (!principalId)
throw new LocalError("org members.revokeEncryptionKey requires a principalId", "revoking a member's encryption key");
return this.client.request(`/orgs/v1/${encodeURIComponent(this.orgId)}/members/${encodeURIComponent(principalId)}/encryption-key`, { method: "DELETE", body: input.reason ? { reason: input.reason } : undefined, context: "revoking a member's encryption key" });
}
}
/** Email-invite management — `r.org(id).invites.*`. The org id is bound at construction. */
export class OrgInvites {
client;
orgId;
constructor(client, orgId) {
this.client = client;
this.orgId = orgId;
}
/** List pending email invites (`GET /orgs/v1/:org_id/invites`). Any active member. */
async list() {
const res = await this.client.request(`/orgs/v1/${encodeURIComponent(this.orgId)}/invites`, { context: "listing org invites" });
return res.invites ?? [];
}
/**
* Invite a person by email (`POST …/invites`); claimed at their first login.
* Requires `owner` (plus step-up when driven by a control-plane session).
*/
async create(input) {
if (!input?.email)
throw new LocalError("org invites.create requires { email }", "creating org invite");
if (!input?.role)
throw new LocalError("org invites.create requires { role }", "creating org invite");
const body = { email: input.email, role: input.role };
if (input.inviteTtlHours !== undefined)
body.invite_ttl_hours = input.inviteTtlHours;
return this.client.request(`/orgs/v1/${encodeURIComponent(this.orgId)}/invites`, { method: "POST", body, context: "creating org invite" });
}
/** Revoke a pending invite by its pending principal id (`DELETE …/invites/:principal_id`). Requires `owner`. */
async revoke(principalId) {
if (!principalId)
throw new LocalError("org invites.revoke requires a principalId", "revoking org invite");
return this.client.request(`/orgs/v1/${encodeURIComponent(this.orgId)}/invites/${encodeURIComponent(principalId)}`, { method: "DELETE", context: "revoking org invite" });
}
}
/**
* A resource-scoped org sub-client returned by `r.org(id)`. The org id is bound
* at construction; instance operations take no repeated id argument. Mirrors the
* project-scoped `r.project(id)` shape (narrower scope).
*/
export class ScopedOrg {
client;
/** Member management (`r.org(id).members.*`). */
members;
/** Email-invite management (`r.org(id).invites.*`). */
invites;
/** The org id this sub-client is bound to. Read-only. */
orgId;
constructor(client, orgId) {
this.client = client;
if (!orgId)
throw new LocalError("r.org(id) requires an org id", "scoping client to org");
this.orgId = orgId;
this.members = new OrgMembers(client, orgId);
this.invites = new OrgInvites(client, orgId);
}
/**
* Read this org (`GET /orgs/v1/:org_id`) — `{ org_id, display_name, tier,
* lease_started_at, lease_expires_at, role }`.
* Any active member may view; a non-member (including a guessed id) gets the
* same non-revealing `403`.
*/
async get() {
return this.client.request(`/orgs/v1/${encodeURIComponent(this.orgId)}`, {
context: "reading org",
});
}
/**
* Rename this org (`PATCH /orgs/v1/:org_id`). Owner-only + step-up gated. Pass
* `null` or `""` to clear the label. Returns the updated `{ org_id,
* display_name, tier, lease_started_at, lease_expires_at }`.
*/
async rename(displayName) {
return this.client.request(`/orgs/v1/${encodeURIComponent(this.orgId)}`, {
method: "PATCH",
body: { display_name: displayName },
context: "renaming org",
});
}
/**
* Claim or rename this org's address-form slug
* (`POST /orgs/v1/:org_id/slug`, repo-first-onramp design D6). Owner-only.
* A genesis claim (this org had no prior slug) debits `~$1` (the payable
* claim fee, ruins bulk-squatting economics) and is a paid, side-effecting
* mutation — REQUIRES `Idempotency-Key`, which this method generates
* client-side (a fresh one per call) unless `opts.idempotencyKey` is
* supplied, so a retried call after a dropped response cannot double-bill.
* A rename releases the OLD slug into its ~90-day cooldown (typed
* `SLUG_RELEASED` refusal on the old slug thereafter, naming this org's
* new one as successor — never a redirect). `created` is `true` for a
* genesis claim, `false` for a rename or an idempotent no-op replay of the
* SAME target slug.
*/
async setSlug(slug, opts = {}) {
if (!slug) {
throw new LocalError("org.setSlug requires a non-empty slug", "claiming org slug");
}
return this.client.request(`/orgs/v1/${encodeURIComponent(this.orgId)}/slug`, {
method: "POST",
body: { slug },
headers: { "Idempotency-Key": opts.idempotencyKey ?? randomIdempotencyKey() },
context: "claiming org slug",
});
}
/**
* Set or clear the default payout wallet for tenant priced routes
* (`PATCH /orgs/v1/:org_id/payout-wallet`). Requires org admin/owner plus
* server-side step-up or fresh SIWX. The wallet must already be active and
* linked to this org; pass `null` to clear the explicit default.
*/
async setPayoutWallet(input) {
const hasWallet = Boolean(input) && "walletAddress" in input;
const hasSigner = Boolean(input) && typeof input.signerId === "string" && input.signerId.length > 0;
if (hasWallet === hasSigner) {
throw new LocalError("org.setPayoutWallet requires exactly one of { walletAddress } (null clears) or { signerId }", "setting org payout wallet");
}
return this.client.request(`/orgs/v1/${encodeURIComponent(this.orgId)}/payout-wallet`, {
method: "PATCH",
body: hasSigner ? { signer_id: input.signerId } : { wallet_address: input.walletAddress ?? null },
context: "setting org payout wallet",
});
}
/**
* Control-plane audit trail for this org (`GET /orgs/v1/:org_id/audit`).
* admin+. Newest-first; page forward with the `after` keyset cursor
* (`next_cursor` from a prior page) — the legacy `before` cursor is still
* accepted. Returns `{ events, has_more, next_cursor }`.
*/
async audit(opts = {}) {
const parts = [];
if (opts.limit !== undefined)
parts.push(`limit=${encodeURIComponent(String(opts.limit))}`);
if (opts.after !== undefined)
parts.push(`after=${encodeURIComponent(opts.after)}`);
if (opts.before !== undefined)
parts.push(`before=${encodeURIComponent(opts.before)}`);
const q = parts.join("&");
const base = `/orgs/v1/${encodeURIComponent(this.orgId)}/audit`;
const res = await this.client.request(q ? `${base}?${q}` : base, { context: "reading org audit trail" });
return {
events: res.events ?? [],
has_more: res.has_more ?? false,
next_cursor: res.next_cursor ?? null,
};
}
}
/**
* Adopt the org your wallet's agent owns — `r.orgs.adopt.*`. The isomorphic
* (raw-proof) seam: `challenge` issues a nonce; `submit` posts the dual proof
* (control-plane session bearer + a fresh `SIGN-IN-WITH-X` wallet signature). The
* Node convenience `signOrgAdopt` / `adoptOrg` in `@run402/sdk/node`
* runs the whole dance (read session → challenge → sign → submit).
*/
export class OrgAdopt {
client;
constructor(client) {
this.client = client;
}
/** Request a single-use challenge nonce the wallet must sign (`POST /orgs/v1/adopt/challenge`). */
async challenge(input) {
gateSecret(this.client, "orgs.adopt.challenge", input);
if (!input?.wallet) {
throw new LocalError("orgs.adopt.challenge requires { wallet }", "requesting org adopt challenge");
}
return this.client.request("/orgs/v1/adopt/challenge", {
method: "POST",
body: { wallet: input.wallet },
...(input.token ? { headers: { Authorization: `Bearer ${input.token}` }, withAuth: false } : {}),
context: "requesting org adopt challenge",
});
}
/**
* Execute the adopt (`POST /orgs/v1/adopt`) carrying both proofs: the
* control-plane session bearer (the human) and the `SIGN-IN-WITH-X` wallet
* signature. Returns a discriminated {@link AdoptResult}; a `select_org` result
* is returned (not thrown). Throws {@link StepUpRequiredError} when the session
* is not passkey-fresh, and `ApiError` (`WALLET_PROOF_INVALID`) on a bad proof.
*/
async submit(input) {
gateSecret(this.client, "orgs.adopt.submit", input);
if (!input?.siwx) {
throw new LocalError("orgs.adopt.submit requires { siwx } (the SIGN-IN-WITH-X proof)", "adopting org");
}
const headers = { "SIGN-IN-WITH-X": input.siwx };
if (input.token)
headers.Authorization = `Bearer ${input.token}`;
const body = {};
if (input.orgId !== undefined)
body.org_id = input.orgId;
if (input.displayName !== undefined)
body.display_name = input.displayName;
return this.client.request("/orgs/v1/adopt", {
method: "POST",
body,
headers,
withAuth: !input.token,
context: "adopting org",
});
}
}
/**
* `r.orgs.lightningReceiver.*`: the organization's bring-your-own, receive-only
* Lightning receiver for routes priced on `lightning` / `lightning-testnet`.
* The sats land on the organization's own node; Run402 holds a pairing that
* can only mint invoices, never a preimage. `set` consumes a secret and is
* CLI-only on the MCP surface (`run402 orgs lightning-receiver set`).
*/
export class OrgLightningReceiver {
client;
constructor(client) {
this.client = client;
}
/**
* Register or replace the receiver (`POST /orgs/v1/:org_id/lightning-receiver`).
* Org admin/owner plus step-up. The gateway probes the pairing before storing
* it: a pairing that can spend, or whose test invoice does not honor a
* description hash, is refused with `LIGHTNING_RECEIVER_PROBE_FAILED` and
* `details.check`. A replacement rotates `receiver_id`.
*/
async set(input) {
gateSecret(this.client, "orgs.lightningReceiver.set", input);
if (!input?.orgId)
throw new LocalError("orgs.lightningReceiver.set requires { orgId, nwcUri }", "registering org Lightning receiver");
if (typeof input.nwcUri !== "string" || !input.nwcUri.startsWith("nostr+walletconnect://")) {
throw new LocalError("orgs.lightningReceiver.set requires nwcUri, a nostr+walletconnect:// pairing", "registering org Lightning receiver");
}
return this.client.request(`/orgs/v1/${encodeURIComponent(input.orgId)}/lightning-receiver`, {
method: "POST",
body: { nwc_uri: input.nwcUri },
context: "registering org Lightning receiver",
});
}
/** The receiver's non-secret facts and health (`GET …/lightning-receiver`); any active member. */
async get(input) {
if (!input?.orgId)
throw new LocalError("orgs.lightningReceiver.get requires { orgId }", "reading org Lightning receiver");
return this.client.request(`/orgs/v1/${encodeURIComponent(input.orgId)}/lightning-receiver`, {
context: "reading org Lightning receiver",
});
}
/** Delete the receiver (`DELETE …/lightning-receiver`); admin/owner plus step-up. Warns about releases still priced on Lightning. */
async delete(input) {
if (!input?.orgId)
throw new LocalError("orgs.lightningReceiver.delete requires { orgId }", "deleting org Lightning receiver");
return this.client.request(`/orgs/v1/${encodeURIComponent(input.orgId)}/lightning-receiver`, {
method: "DELETE",
context: "removing org Lightning receiver",
});
}
}
export class Orgs {
client;
/**
* Adopt the org your wallet's agent owns: `r.orgs.adopt.challenge()` +
* `.submit()`. The raw dual-proof seam; the Node convenience `adoptOrg` in
* `@run402/sdk/node` runs the full dance.
*/
adopt;
/** The organization's receive-only Lightning receiver for routes priced on Lightning: `r.orgs.lightningReceiver.set/get/remove`. */
lightningReceiver;
constructor(client) {
this.client = client;
this.adopt = new OrgAdopt(client);
this.lightningReceiver = new OrgLightningReceiver(client);
}
/**
* Create an empty org on the `prototype` tier (`POST /orgs/v1`); the caller
* becomes `owner`. Accepts only an optional `displayName` — there is no tier
* input at create. The response reports the created org's tier/lease state.
* Step-up gated; the soft per-owner free-org cap answers 403
* `FREE_ORG_OWNER_LIMIT_EXCEEDED` with `next_actions` (a policy refusal,
* never a 429 — raised as {@link Unauthorized} carrying that `code`).
*/
async create(input = {}) {
const body = {};
if (input.displayName !== undefined)
body.display_name = input.displayName;
return this.client.request("/orgs/v1", {
method: "POST",
body,
context: "creating organization",
});
}
/** List the orgs the caller is an active member of (`GET /orgs/v1`), as memberships. */
async list() {
const res = await this.client.request("/orgs/v1", {
context: "listing organizations",
});
return res.orgs ?? [];
}
/**
* Resolve the caller's control-plane principal and its org memberships
* (`GET /agent/v1/whoami`). This is the REMOTE identity; for the local,
* network-free wallet/profile identity use `r.whoami()`.
*/
async whoami() {
return this.client.request("/agent/v1/whoami", {
context: "resolving principal identity",
});
}
/**
* Set the caller's own display name (`PATCH /agent/v1/me`) — the name
* promotion credit (`hand_to_member.credited_as`), `up`'s room presence,
* and audit surfaces show for this principal. 1–64 characters after
* trimming, no control characters. Self-only: a grant key names the
* principal it acts for. Returns the same body `whoami()` returns.
*/
async setDisplayName(displayName) {
return this.client.request("/agent/v1/me", {
method: "PATCH",
body: { display_name: displayName },
context: "setting principal display name",
});
}
}
//# sourceMappingURL=org.js.map