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.
562 lines • 26.4 kB
JavaScript
/**
* `projects` namespace — project lifecycle, introspection, and admin.
*
* Covers:
* - remote: provision, delete, list, getUsage, getSchema, getQuote
* - local: info, keys, use (require provider support for persistence methods)
*
* Staff-only project actions live on the {@link Admin} namespace:
* - `admin.archiveProject` / `admin.reactivateProject` — moderate-archive
* - `admin.setLeasePerpetual` — organization-level escape hatch (replaces the
* v1.56 `projects.pin` removed in v1.57)
*/
import { gateSecret } from "../secret-gate.js";
import { ProjectStripe } from "./stripe.js";
import { restDiagnostic } from "../rest-diagnostics.js";
import { LocalError } from "../errors.js";
import { requireProjectCredentials } from "../project-credentials.js";
import { usableProjectKeys, withPrincipalFallback } from "../stale-project-key.js";
function normalizeListProjectsResult(result) {
const rows = Array.isArray(result.projects) ? result.projects : [];
return {
...result,
projects: rows.map((row) => {
const raw = row;
const { project_id: projectId, id, ...rest } = raw;
return { id: id ?? projectId ?? "", ...rest };
}),
};
}
// The SQL response is returned VERBATIM in the wire shape:
// { status, schema, rows, row_count, fields, statements, warnings } (snake_case, docs/style.md).
// A former normalization here renamed row_count -> rowCount, which made the
// SDK the odd layer out — the gateway, the in-function `adminDb().sql()`
// runtime, and the CLI all speak row_count. One logical operation, one shape.
function normalizeExposeValidationResult(value) {
if ("hasErrors" in value)
return value;
return {
...(value.effective_access ? { effective_access: value.effective_access } : {}),
hasErrors: value.has_errors === true,
errors: Array.isArray(value.errors) ? value.errors : [],
warnings: Array.isArray(value.warnings) ? value.warnings : [],
};
}
export class Projects {
client;
schema;
usage;
quote;
promoteUser;
demoteUser;
/** The organization's Stripe account and Checkout / portal sessions for a project's app. */
stripe;
constructor(client) {
this.client = client;
this.stripe = new ProjectStripe(client);
this.schema = this.getSchema.bind(this);
this.usage = this.getUsage.bind(this);
this.quote = this.getQuote.bind(this);
const role = async (id, email, action, context) => {
const keys = await requireProjectCredentials(this.client, id, context);
await this.client.request(`/projects/v1/admin/${id}/${action}`, {
method: "POST",
headers: { Authorization: `Bearer ${keys.service_key}` },
body: { email },
context,
});
};
this.promoteUser = (id, email) => role(id, email, "promote-user", "promoting user");
this.demoteUser = (id, email) => role(id, email, "demote-user", "demoting user");
}
/**
* Provision a new Postgres project. Requires SIWX auth; payment
* flows through the configured fetch wrapper (x402 in Node, session
* budget in sandbox). Returned keys are persisted to the local store
* when the credential provider supports it.
*
* @throws {PaymentRequired} when the wallet has insufficient balance and
* the fetch wrapper cannot sign the 402 retry.
*/
async provision(opts = {}) {
gateSecret(this.client, "projects.provision", opts);
const body = {};
if (opts.tier !== undefined)
body.tier = opts.tier;
if (opts.name !== undefined)
body.name = opts.name;
if (opts.orgId !== undefined)
body.org_id = opts.orgId;
const result = await this.client.request("/projects/v1", {
method: "POST",
body,
// Retry-safety: a caller-supplied key collapses a re-run (the agent's
// natural mode after a crash) onto the same project instead of duplicating.
...(opts.idempotencyKey ? { headers: { "Idempotency-Key": opts.idempotencyKey } } : {}),
// Write-approval scope: creating a project in an org is `org.project.create`
// targeting that org. Only meaningful when provisioning into an existing org.
...(opts.orgId
? {
authMeta: {
method: "projects.provision",
capability: "org.project.create",
target: { org_id: opts.orgId },
},
}
: {}),
context: "provisioning project",
});
// Persist the new project and set it active, if the provider supports it.
const creds = this.client.credentials;
if (creds.saveProject) {
await creds.saveProject(result.project_id, {
anon_key: result.anon_key,
service_key: result.service_key,
...(result.endpoints?.static_base_url ? { site_url: result.endpoints.static_base_url } : {}),
});
}
if (creds.setActiveProject) {
await creds.setActiveProject(result.project_id);
}
return result;
}
/**
* Immediately and irreversibly delete a project. Triggers the full
* destructive cascade (drop tenant schema, delete Lambda functions,
* release subdomains, tombstone mailbox, wipe secrets). Local keystore
* is cleaned via the credential provider when supported.
*
* @throws {ProjectCredentialNotFound} if local project credentials are absent.
*/
async delete(id) {
const keys = await requireProjectCredentials(this.client, id, "deleting project");
await this.client.request(`/projects/v1/${id}`, {
method: "DELETE",
headers: { Authorization: `Bearer ${keys.service_key}` },
context: "deleting project",
});
const creds = this.client.credentials;
if (creds.removeProject)
await creds.removeProject(id);
}
/**
* List projects in the named, domain-aware inventory (gateway
* `project-findability`). Each row carries `name`, `site_url`,
* `custom_domains`, `org_id` (the owning org), `status`, and
* `created_at`.
*
* Membership-scoped by default (`GET /projects/v1`): returns every project
* owned by an org the caller's principal is an active member of. This is the
* cold-start lone-agent path — `list()` with no options, authenticated with
* SIWX wallet auth from the credential provider.
*
* - `{ org }` narrows to one owning org (authorize-before-reveal: a non-member
* or guessed id is a 403, a non-UUID id a 400).
* - `{ all: true }` reads every project the caller can reach across all its
* organizations (`GET /agent/v1/me/projects`). Pass `{ all: true, token }`
* with a sign-in session token for the person's account; without `token`,
* `all` uses the credential provider (a SIWX wallet reads its own slice).
* The response echoes the resolved `scope` (`"principal"` or `"wallet"`)
* and is unpaged.
* - `{ limit, cursor }` paginate the membership-scoped read (server default
* 50, max 200).
*
* @throws {Run402Error} with `context: "listing projects"` on the usual
* auth/network failures (e.g. `Unauthorized` when no local wallet is
* configured and the gateway rejects the missing SIWX header).
*/
async list(opts = {}) {
gateSecret(this.client, "projects.list", opts);
if (opts.all && opts.org !== undefined) {
throw new LocalError("projects.list({ all, org }): `all` (every organization) and `org` (single-org filter) are mutually exclusive.", "listing projects");
}
if (opts.all) {
// Account-wide inventory (`--all`). With a sign-in session token the
// gateway returns every project the person can reach; without one, the
// credential provider's wallet reads its own slice (same row shape).
const headers = opts.token ? { Authorization: `Bearer ${opts.token}` } : undefined;
const result = await this.client.request("/agent/v1/me/projects", {
context: "listing projects",
...(headers ? { headers, withAuth: false } : {}),
});
return normalizeListProjectsResult(result);
}
// Membership-scoped named inventory (`GET /projects/v1`). SIWX wallet auth
// is mandatory server-side; the credential provider supplies the header.
const qs = new URLSearchParams();
if (opts.org !== undefined)
qs.set("org_id", opts.org);
if (opts.limit !== undefined)
qs.set("limit", String(opts.limit));
if (opts.cursor !== undefined)
qs.set("after", opts.cursor);
const query = qs.toString();
const result = await this.client.request(`/projects/v1${query ? `?${query}` : ""}`, {
context: "listing projects",
});
return normalizeListProjectsResult(result);
}
/**
* Rename a project (gateway `project-findability`, `PATCH /projects/v1/:id`).
* Surfaces the project's `name` so a human can fix an auto-generated label.
*
* Authorization is org-membership based (`admin`+ on the owning org, or a
* `project:write` grant) and authorize-before-reveal — an unauthorized caller
* (including a guessed id) gets the same `Unauthorized` as a real-but-
* unauthorized project, never a not-found oracle. The server validates the
* name (non-empty, ≤ 200 chars, no control characters).
*
* Uses the caller's SIWX wallet auth (or a control-plane session) from the
* credential provider — not a project service key — so it works without the
* project being in the local keystore.
*
* @throws {Unauthorized} when the caller is not authorized for the project.
* @throws {ApiError} (HTTP 400) when the new name is invalid.
*/
async rename(projectId, name) {
return this.client.request(`/projects/v1/${projectId}`, {
method: "PATCH",
body: { name },
context: "renaming project",
});
}
/**
* Claim or rename this project's per-org-unique, address-form name
* (`POST /projects/v1/:id/repo-name`) — the
* `<name>` half of `run402::<org-slug>/<name>`. Distinct from
* {@link rename} (the free-text display name, unchanged): the address-form
* name is charset-restricted (`[a-z0-9-]`, ≤63 chars) and per-org-unique.
* No fee, unlike the org-slug namespace. Same authority as `rename`
* (`project.rename`).
*/
async setRepoName(projectId, name) {
return this.client.request(`/projects/v1/${projectId}/repo-name`, {
method: "POST",
body: { name },
context: "setting the project's repo name",
});
}
/**
* List redacted tenant x402 payment records for priced function web routes
* (`GET /projects/v1/:project_id/tenant-payments`). Requires
* project.tenant_payments.read server-side: org developer+ or read-scoped
* project grant or grant key.
*/
async listTenantPayments(projectId, opts = {}) {
const qs = new URLSearchParams();
if (opts.limit !== undefined)
qs.set("limit", String(opts.limit));
if (opts.after !== undefined)
qs.set("after", opts.after);
if (opts.status !== undefined)
qs.set("status", opts.status);
const query = qs.toString();
return this.client.request(`/projects/v1/${encodeURIComponent(projectId)}/tenant-payments${query ? `?${query}` : ""}`, { context: "listing tenant payments" });
}
/**
* Get usage metrics for a project — API calls, storage, tier limits,
* lease expiry.
*/
async getUsage(id) {
const keys = await requireProjectCredentials(this.client, id, "fetching usage");
return this.client.request(`/projects/v1/admin/${id}/usage`, {
headers: { Authorization: `Bearer ${keys.service_key}` },
context: "fetching usage",
});
}
/**
* Introspect the project's database schema — tables, columns, types,
* constraints, and RLS policies. Uses the cached service key when this
* machine has one, otherwise your own authority (`project.database.read`:
* org role viewer or higher, or a `database:read` grant). A cached key the
* gateway refuses is retried once as you, like {@link sql}.
*/
async getSchema(id) {
const keys = await usableProjectKeys(this.client, id);
return withPrincipalFallback(this.client, id, keys, (k) => this.client.request(`/projects/v1/admin/${id}/schema`, {
...(k ? { headers: { Authorization: `Bearer ${k.service_key}` } } : {}),
context: "fetching schema",
}));
}
/** Run SQL against the project's database (`POST /projects/v1/:project_id/sql`).
* Uses the cached service key when this machine has one; otherwise the
* request goes as you (wallet, sign-in session, or grant key) and the
* gateway authorizes it by your role: `project.database.read` (viewer or
* higher, or `database:read`) runs read-only, enforced by PostgreSQL;
* `project.database.write` (developer or higher, or `database:write`) can
* write. A cached key the gateway refuses (expired, revoked, unknown) is
* retried once as you; with no principal to retry as, the refusal names
* `run402 credentials project-keys delete --project <id>` as its first next
* action. `{ readOnly: true }` runs the call read-only for any caller (one
* statement, a write answers `DATABASE_READ_ONLY`). Returns the gateway envelope
* verbatim: `{ status, schema, rows, row_count, fields, statements,
* warnings }`. `rows` and `fields` come from the last statement and
* `row_count` is that statement's count; `statements[]` carries each
* statement's `{ command, row_count }` for a multi-statement text.
* `warnings[]` never changes the result: `SCHEMA_CHANGE_OUTSIDE_MIGRATION`
* means a schema change on a released project belongs in a migration, and
* `MULTI_STATEMENT_TEXT_BODY` points at {@link sqlBatch}. */
async sql(id, sql, params, opts = {}) {
const keys = await usableProjectKeys(this.client, id);
const useParams = Array.isArray(params) && params.length > 0;
const readOnly = opts.readOnly === true;
const json = useParams || readOnly;
return withPrincipalFallback(this.client, id, keys, (k) => this.client.request(`/projects/v1/${id}/sql`, {
method: "POST",
headers: {
...(k ? { Authorization: `Bearer ${k.service_key}` } : {}),
"Content-Type": json ? "application/json" : "text/plain",
},
body: json ? { sql, ...(useParams ? { params } : {}), ...(readOnly ? { read_only: true } : {}) } : undefined,
rawBody: json ? undefined : sql,
context: "running SQL",
}));
}
/** Run several statements (1 to 100, one statement each) with an explicit
* transaction (`POST /projects/v1/:project_id/sql/batch`). With
* `transaction: "all"` (default) the first failure rolls back the batch and
* throws `SQL_BATCH_STATEMENT_FAILED` naming the statement's index; with
* `"each"` every statement commits on its own and `results[i].status` says
* how each went. */
async sqlBatch(id, statements, opts = {}) {
const keys = await requireProjectCredentials(this.client, id, "running a SQL batch");
return this.client.request(`/projects/v1/${id}/sql/batch`, {
method: "POST",
headers: { Authorization: `Bearer ${keys.service_key}` },
body: { statements, transaction: opts.transaction ?? "all" },
context: "running a SQL batch",
});
}
/** Query or mutate a project table through PostgREST. */
async rest(id, table, options) {
return (await this.restResponse(id, table, options)).body;
}
/** Query or mutate a project table through PostgREST and preserve HTTP status. */
async restResponse(id, table, options) {
const opts = options ?? {};
const method = opts.method ?? "GET";
const query = formatRestQuery(opts.query);
const serviceRequested = opts.keyType === "service";
const keys = serviceRequested
? await usableProjectKeys(this.client, id)
: await this.client.getProjectCredentials(id);
// No cached key and no explicit anon request: go as yourself on the
// project REST route, authorized by your role (GET needs
// project.database.read, other methods project.database.write) with
// database authority, like keyType "service". An explicit anon read
// needs the anon key.
const resolved = keys ?? (opts.keyType !== "anon" ? null : await requireProjectCredentials(this.client, id, "querying REST"));
const send = (k) => {
const asPrincipal = !k && opts.keyType !== "anon";
const useService = asPrincipal || serviceRequested;
const headers = {};
if (k) {
const key = useService ? k.service_key : k.anon_key;
headers.apikey = key;
headers.Authorization = `Bearer ${key}`;
}
if (method !== "GET")
headers.Prefer = "return=representation";
// The gateway rejects the service_role on the public PostgREST path
// (/rest/v1/*), so service-key REST is routed through the project's
// service REST route (/projects/v1/:project_id/rest/*). Anon keys use the
// public path.
const path = useService
? `/projects/v1/${id}/rest/${encodeURIComponent(table)}${query}`
: `/rest/v1/${encodeURIComponent(table)}${query}`;
return this.client.requestWithResponse(path, {
method, headers, body: opts.body, context: "querying REST", withAuth: asPrincipal,
});
};
try {
// A refused cached service key falls back to your principal on the same
// project REST route; an anon read has no principal equivalent.
return serviceRequested && resolved
? await withPrincipalFallback(this.client, id, resolved, send)
: await send(resolved);
}
catch (error) {
throw restDiagnostic(error, method, table);
}
}
/** Apply the project's declarative expose manifest. */
async applyExpose(id, manifest) {
const keys = await requireProjectCredentials(this.client, id, "applying expose manifest");
return this.client.request(`/projects/v1/admin/${id}/expose`, {
method: "POST",
headers: { Authorization: `Bearer ${keys.service_key}` },
body: manifest,
context: "applying expose manifest",
});
}
/**
* Validate an authorization/expose manifest without applying it.
*
* When `opts.project` / `opts.project_id` is supplied, the gateway validates
* against that project's live schema using its service key. Otherwise it
* performs projectless wallet-auth validation. `migrationSql` is validation
* context only and is never executed.
*/
async validateExpose(manifest, opts = {}) {
const project = normalizeValidationProject(opts);
const parsed = parseExposeManifestValidationInput(manifest);
if ("hasErrors" in parsed)
return parsed;
const body = { manifest: parsed.manifest };
if (opts.migrationSql !== undefined)
body.migration_sql = opts.migrationSql;
if (project) {
const keys = await requireProjectCredentials(this.client, project, "validating expose manifest");
const result = await this.client.request(`/projects/v1/admin/${project}/expose/validate`, {
method: "POST",
headers: { Authorization: `Bearer ${keys.service_key}` },
body,
context: "validating expose manifest",
withAuth: false,
});
return normalizeExposeValidationResult(result);
}
const result = await this.client.request("/projects/v1/expose/validate", {
method: "POST",
body,
context: "validating expose manifest",
});
return normalizeExposeValidationResult(result);
}
/** Fetch the project's current expose manifest. */
async getExpose(id) {
const keys = await requireProjectCredentials(this.client, id, "getting expose manifest");
return this.client.request(`/projects/v1/admin/${id}/expose`, {
headers: { Authorization: `Bearer ${keys.service_key}` },
context: "getting expose manifest",
});
}
/**
* Get tier pricing — prices, lease durations, storage limits, API-call
* limits. Public, no auth, no payment.
*/
async getQuote() {
return this.client.request("/tiers/v1", {
context: "getting quote",
withAuth: false,
});
}
/**
* Authoritative single-project read — `GET /projects/v1/:id` (gateway
* `project.read`). Returns the server-side {@link ProjectDetail} (identity,
* owning org, tier, lifecycle, site_url + custom domains, active-release
* pointer, mailbox addresses, usage vs. tier limits). Carries no secrets.
*
* Uses the default credential (wallet SIWX or control-plane session) and does
* NOT require the project to be in the local keystore — read a project you own
* via org membership without provisioning it locally. Authorize-before-reveal:
* a forbidden or absent project both surface as `Unauthorized`, never a 404.
* For the local anon/service keys use {@link Projects.keys} instead.
*/
async get(id) {
return this.client.request(`/projects/v1/${id}`, {
context: "getting project",
});
}
/**
* Inspect a project from local state. Combines the project id with the
* stored keys. Does not make an API call.
*
* @throws {ProjectCredentialNotFound} if local project credentials are absent.
*/
async info(id) {
gateSecret(this.client, "projects.info", id);
const keys = await requireProjectCredentials(this.client, id, "fetching project info");
return { project_id: id, ...keys };
}
/**
* Return the stored anon/service keys for a project from local state.
* Does not make an API call.
*
* @throws {ProjectCredentialNotFound} if local project credentials are absent.
*/
async keys(id) {
gateSecret(this.client, "projects.keys", id);
return requireProjectCredentials(this.client, id, "fetching project keys");
}
/**
* Set the active/default project in local state. Requires the credential
* provider to support `setActiveProject`.
*
* Returns `void`. To both persist the active project AND get a project-scoped
* client in one call, use {@link Run402.useProject} instead:
*
* const p = await r.useProject("prj_xxx");
* await p.apply.apply({ site: { ... } });
*
* @throws {Run402Error} if the authoritative project read is not allowed.
* @throws {LocalError} if the provider does not support active-project state.
*/
async use(id) {
await this.get(id);
const setter = this.client.credentials.setActiveProject;
if (!setter) {
throw new LocalError("This credential provider does not support setActiveProject — `use` is only available with providers that track local active-project state.", "setting active project");
}
await setter.call(this.client.credentials, id);
}
/**
* Return the active/default project id in local state, or null when none
* is set or the provider does not track active-project state.
*
* To resolve the active project AND build a scoped sub-client in one call,
* use {@link Run402.project} (no arg):
*
* const p = await r.project(); // throws if no active project is set
* await p.functions.list();
*/
async active() {
const getter = this.client.credentials.getActiveProject;
if (!getter)
return null;
return getter.call(this.client.credentials);
}
}
function formatRestQuery(query) {
if (query === undefined)
return "";
if (typeof query === "string") {
if (!query)
return "";
return query.startsWith("?") ? query : `?${query}`;
}
const sp = new URLSearchParams(query);
const out = sp.toString();
return out ? `?${out}` : "";
}
function normalizeValidationProject(opts) {
if (opts.project !== undefined &&
opts.project_id !== undefined &&
opts.project !== opts.project_id) {
throw new LocalError("Pass only one project context to projects.validateExpose(): `project` and `project_id` differ.", "validating expose manifest");
}
return opts.project ?? opts.project_id;
}
function parseExposeManifestValidationInput(manifest) {
if (typeof manifest !== "string")
return { manifest };
try {
return { manifest: JSON.parse(manifest) };
}
catch (err) {
const message = err instanceof Error ? err.message : String(err);
return {
hasErrors: true,
errors: [
{
type: "schema-shape",
severity: "error",
detail: `Expose manifest JSON is invalid: ${message}`,
fix: "Pass a JSON object matching the expose manifest v1 schema before running semantic validation.",
},
],
warnings: [],
};
}
}
//# sourceMappingURL=projects.js.map