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.
195 lines • 9.67 kB
JavaScript
/**
* `transfers` namespace — the unified project-transfer noun (v1.93+;
* owned-org recipient shape v1.96+).
*
* Exposed as `r.admin.transfers.*`. Project transfer is ONE capability,
* body-discriminated by recipient kind: a wallet recipient (`toWallet`, SIWX
* bilateral signing), an email recipient (`toEmail`, the recipient accepts into
* an org), or an owned org recipient (`toOrgId`, same-actor org move). All ride
* the same `/transfers` surface — there is no separate `/handoffs` noun (the
* gateway removed it in `unify-project-transfer-surface`).
*
* Gateway endpoints:
* POST /projects/v1/:project_id/transfers — initiate; body { to_wallet } XOR { to_email } XOR { to_org_id }
* GET /agent/v1/transfers/incoming — inbox (pending kinds, unioned)
* GET /agent/v1/transfers/outgoing — outbox (pending kinds, unioned)
* GET /agent/v1/transfers/:transfer_id — preview (kind-agnostic)
* POST /agent/v1/transfers/:transfer_id/accept — the ONE completion; the row's recipient kind
* picks the credential (wallet: to_wallet's SIWX;
* email: a principal whose verified email matches,
* body { org_id?, accept_retained_member? })
* POST /agent/v1/transfers/:transfer_id/cancel — cancel (kind-agnostic)
*
* Owner-side mutations against a project with a pending transfer return
* 409 `PROJECT_HAS_PENDING_TRANSFER`. The SDK kernel surfaces that as
* {@link TransferFreezeError} so agents can guide the user to cancel. Accepting
* with the wrong credential for a row's kind (e.g. a wallet signature on an
* email-addressed row) returns 409 `WRONG_COMPLETION_FOR_TRANSFER_KIND`; the
* thrown error exposes an `accept_transfer` next action naming the credential
* the row needs, on the SAME `transfer_id`.
*/
import { gateSecret } from "../secret-gate.js";
import { LocalError } from "../errors.js";
// ─── Class ───────────────────────────────────────────────────────────────────
export class Transfers {
client;
constructor(client) {
this.client = client;
}
async initiate(input) {
gateSecret(this.client, "admin.transfers.initiate", input);
const toWallet = "toWallet" in input ? input.toWallet : undefined;
const toEmail = "toEmail" in input ? input.toEmail : undefined;
const toOrgId = "toOrgId" in input ? input.toOrgId : undefined;
const hasWallet = typeof toWallet === "string" && toWallet.length > 0;
const hasEmail = typeof toEmail === "string" && toEmail.length > 0;
const hasOrg = typeof toOrgId === "string" && toOrgId.length > 0;
const recipientCount = Number(hasWallet) + Number(hasEmail) + Number(hasOrg);
if (recipientCount !== 1) {
throw new LocalError("Provide exactly one of toWallet, toEmail, or toOrgId.", "initiating project transfer", { code: "VALIDATION_ERROR", details: { fields: ["toWallet", "toEmail", "toOrgId"] } });
}
const path = `/projects/v1/${encodeURIComponent(input.projectId)}/transfers`;
if (hasOrg) {
rejectDefinedField(input, "billingPolicy", "org");
rejectDefinedField(input, "kysignedRecordId", "org");
rejectDefinedField(input, "retainMember", "org");
const body = { to_org_id: toOrgId };
if (input.message !== undefined)
body.message = input.message;
const result = await this.client.request(path, {
method: "POST",
body,
context: "initiating project transfer",
});
await persistProjectKeys(this.client, result);
return result;
}
if (hasEmail) {
rejectDefinedField(input, "billingPolicy", "email");
rejectDefinedField(input, "kysignedRecordId", "email");
const body = { to_email: toEmail };
if (input.message !== undefined)
body.message = input.message;
const retain = input.retainMember;
if (retain !== undefined)
body.retain_member = retain;
return this.client.request(path, {
method: "POST",
body,
context: "initiating project transfer",
});
}
const w = input;
rejectDefinedField(w, "retainMember", "wallet");
const body = { to_wallet: toWallet };
if (w.billingPolicy !== undefined)
body.billing_policy = w.billingPolicy;
if (w.message !== undefined)
body.message = w.message;
if (w.kysignedRecordId !== undefined)
body.kysigned_record_id = w.kysignedRecordId;
return this.client.request(path, {
method: "POST",
body,
context: "initiating project transfer",
});
}
/**
* Fetch the preview document for a pending or terminal transfer of either
* kind. The caller must be a party to it (wallet signer, addressed-email
* principal, or offering-org member); other callers receive 403. Preview
* lists secret NAMES (not values), custom domains, functions, CI bindings
* that will be revoked at completion, the billing implications, and — on
* email rows — the `retain_member` offer.
*/
async preview(transferId) {
return this.client.request(`/agent/v1/transfers/${encodeURIComponent(transferId)}`, { context: "previewing project transfer" });
}
/**
* Accept an incoming transfer — the ONE completion, whatever the row's
* address. A wallet-addressed row is accepted by the `to_wallet`'s SIWX
* signature; an email-addressed row by a principal (sign-in session or
* SIWX) whose verified email matches, into an org it owns (`orgId`) or,
* omitted, a newly created wallet-less org. Both paths run the same atomic
* transaction: ownership flip, grant/CI revoke, notification enqueue, the
* persistent `secrets_rotation_advised` advisory, and an audit row. Both
* return the new owner's project keys, which `accept` persists to the
* keystore so the new owner can operate the project immediately.
*/
async accept(transferId, opts = {}) {
gateSecret(this.client, "admin.transfers.accept", transferId, opts);
const body = {};
if (opts.orgId !== undefined)
body.org_id = opts.orgId;
if (opts.acceptRetainedMember !== undefined)
body.accept_retained_member = opts.acceptRetainedMember;
const result = await this.client.request(`/agent/v1/transfers/${encodeURIComponent(transferId)}/accept`, {
method: "POST",
body,
context: "accepting project transfer",
});
await persistProjectKeys(this.client, result);
return result;
}
/**
* Cancel a pending transfer of any kind. The caller must be authorized for
* the row's kind (a wallet signer, or an owner/admin of the offering org /
* the addressed-email principal). Already-processed transfers return 409
* `TRANSFER_ALREADY_PROCESSED`.
*/
async cancel(transferId, opts) {
const reason = opts?.reason;
const body = {};
if (reason !== undefined)
body.reason = reason;
return this.client.request(`/agent/v1/transfers/${encodeURIComponent(transferId)}/cancel`, {
method: "POST",
body,
context: "cancelling project transfer",
});
}
/** Pending transfers OFFERED TO the caller — wallet/email/future org rows, unioned. */
async listIncoming(opts = {}) {
const q = buildPagination(opts);
const path = q ? `/agent/v1/transfers/incoming?${q}` : "/agent/v1/transfers/incoming";
return this.client.request(path, {
context: "listing incoming transfers",
});
}
/** Pending transfers INITIATED BY the caller — wallet/email/future org rows, unioned. */
async listOutgoing(opts = {}) {
const q = buildPagination(opts);
const path = q ? `/agent/v1/transfers/outgoing?${q}` : "/agent/v1/transfers/outgoing";
return this.client.request(path, {
context: "listing outgoing transfers",
});
}
}
async function persistProjectKeys(client, result) {
if (result.project_id && result.anon_key && result.service_key) {
const creds = client.credentials;
if (creds.saveProject) {
await creds.saveProject(result.project_id, {
anon_key: result.anon_key,
service_key: result.service_key,
});
}
if (creds.setActiveProject) {
await creds.setActiveProject(result.project_id);
}
}
}
function rejectDefinedField(input, field, recipient) {
if (input[field] !== undefined) {
throw new LocalError(`${field} is not supported for ${recipient}-addressed project transfers.`, "initiating project transfer", { code: "VALIDATION_ERROR", details: { field, recipient } });
}
}
function buildPagination(opts) {
const parts = [];
if (opts.limit !== undefined)
parts.push(`limit=${encodeURIComponent(String(opts.limit))}`);
if (opts.after !== undefined)
parts.push(`after=${encodeURIComponent(opts.after)}`);
return parts.join("&");
}
//# sourceMappingURL=transfers.js.map