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.

230 lines • 12.2 kB
/** * The vault deploy lane, supplied at last (change `vault-deploy-lane`). * * `add-vault` 5.6 built `runVaultDeploy` with the lane INJECTED and never * supplied one, so a project whose `vault_policy` is `required` could not be * deployed by any published client. This module is that supplier, plus the * entry point the CLI calls in place of a bare `apply()`: * * - {@link createApplyDeployLane} drives the shipped `/apply/v1` routes — * plan carrying `{capture_id, snapshot_oid_hmac}`, content upload, then a * commit carrying the activation token — by REUSING `Deploy.apply()` rather * than reassembling it. Warning gating, CAS dedup, event emission, asset * re-plan, and terminal polling are the same code every other deploy runs; * the only difference is two extra wire fields. * - {@link applyWithVault} decides whether any of that happens at all. A * project that is not `required` takes the untouched path: no capture, no * token, no extra refusal, one policy read. * * THE COROUTINE, because it is the non-obvious part. `runVaultDeploy` wants * two separable steps (plan, then commit) with the vault push running between * them; `Deploy.apply()` is one call. The lane bridges them with a pair of * deferreds: `apply()`'s `authorize` hook fires once the plan exists and its * content is uploaded, which RESOLVES `lane.plan(...)`; it then waits for * `lane.commit(...)` to hand back the activation block. If no commit is ever * coming — the push failed, or correspondence refused — {@link abandon} rejects * that wait so the apply unwinds with nothing committed. */ import { LocalError, isRun402Error } from "../errors.js"; function deferred() { let resolve; let reject; const promise = new Promise((res, rej) => { resolve = res; reject = rej; }); // A rejection that lands before anyone awaits is still handled — the awaiter // arrives a tick later, and an unhandled-rejection crash here would take out // a deploy that is merely being abandoned. promise.catch(() => undefined); return { promise, resolve, reject }; } export function createApplyDeployLane(options) { const planned = deferred(); const authorized = deferred(); let applyPromise = null; let result = null; let settled = false; const lane = { async plan(input) { applyPromise = options.engine.apply(options.spec, { ...(options.apply ?? {}), vault: { declaration: { capture_id: input.capture_id, snapshot_oid_hmac: input.snapshot_oid_hmac }, authorize: async (p) => { if (!p.apply_plan_sha256) { // A capture-bearing plan the gateway answered without a canonical // digest cannot back a token. Fail the PLAN lane rather than // inventing a placeholder: the capture still publishes (with a // null digest, so nothing is mintable from it) and the outcome // is an honest `DEPLOY_FAILED_VAULTED`. const missing = new LocalError("the gateway accepted a capture-bearing plan but returned no apply_plan_sha256; no activation token can be minted for it", "planning a vault deploy", { code: "VAULT_PLAN_DIGEST_MISSING", details: { plan_id: p.plan_id, operation_id: p.operation_id } }); planned.reject(missing); throw missing; } planned.resolve({ plan_id: p.plan_id, operation_id: p.operation_id, apply_plan_sha256: p.apply_plan_sha256 }); return authorized.promise; }, }, }); // A failure BEFORE the plan exists (validate, plan, upload) never calls // `authorize`, so surface it as the plan lane's failure — that is what // makes it `DEPLOY_FAILED_VAULTED` with a null digest rather than a hang. applyPromise.then((r) => { settled = true; result = r; }, (e) => { settled = true; planned.reject(e); }); return planned.promise; }, async commit(input) { authorized.resolve(commitDeclaration(input)); if (!applyPromise) throw new LocalError("commit was called before plan", "committing vault deploy", { code: "VAULT_LANE_OUT_OF_ORDER" }); result = await applyPromise; return result; }, abandon(reason) { if (settled) return; authorized.reject(reason instanceof Error ? reason : new LocalError("the vault deploy was abandoned before commit", "committing vault deploy", { code: "VAULT_DEPLOY_ABANDONED", details: { reason } })); }, result: () => result, }; return lane; } function commitDeclaration(input) { if (input.allow_unvaulted) return { allow_unvaulted: true, override_reason: input.allow_unvaulted.reason }; const tokenId = input.activation_token?.object_id; if (!tokenId) { throw new LocalError("a vaulted commit needs an activation token and none was minted", "committing vault deploy", { code: "VAULT_ACTIVATION_TOKEN_MISSING" }); } return { activation_token_id: tokenId }; } /** `run402 repos policy required` — the offer D3 attaches to every ungated deploy. */ export function vaultPolicyRequiredNextAction(repoId) { return { type: "vault_policy_required", command: "run402 repos policy required", why: `vault ${repoId} exists but vault_policy was never set, so this deploy ran without requiring a vaulted capture. Set it to require one, or run \`run402 repos policy grandfathered --reason <why>\` to explicitly accept the current state.`, }; } /** The standing `warnings[]` entry D3 attaches to every ungated deploy, until the policy is set either way. */ export function vaultUngatedWarning(repoId) { return { code: "VAULT_POLICY_UNSET", severity: "low", requires_confirmation: false, message: `vault ${repoId} is not gated: vault_policy was never set, so a deploy can activate without a vaulted capture. Run \`run402 repos policy required\` (or \`grandfathered --reason <why>\` to explicitly accept this) to clear the drift.`, affected: [repoId], }; } /** Attach D3's offer + warning to an ungated deploy's result, without disturbing anything else on it. */ function decorateUngatedResult(result, repoId) { return { ...result, warnings: [...(result.warnings ?? []), vaultUngatedWarning(repoId)], next_actions: [...(result.next_actions ?? []), vaultPolicyRequiredNextAction(repoId)], }; } /** * Deploy, taking the project's `vault_policy` into account. * * NOT `required` — no vault, an unreadable policy, a Core target, or an * explicit `grandfathered` — runs `apply()` with the caller's own options and * nothing else. That path must stay byte-identical: it is every non-vault * user's deploy, and the only cost this function may add to it is the single * policy read that determines the project is not `required`. */ export async function applyWithVault(options) { const applyOpts = { ...(options.apply ?? {}), ...(options.target ? { target: options.target } : {}), }; const plain = async (mode) => ({ mode, deploy: await options.sdk._applyEngine.apply(options.spec, applyOpts), vault: null, }); // A self-hosted Core gateway has no vault gate; never spend a round trip. if (options.target === "core") return plain({ kind: "none", reason: "no_vault" }); const record = await readVaultRecord(options.sdk.repos, options.spec.project_id); if (!record) return plain({ kind: "none", reason: "policy_unreadable" }); if (record.vault_policy === "grandfathered") { return plain({ kind: "grandfathered", repo_id: record.repo_id }); } if (record.vault_policy !== "required") { // D3: allocation no longer sets `vault_policy`, so a vault whose // policy is `null` is the ORDINARY shape, not a weakened one — the // deploy is never blocked or interactively prompted on this. It still // runs the untouched plain path (same options object, no vault hooks); // the only difference from `grandfathered` is that the result carries an // offer to gate, plus a standing warning naming the drift until the // policy is set either way (spec scenario "Ungated drift stays visible"). const deploy = await options.sdk._applyEngine.apply(options.spec, applyOpts); return { mode: { kind: "ungated", repo_id: record.repo_id }, deploy: decorateUngatedResult(deploy, record.repo_id), vault: null }; } const lane = createApplyDeployLane({ engine: options.sdk._applyEngine, spec: options.spec, apply: applyOpts }); let vault; try { vault = await options.sdk.repos.deploy({ lane, repo_id: record.repo_id, repo_dir: options.repo_dir ?? process.cwd(), ...(options.keystore_root !== undefined ? { keystore_root: options.keystore_root } : {}), ...(options.allow_unvaulted ? { allow_unvaulted: options.allow_unvaulted } : {}), ...(options.onCommitLine ? { onCommitLine: options.onCommitLine } : {}), ...(options.allowDirty ? { snapshot: { allowDirty: true } } : {}), }); } catch (err) { // Every throw here is a refusal that precedes activation — a snapshot // refusal, `SNAPSHOT_MOVED_DURING_DEPLOY`, or an unauthorized override. // Unwind the apply so no plan is left mid-flight, then surface the // refusal as itself: it is the caller's answer, not something to retry // under a fresh capture. lane.abandon(err); throw enrichCaptureRefusal(err, record.repo_id); } finally { lane.abandon(new LocalError("the deploy ended without committing this plan", "committing vault deploy", { code: "VAULT_DEPLOY_ABANDONED" })); } return { mode: { kind: "vaulted", repo_id: record.repo_id }, deploy: lane.result(), vault }; } /** * Read the vault record, or `null` when it cannot be read. * * A read that fails for ANY reason is not a deploy failure: the gateway is the * authority on the policy and refuses the commit itself if a capture was * genuinely required. Turning a transient control-plane blip into a failed * deploy for a project that has no vault at all would be the worse trade. */ async function readVaultRecord(repos, projectId) { try { return await repos.forProject(projectId); } catch { return null; } } /** * A `required` project whose keystore this machine does not hold cannot * capture. The protocol's own code is PRESERVED (`KEYSTORE_MISSING` / * `VAULT_REPO_STATE_MISSING` — surfacing the typed outcome rather than * substituting one), with actions prepended that actually resolve it. */ function enrichCaptureRefusal(err, repoId) { if (!isRun402Error(err)) return err; const e = err; if (e.code !== "KEYSTORE_MISSING" && e.code !== "VAULT_REPO_STATE_MISSING") return err; return new LocalError(`${e.message} — this project's vault_policy is \`required\`, so a deploy from this machine needs the vault keystore`, "capturing for a vault-required deploy", { code: e.code, details: { repo_id: repoId, ...(e.details && typeof e.details === "object" ? e.details : {}) }, next_actions: [ { action: "restore the vault keystore onto this machine (run402 repos view prints keystore.root; back it up from the machine that holds it)" }, { action: 'run402 repos policy grandfathered --reason "<why>" — un-gate the project so deploys activate without a capture (owner + step-up, audited, doctor-persistent advisory)' }, ...(Array.isArray(e.next_actions) ? e.next_actions : []), ], }); } //# sourceMappingURL=vault-apply.js.map