UNPKG

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