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.
433 lines • 19 kB
JavaScript
/**
* `admin` namespace — operations that don't fit a public
* resource namespace cleanly: messages/contact plus internal finance reads.
*
* (The compound `init` and `status` flows live at the MCP/CLI edge because
* they stitch together multiple SDK namespaces + local state.)
*/
import { gateSecret } from "../secret-gate.js";
import { LocalError } from "../errors.js";
import { Transfers } from "./transfers.js";
const FINANCE_WINDOWS = new Set(["24h", "7d", "30d", "90d"]);
/** Request options carrying both proofs, or nothing when no session is at hand. */
export function sessionProofRequest(proofs) {
if (!proofs)
return {};
return { headers: { "SIGN-IN-WITH-X": proofs.siwx, Authorization: `Bearer ${proofs.token}` }, withAuth: false };
}
/**
* `r.admin.channels` — the Telegram notification-channel binding lifecycle
* (connect / list / revoke). Mutations (`connectTelegram`, `revokeTelegram`)
* require `operator_passkey` assurance; `connectTelegram` additionally
* requires a VERIFIED contact email (bindings are addressed to it). See
* `r.admin.setAgentContact` / `r.admin.verifyAgentContactEmail` and
* `r.admin.startContactPasskeyEnrollment` (or `run402 login`) to reach that assurance level —
* same ladder as {@link Admin.rotateWebhookSecret}.
*/
export class Channels {
client;
constructor(client) {
this.client = client;
}
/**
* Start binding a Telegram chat. Returns two single-use, 15-minute deep
* links — `connect_url` for a private chat, `connect_group_url` for a
* group — plus a `pending` binding id. A human taps ONE of the links and
* starts the bot; poll {@link Channels.list} until the binding's `status`
* flips to `"active"` (or `code_expires_at` passes and it's swept back to
* `"revoked"`).
*
* Throws (via the generic SDK error hierarchy — check `err.code`) HTTP 503
* `TELEGRAM_CHANNEL_NOT_CONFIGURED` until the platform's dedicated
* notification bot is provisioned, and HTTP 412
* `CONTACT_EMAIL_NOT_VERIFIED` when the caller has no verified email yet.
*/
async connectTelegram(opts = {}, proofs) {
gateSecret(this.client, "admin.channels.connectTelegram", opts, proofs);
const body = {};
if (opts.label !== undefined)
body.label = opts.label;
return this.client.request("/agent/v1/notifications/channels/telegram", { method: "POST", body, context: "connecting a Telegram notification channel", ...sessionProofRequest(proofs) });
}
/** List every notification channel — email, webhook, and every live
* (non-revoked) Telegram binding — for the authenticated wallet. */
async list() {
return this.client.request("/agent/v1/notifications/channels", { context: "listing notification channels" });
}
/**
* Revoke a Telegram binding. Missing / already-revoked / another
* caller's binding id all return the SAME not-found error
* (authorize-before-reveal) — no existence oracle.
*/
async revokeTelegram(bindingId, proofs) {
gateSecret(this.client, "admin.channels.revokeTelegram", bindingId, proofs);
return this.client.request(`/agent/v1/notifications/channels/telegram/${encodeURIComponent(bindingId)}`, { method: "DELETE", context: "revoking a Telegram notification channel", ...sessionProofRequest(proofs) });
}
}
/**
* `r.admin.rules` — Telegram routing rules: one match (ANDed
* dimensions; an omitted dimension is a wildcard) → one Telegram binding.
* Rules govern the Telegram channel ONLY in v1 — email/webhook keep their
* existing preference-toggle semantics untouched. Mutations require
* `operator_passkey` assurance.
*/
export class Rules {
client;
constructor(client) {
this.client = client;
}
/** List the caller's routing rules, newest first. */
async list() {
return this.client.request("/agent/v1/notifications/rules", { context: "listing notification routing rules" });
}
/**
* Create a routing rule. `telegramBindingId` must reference a binding this
* caller owns and that is currently usable (`status: "active"`); an
* unusable or foreign binding id returns the same 404 as a nonexistent one
* (authorize-before-reveal).
*/
async create(input, proofs) {
gateSecret(this.client, "admin.rules.create", input, proofs);
const body = { telegram_binding_id: input.telegramBindingId };
if (input.projectId !== undefined)
body.project_id = input.projectId;
if (input.source !== undefined)
body.source = input.source;
if (input.eventTypes !== undefined)
body.event_types = input.eventTypes;
if (input.classes !== undefined)
body.classes = input.classes;
return this.client.request("/agent/v1/notifications/rules", { method: "POST", body, context: "creating a notification routing rule", ...sessionProofRequest(proofs) });
}
/**
* Patch a routing rule. PATCH semantics: only fields PRESENT on `patch`
* are sent — `{ projectId: null }` clears that dimension back to
* wildcard; omitting a field leaves it unchanged (see
* {@link UpdateRoutingRulePatch}).
*/
async update(ruleId, patch, proofs) {
gateSecret(this.client, "admin.rules.update", ruleId, patch, proofs);
const body = {};
if ("projectId" in patch)
body.project_id = patch.projectId;
if ("source" in patch)
body.source = patch.source;
if ("eventTypes" in patch)
body.event_types = patch.eventTypes;
if ("classes" in patch)
body.classes = patch.classes;
if ("telegramBindingId" in patch)
body.telegram_binding_id = patch.telegramBindingId;
if ("enabled" in patch)
body.enabled = patch.enabled;
return this.client.request(`/agent/v1/notifications/rules/${encodeURIComponent(ruleId)}`, { method: "PATCH", body, context: "updating a notification routing rule", ...sessionProofRequest(proofs) });
}
/** Delete a routing rule. */
async delete(ruleId, proofs) {
gateSecret(this.client, "admin.rules.delete", ruleId, proofs);
return this.client.request(`/agent/v1/notifications/rules/${encodeURIComponent(ruleId)}`, { method: "DELETE", context: "deleting a notification routing rule", ...sessionProofRequest(proofs) });
}
}
export class Admin {
client;
/**
* Project transfer sub-namespace — unified wallet, email, and owned-org
* project transfer surface. Access via `r.admin.transfers.{initiate,
* preview, accept, claim, cancel, listIncoming, listOutgoing}`.
*/
transfers;
/**
* Telegram notification-channel binding lifecycle. Access via
* `r.admin.channels.{connectTelegram, list, revokeTelegram}`.
*/
channels;
/**
* Telegram routing rules — one match (ANDed dimensions) to one binding.
* Access via `r.admin.rules.{list, create, update, delete}`.
*/
rules;
constructor(client) {
this.client = client;
this.transfers = new Transfers(client);
this.channels = new Channels(client);
this.rules = new Rules(client);
}
/**
* Staff-scoped sub-client for an org id — the staff analog of
* `r.org(id)`, kept on `r.admin` because these actions require platform-admin
* (`X-Admin-Mode`) auth, a different principal from the member-facing
* `r.org(id)`. Exposes `pinLease()` / `unpinLease()`. Lazy and synchronous.
*/
org(orgId) {
return new ScopedAdminOrg(this, orgId);
}
/**
* Staff-scoped sub-client for a project id. Exposes `archive(opts?)`,
* `reactivate()`, and `finance(opts?)` with the id pre-bound. Lazy and
* synchronous.
*/
project(projectId) {
return new ScopedAdminProject(this, projectId);
}
/**
* Send feedback to the Run402 developers. Requires an active tier.
*
* WRITE-ONLY: there is no inbox and no reply path. When an answer from a
* human is required, raise an escalation instead.
*
* Also the way a promotion consent is relayed: after a deploy response
* carries a `hand_to_member` next action, ask your human yes or no, and
* on yes call `sendFeedback("promote: yes", { project_id, handle })`.
*/
async sendFeedback(message, opts) {
const body = { message };
if (opts?.project_id)
body.project_id = opts.project_id;
if (opts?.handle)
body.handle = opts.handle;
return this.client.request("/feedback/v1", {
method: "POST",
body,
context: "sending feedback",
});
}
/**
* @deprecated Renamed to {@link sendFeedback}. Kept so code written against
* the old name keeps compiling; it now posts to `/feedback/v1` like its
* replacement. The `message` vocabulary is being freed for addressed
* agent/human messaging, which is a different capability with a return path.
*/
async sendMessage(message, opts) {
return this.sendFeedback(message, opts);
}
/** Register agent contact info and start email verification when needed. */
async setAgentContact(contact) {
const body = { name: contact.name };
if (contact.email)
body.email = contact.email;
if (contact.webhook)
body.webhook = contact.webhook;
return this.client.request("/agent/v1/contact", {
method: "POST",
body,
context: "setting agent contact",
});
}
/** Read the current agent contact assurance state for the wallet. */
async getAgentContactStatus() {
return this.client.request("/agent/v1/contact/status", {
method: "GET",
context: "fetching agent contact status",
});
}
/** Start or resend the contact email reply challenge. */
async verifyAgentContactEmail() {
return this.client.request("/agent/v1/contact/verify-email", {
method: "POST",
context: "starting agent contact email verification",
});
}
/** Email a passkey enrollment link to the verified contact email. */
async startContactPasskeyEnrollment() {
return this.client.request("/agent/v1/contact/passkey/enroll", {
method: "POST",
context: "starting contact passkey enrollment",
});
}
// ---------------------------------------------------------------------------
// Owner notifications (v1.55).
// ---------------------------------------------------------------------------
/** List notification audit rows (paginated, filterable). */
async listNotifications(opts = {}) {
const q = [];
if (opts.type)
q.push(`type=${encodeURIComponent(opts.type)}`);
if (opts.since)
q.push(`since=${encodeURIComponent(opts.since)}`);
if (opts.limit != null)
q.push(`limit=${opts.limit}`);
if (opts.after != null)
q.push(`after=${encodeURIComponent(opts.after)}`);
const url = q.length > 0
? `/agent/v1/notifications?${q.join("&")}`
: "/agent/v1/notifications";
return this.client.request(url, {
method: "GET",
context: "listing notifications",
});
}
/** Retrieve a single notification audit row by id. */
async getNotification(id) {
return this.client.request(`/agent/v1/notifications/${encodeURIComponent(id)}`, { method: "GET", context: "fetching notification" });
}
/** Read the current notification preferences. */
async getNotificationPreferences() {
return this.client.request("/agent/v1/notifications/preferences", { method: "GET", context: "fetching notification preferences" });
}
/** Patch notification preferences (assurance ladder applies). */
async setNotificationPreferences(patch, proofs) {
gateSecret(this.client, "admin.setNotificationPreferences", patch, proofs);
return this.client.request("/agent/v1/notifications/preferences", {
method: "PATCH",
body: patch,
context: "updating notification preferences",
...sessionProofRequest(proofs),
});
}
/**
* Trigger a real test notification. Sends a sample `project_past_due`
* event through the normal worker pipeline (email/webhook); the audit row
* is marked `is_test: true`. ALSO delivers a synthetic event through the
* caller's Telegram routing rules end-to-end (binding + rule + render +
* send) and reports a per-destination outcome in `telegram.destinations`
* — pass `opts.source` / `opts.eventType` to target a specific rule's
* filters instead of the default sample event. Rate-limited per wallet at
* 1/min.
*/
async testNotification(opts = {}) {
const body = {};
if (opts.source !== undefined)
body.source = opts.source;
if (opts.eventType !== undefined)
body.event_type = opts.eventType;
return this.client.request("/agent/v1/notifications/test", { method: "POST", body, context: "triggering test notification" });
}
/**
* Rotate the caller's webhook signing secret. The new plaintext secret
* is returned EXACTLY once. The previous secret remains valid for 24
* hours (dual-secret grace window). Requires `operator_passkey` assurance.
*/
async rotateWebhookSecret(proofs) {
gateSecret(this.client, "admin.rotateWebhookSecret", proofs);
return this.client.request("/agent/v1/webhook-secret/rotate", { method: "POST", context: "rotating webhook signing secret", ...sessionProofRequest(proofs) });
}
/**
* Fetch per-project finance for staff.
*
* This is the same admin-only surface used by the Run402 Finance tab. It is
* gated by platform-admin auth; project service keys are not sufficient.
* Use the Node SDK with an admin wallet, pass `cookie` for browser
* session auth, or provide a credential provider whose `getAuth()` returns
* suitable admin headers.
*/
async getProjectFinance(projectId, opts = {}) {
const window = opts.window ?? "30d";
if (!FINANCE_WINDOWS.has(window)) {
throw new LocalError(`Invalid finance window: ${String(window)}. Expected one of: 24h, 7d, 30d, 90d.`, "fetching project finance");
}
const headers = { "X-Admin-Mode": "1" };
if (opts.cookie)
headers.Cookie = opts.cookie;
return this.client.request(`/admin/api/finance/project/${encodeURIComponent(projectId)}?window=${encodeURIComponent(window)}`, {
headers,
context: "fetching project finance",
});
}
// -------------------------------------------------------------------------
// Staff-only project + organization actions (v1.57).
// -------------------------------------------------------------------------
/**
* Toggle a organization's `lease_perpetual` flag — the staff escape
* hatch that pins every project in the organization (replaces the v1.56
* per-project `pin` removed in v1.57). When enabling on an organization in a
* grace state, the gateway reactivates inline and reports it via
* `reactivated: true`.
*
* Platform-admin only. Calls
* `POST /orgs/v1/admin/:org_id/lease-perpetual`.
*/
/**
* Shared, non-deprecated implementation behind {@link setLeasePerpetual} and
* the `r.admin.org(id).pinLease()/unpinLease()` handle.
* @internal
*/
_setLeasePerpetual(organizationId, perpetual) {
return this.client.request(`/orgs/v1/admin/${encodeURIComponent(organizationId)}/lease-perpetual`, {
method: "POST",
headers: { "X-Admin-Mode": "1" },
body: { lease_perpetual: perpetual },
context: "setting organization lease_perpetual",
});
}
/**
* Staff moderation action — archive a single project (ToS / abuse).
* Sets `projects.archived_at` to NOW(). Independent of organization-level
* lifecycle; the rest of the organization's projects continue serving.
*
* Platform-admin only. Calls `POST /projects/v1/admin/:id/archive`.
*/
async archiveProject(projectId, opts = {}) {
const body = {};
if (opts.reason !== undefined)
body.reason = opts.reason;
return this.client.request(`/projects/v1/admin/${encodeURIComponent(projectId)}/archive`, {
method: "POST",
headers: { "X-Admin-Mode": "1" },
body,
context: "archiving project",
});
}
/**
* Staff "un-archive" — flips `projects.archived_at` back to NULL. It does
* not touch organization-level lifecycle. To reactivate a grace-state
* organization, either set a tier or set `lease_perpetual: true`
* via {@link setLeasePerpetual}.
*
* Platform-admin only. Calls `POST /projects/v1/admin/:id/reactivate`.
*/
async reactivateProject(projectId) {
return this.client.request(`/projects/v1/admin/${encodeURIComponent(projectId)}/reactivate`, {
method: "POST",
headers: { "X-Admin-Mode": "1" },
context: "reactivating project",
});
}
}
/**
* Staff-scoped sub-client for a single org, returned by `r.admin.org(id)`.
* Replaces the boolean `admin.setLeasePerpetual(orgId, perpetual)` with two
* intent-named verbs. Carries platform-admin auth; staff-only errors surface
* at call time.
*/
export class ScopedAdminOrg {
admin;
orgId;
constructor(admin, orgId) {
this.admin = admin;
this.orgId = orgId;
}
/** Pin the org's lease (`lease_perpetual = true`). */
pinLease() {
return this.admin._setLeasePerpetual(this.orgId, true);
}
/** Unpin the org's lease (`lease_perpetual = false`). */
unpinLease() {
return this.admin._setLeasePerpetual(this.orgId, false);
}
}
/**
* Staff-scoped sub-client for a single project, returned by
* `r.admin.project(id)`. The project id is pre-bound; methods carry
* platform-admin auth.
*/
export class ScopedAdminProject {
admin;
projectId;
constructor(admin, projectId) {
this.admin = admin;
this.projectId = projectId;
}
/** Archive the project (staff moderation). */
archive(opts = {}) {
return this.admin.archiveProject(this.projectId, opts);
}
/** Un-archive the project. */
reactivate() {
return this.admin.reactivateProject(this.projectId);
}
/** Read per-project finance (staff Finance tab). */
finance(opts = {}) {
return this.admin.getProjectFinance(this.projectId, opts);
}
}
//# sourceMappingURL=admin.js.map