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.

407 lines • 19.8 kB
/** CI/OIDC federation namespace and canonical delegation helpers. */ import { gateSecret } from "../secret-gate.js"; import { LocalError, Run402DeployError, isRun402Error } from "../errors.js"; import { CI_AUDIENCE, CI_BINDING_REVOKED_ERROR, CI_GITHUB_ACTIONS_ISSUER, CI_GITHUB_ACTIONS_PROVIDER, } from "./ci.types.js"; export { CI_AUDIENCE, CI_BINDING_REVOKED_ERROR, CI_GITHUB_ACTIONS_ISSUER, CI_GITHUB_ACTIONS_PROVIDER, DEFAULT_CI_DELEGATION_CHAIN_ID, V1_CI_ALLOWED_ACTIONS, V1_CI_ALLOWED_EVENTS_DEFAULT, } from "./ci.types.js"; const TOKEN_EXCHANGE_GRANT_TYPE = "urn:ietf:params:oauth:grant-type:token-exchange"; const TOKEN_EXCHANGE_SUBJECT_TOKEN_TYPE = "urn:ietf:params:oauth:token-type:jwt"; const MAX_SUBJECT_MATCH_CHARS = 256; const MAX_RESOURCE_URI_BYTES = 4096; const MAX_STATEMENT_BYTES = 8192; const CI_ROUTE_SCOPE_LIMIT = 100; const CI_ROUTE_SCOPE_BYTE_LIMIT = 256; const NONCE_RE = /^[0-9a-f]{16,64}$/; const CI_DEPLOY_SPEC_ALLOWED_KEYS = new Set([ "project_id", "database", "functions", "site", "base", "routes", // The gateway's V1_CI_DEPLOY_SPEC_ALLOWLIST permits the `assets` slice // (gated per-entry by the binding's `asset_key_scopes`). Omitting it from // this SDK-side allowlist would make the local pre-validation reject CI // asset uploads before the gateway ever saw them. "assets", ]); export class Ci { client; constructor(client) { this.client = client; } async createBinding(input) { if (input?.provider !== CI_GITHUB_ACTIONS_PROVIDER) { throw new LocalError('ci.createBinding provider must be "github-actions" in v1', "creating CI binding"); } if (!input.signed_delegation) { throw new LocalError("ci.createBinding requires signed_delegation", "creating CI binding"); } const values = normalizeCiDelegationValues(input); const body = { project_id: values.project_id, provider: input.provider, subject_match: values.subject_match, allowed_actions: values.allowed_actions, allowed_events: values.allowed_events, github_repository_id: values.github_repository_id, expires_at: values.expires_at, nonce: values.nonce, signed_delegation: input.signed_delegation, }; if (values.route_scopes.length > 0) { body.route_scopes = values.route_scopes; } return this.client.request("/ci/v1/bindings", { method: "POST", body, context: "creating CI binding", }); } async listBindings(input) { if (!input?.project) { throw new LocalError("ci.listBindings requires { project }", "listing CI bindings"); } const qs = new URLSearchParams({ project: input.project }); return this.client.request(`/ci/v1/bindings?${qs.toString()}`, { context: "listing CI bindings" }); } async getBinding(bindingId) { if (!bindingId) { throw new LocalError("ci.getBinding requires a binding id", "getting CI binding"); } return this.client.request(`/ci/v1/bindings/${encodeURIComponent(bindingId)}`, { context: "getting CI binding" }); } async revokeBinding(bindingId) { if (!bindingId) { throw new LocalError("ci.revokeBinding requires a binding id", "revoking CI binding"); } return this.client.request(`/ci/v1/bindings/${encodeURIComponent(bindingId)}/revoke`, { method: "POST", context: "revoking CI binding" }); } /** * Replace the asset_key_scopes on an existing CI binding. * * The gateway gates `spec.assets` (in plan / commit / direct uploads * routed through CI sessions) by per-binding scopes (closed-by-default). * Existing bindings created before v1.48 — and every binding created * via `createBinding()`, which doesn't yet accept the parameter — * default to no scopes, blocking `client.assets.put` under CI auth. * This method grants scopes after the fact. * * Scope strings: * - `"astro/*"` — wildcard prefix; matches every key under `astro/`. * Use this when the consumer uploads under a known prefix * (the @run402/astro integration uses `astro/` by default). * - `"astro/hero.jpg"` — exact key match. Most callers want the * wildcard form. * * Authorization: same as revokeBinding — the caller's SIWE wallet * must match the binding's project owner. Idempotent: passing the * same scope list twice is a no-op (returns the same row). */ async setAssetKeyScopes(bindingId, assetKeyScopes) { if (!bindingId) { throw new LocalError("ci.setAssetKeyScopes requires a binding id", "updating CI binding asset_key_scopes"); } if (!Array.isArray(assetKeyScopes)) { throw new LocalError("ci.setAssetKeyScopes requires asset_key_scopes as an array", "updating CI binding asset_key_scopes"); } return this.client.request(`/ci/v1/bindings/${encodeURIComponent(bindingId)}/asset-scopes`, { method: "POST", body: { asset_key_scopes: assetKeyScopes }, context: "updating CI binding asset_key_scopes", }); } async exchangeToken(input) { gateSecret(this.client, "ci.exchangeToken", input); if (!input?.project_id || !input.subject_token) { throw new LocalError("ci.exchangeToken requires { project_id, subject_token }", "exchanging CI OIDC token"); } const body = { grant_type: TOKEN_EXCHANGE_GRANT_TYPE, subject_token: input.subject_token, subject_token_type: TOKEN_EXCHANGE_SUBJECT_TOKEN_TYPE, project_id: input.project_id, }; return this.client.request("/ci/v1/token-exchange", { method: "POST", body, withAuth: false, context: "exchanging CI OIDC token", }); } } /** * True when `err` is the CI token-exchange `binding_revoked` denial: a * subject-matching binding existed but was revoked — typically because the * project was transferred / handed off, which suspends the prior org's CI * bindings. The actionable fix is to re-create the binding with * `run402 ci link github`, NOT to widen asset scopes (`run402 ci * set-asset-scopes` 409s on a revoked binding). * * Discriminates against `access_denied` (no binding ever matched). The gateway * gives both the generic canonical `code: "FORBIDDEN"`, so the only signal is * the OAuth-style `error` field on the 403 response body — this guard reads it * for you. The thrown error stays an {@link Unauthorized} (`isUnauthorized` * remains true), so existing generic-403 handling is unaffected. Structural * (brand + body) so it survives duplicate SDK copies and realm boundaries. */ export function isCiBindingRevoked(err) { if (!isRun402Error(err) || err.status !== 403) return false; const body = err.body; return (typeof body === "object" && body !== null && !Array.isArray(body) && body.error === CI_BINDING_REVOKED_ERROR); } export function normalizeCiDelegationValues(values) { if (!values || typeof values !== "object") { throw new LocalError("CI delegation values must be an object", "validating CI delegation"); } if (!values.project_id) { throw new LocalError("CI delegation project_id is required", "validating CI delegation"); } const subject_match = validateCiSubjectMatch(values.subject_match); const nonce = validateCiNonce(values.nonce); const allowed_actions = normalizeAllowedActions(values.allowed_actions); const allowed_events = normalizeAllowedList(values.allowed_events, "allowed_events"); const route_scopes = normalizeCiRouteScopes(values.route_scopes); if (allowed_events.length === 0) { throw new LocalError("CI delegation allowed_events must contain at least one event", "validating CI delegation"); } return { project_id: values.project_id, issuer: values.issuer ?? CI_GITHUB_ACTIONS_ISSUER, audience: values.audience ?? CI_AUDIENCE, subject_match, allowed_actions, allowed_events, route_scopes, expires_at: values.expires_at ?? null, github_repository_id: values.github_repository_id ?? null, nonce, }; } export function buildCiDelegationStatement(values) { const v = normalizeCiDelegationValues(values); const canLines = [ " - deploy function code that runs with this project's runtime authority, including the project's service-role key, the adminDb() bypass-RLS surface, and configured runtime secrets read via process.env;", " - deploy database migrations, RLS/expose changes, and schema-altering SQL via spec.database.", ]; if (v.route_scopes.length > 0) { canLines.push(` - deploy route declarations only within these public path scopes: ${v.route_scopes.join(",")}.`); } const cannotLine = v.route_scopes.length > 0 ? "The workflows cannot directly call secrets, domain, subdomain, lifecycle, billing, contracts, or faucet endpoints. They cannot ship spec.secrets, spec.subdomains, spec.checks, non-current spec.base, or route changes outside the delegated route scopes." : "The workflows cannot directly call secrets, domain, subdomain, lifecycle, billing, contracts, or faucet endpoints. They cannot ship spec.secrets, spec.subdomains, spec.routes, spec.checks, or non-current spec.base."; const statement = [ `Authorize GitHub Actions workflows whose OIDC subject matches ${v.subject_match} to deploy to run402 project ${v.project_id}.`, "", "The workflows can:", ...canLines, "", cannotLine, "", `Audience: ${v.audience}`, `Allowed events: ${v.allowed_events.join(",")}`, ...(v.route_scopes.length > 0 ? [`Route scopes: ${v.route_scopes.join(",")}`] : []), `Repository ID: ${v.github_repository_id ?? "none-soft-bound"}`, `Expires: ${v.expires_at ?? "never"}`, `Nonce: ${v.nonce}`, "", "Revoke at any time via the run402 CLI or POST /ci/v1/bindings/:id/revoke. Revocation stops future CI gateway requests but does not undo already-deployed code, stop in-flight deploy operations, rotate exfiltrated keys, or remove deployed functions. Recovery from a compromise: revoke the binding, then SIWE-deploy a known-good release that overwrites the malicious code, and rotate any service-role key the deployed code may have read.", ].join("\n"); if (new TextEncoder().encode(statement).byteLength > MAX_STATEMENT_BYTES) { throw new LocalError(`CI delegation Statement exceeds ${MAX_STATEMENT_BYTES} bytes`, "building CI delegation statement"); } return statement; } export function buildCiDelegationResourceUri(values) { const v = normalizeCiDelegationValues(values); const parts = [ `project_id=${encodeRfc3986(v.project_id)}`, `issuer=${encodeRfc3986(v.issuer)}`, `audience=${encodeRfc3986(v.audience)}`, `subject_match=${encodeRfc3986(v.subject_match)}`, `allowed_actions=${v.allowed_actions.map(encodeRfc3986).join(",")}`, `allowed_events=${v.allowed_events.map(encodeRfc3986).join(",")}`, ]; if (v.route_scopes.length > 0) { parts.push(`route_scopes=${v.route_scopes.map(encodeCiResourceComponent).join(",")}`); } if (v.expires_at !== null) parts.push(`expires_at=${encodeRfc3986(v.expires_at)}`); if (v.github_repository_id !== null) { parts.push(`github_repository_id=${encodeRfc3986(v.github_repository_id)}`); } parts.push(`nonce=${encodeRfc3986(v.nonce)}`); const uri = `run402-ci-delegation:v1?${parts.join("&")}`; if (new TextEncoder().encode(uri).byteLength > MAX_RESOURCE_URI_BYTES) { throw new LocalError(`CI delegation Resource URI exceeds ${MAX_RESOURCE_URI_BYTES} bytes`, "building CI delegation resource URI"); } return uri; } export function validateCiSubjectMatch(subject) { if (typeof subject !== "string" || subject.length === 0) { throw new LocalError("CI subject_match must be a non-empty string", "validating CI subject"); } if (subject.length > MAX_SUBJECT_MATCH_CHARS) { throw new LocalError(`CI subject_match must be ${MAX_SUBJECT_MATCH_CHARS} characters or fewer`, "validating CI subject"); } if (/[\x00-\x1f\x7f]/.test(subject)) { throw new LocalError("CI subject_match must not contain control characters", "validating CI subject"); } const firstWildcard = subject.indexOf("*"); if (firstWildcard >= 0) { if (subject === "*") { throw new LocalError("CI subject_match cannot be a bare wildcard", "validating CI subject"); } if (firstWildcard !== subject.length - 1) { throw new LocalError("CI subject_match wildcard is only allowed as the final character", "validating CI subject"); } if (subject.indexOf("*", firstWildcard + 1) >= 0) { throw new LocalError("CI subject_match can contain at most one wildcard", "validating CI subject"); } } return subject; } export function validateCiNonce(nonce) { if (typeof nonce !== "string" || !NONCE_RE.test(nonce)) { throw new LocalError("CI delegation nonce must be lowercase hex between 16 and 64 characters", "validating CI nonce"); } return nonce; } export function normalizeCiRouteScopes(values) { if (values === undefined || values === null) return []; if (!Array.isArray(values)) { throw new LocalError("CI delegation route_scopes must be an array", "validating CI route scopes"); } if (values.length > CI_ROUTE_SCOPE_LIMIT) { throw new LocalError(`CI delegation route_scopes must contain ${CI_ROUTE_SCOPE_LIMIT} entries or fewer`, "validating CI route scopes"); } const seen = new Set(); const out = []; values.forEach((value, index) => { const normalized = validateCiRouteScope(value, `route_scopes.${index}`); const identity = routeScopeIdentity(normalized); if (seen.has(identity)) { throw new LocalError(`CI delegation route_scopes contains duplicate route pattern after normalization: ${normalized}`, "validating CI route scopes"); } seen.add(identity); out.push(normalized); }); return out.sort(compareAscii); } export function validateCiRouteScope(value, resource = "route_scopes") { if (typeof value !== "string" || value.length === 0) { throw new LocalError(`CI delegation ${resource} must be a non-empty string`, "validating CI route scopes"); } if (!value.startsWith("/")) { throw new LocalError(`CI delegation ${resource} must start with /; use route scopes such as /api or /api/*`, "validating CI route scopes"); } if (new TextEncoder().encode(value).byteLength > CI_ROUTE_SCOPE_BYTE_LIMIT) { throw new LocalError(`CI delegation ${resource} exceeds ${CI_ROUTE_SCOPE_BYTE_LIMIT} bytes`, "validating CI route scopes"); } if (value.includes("?")) { throw new LocalError(`CI delegation ${resource} must not include a query string`, "validating CI route scopes"); } try { decodeURI(value); } catch { throw new LocalError(`CI delegation ${resource} contains invalid percent encoding`, "validating CI route scopes"); } const wildcardIndex = value.indexOf("*"); if (wildcardIndex >= 0 && !value.endsWith("/*")) { throw new LocalError(`CI delegation ${resource} wildcards are only supported as final /*`, "validating CI route scopes"); } if (value === "*" || value === "/*") { throw new LocalError(`CI delegation ${resource} prefix wildcard must include a path segment before /*`, "validating CI route scopes"); } return value; } export function assertCiDeployableSpec(specOrPlanBody) { const { spec, manifestRef } = unwrapSpecOrPlanBody(specOrPlanBody); if (manifestRef !== undefined && manifestRef !== null) { throwCiDeploySpecError("manifest_ref", "CI deploys must use inline specs under the gateway body cap; manifest_ref is not allowed."); } if (!spec || typeof spec !== "object" || Array.isArray(spec)) { throwCiDeploySpecError("spec", "CI deploy requires a ReleaseSpec object."); } const obj = spec; for (const key of Object.keys(obj)) { if (!CI_DEPLOY_SPEC_ALLOWED_KEYS.has(key)) { if (key === "secrets") { throwCiDeploySpecError("secrets", "CI deploy manifests must omit spec.secrets. Set secrets locally or admin-side before CI runs; CI credentials do not have secret-write authority or secret-existence authority."); } throwCiDeploySpecError(key, `CI deploy cannot ship spec.${key}; only project_id, database, functions, site, routes, assets, and base:{release:"current"} are allowed.`); } } if (Object.prototype.hasOwnProperty.call(obj, "base") && !isCurrentBase(obj.base)) { throwCiDeploySpecError("base", 'CI deploy base must be absent or exactly { release: "current" }.'); } } function normalizeAllowedActions(values) { const actions = normalizeAllowedList(values, "allowed_actions"); if (actions.length !== 1 || actions[0] !== "deploy") { throw new LocalError('CI delegation allowed_actions must be exactly ["deploy"] in v1', "validating CI delegation"); } return ["deploy"]; } function normalizeAllowedList(values, field) { if (!Array.isArray(values)) { throw new LocalError(`CI delegation ${field} must be an array`, "validating CI delegation"); } const cleaned = values.map((value) => { if (typeof value !== "string" || value.length === 0) { throw new LocalError(`CI delegation ${field} must contain only non-empty strings`, "validating CI delegation"); } return value; }); return Array.from(new Set(cleaned)).sort(); } function encodeRfc3986(value) { return encodeURIComponent(value).replace(/[!'()*]/g, (char) => `%${char.charCodeAt(0).toString(16).toUpperCase()}`); } function encodeCiResourceComponent(value) { // The gateway canonical builder uses encodeURIComponent here; in particular, // final-wildcard route scopes render `/admin/*` as `%2Fadmin%2F*`. return encodeURIComponent(value); } function routeScopeIdentity(pattern) { return pattern.endsWith("/*") ? `prefix:${pattern}` : `exact:${normalizeExactRoute(pattern)}`; } function normalizeExactRoute(pattern) { return pattern.length > 1 && pattern.endsWith("/") ? pattern.slice(0, -1) : pattern; } function compareAscii(a, b) { return a < b ? -1 : a > b ? 1 : 0; } function unwrapSpecOrPlanBody(value) { if (value && typeof value === "object" && !Array.isArray(value) && "spec" in value && !("project_id" in value)) { const body = value; return { spec: body.spec, manifestRef: body.manifest_ref }; } return { spec: value }; } function isCurrentBase(value) { if (!value || typeof value !== "object" || Array.isArray(value)) return false; const obj = value; const keys = Object.keys(obj); return keys.length === 1 && obj.release === "current"; } function throwCiDeploySpecError(resource, message) { throw new Run402DeployError(message, { code: "forbidden_spec_field", phase: "validate", resource, retryable: false, context: "validating CI deploy spec", }); } //# sourceMappingURL=ci.js.map