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