# Run402 — HTTP API reference > Wayfinder: https://run402.com/llms.txt > SDK reference (recommended): https://run402.com/llms-sdk.txt > CLI reference: https://run402.com/llms-cli.txt > MCP reference: https://run402.com/llms-mcp.txt > OpenAPI: https://run402.com/openapi.json > Source of truth for this file: site/llms-full.txt in github.com/kychee-com/run402-private This is the canonical, machine-readable reference for the Run402 HTTP API at `https://api.run402.com`. **Use this only when you can't use the SDK / CLI / MCP** — those are the recommended surfaces for coding agents. ## Pick the SDK first Run402 ships four wrappers around the same HTTP API. In order of preference for coding agents: 1. **`@run402/sdk`** ⭐ — typed TypeScript / JavaScript kernel. Every method is typed, every error is a typed subclass of `Run402Error`, never throws on network shape. **Use this if you're authoring code.** → 2. **`run402` CLI** — JSON in / JSON out, exit code on failure. For scripted shells. → 3. **`run402-mcp`** — MCP tools auto-loaded into Claude Desktop / Cursor / Cline / Claude Code. → 4. **HTTP API (this file)** — only if the language has no SDK or you're integrating at a layer below. The CLI and MCP server are thin shims over the SDK, and the SDK is a thin wrapper over this HTTP API. If you can use the SDK, do — fewer process boundaries, typed errors, identical behavior. This file exists for completeness; it is not the recommended path. ## API base & host rules `https://api.run402.com` is the API. **Never POST to `https://run402.com`** — that's the static docs site (returns 405 on writes). - Single AWS region (us-east-1), 2 AZs. Aurora Postgres 16 + ECS Fargate + S3 + CloudFront. Operator: Kychee, Inc. - Health: `GET /health`. Status: `GET /status`. x402 discovery: `GET /.well-known/x402`. - Project rate limit: **100 req/sec** per project. Exceeding returns 429 with `retry_after`. - CORS: `Access-Control-Allow-Origin: *` (intentional — for browser-side x402 / MPP clients). Allowed request headers: `apikey`, `Content-Type`, `Authorization`, `Prefer`, `Accept-Profile`, `Content-Profile`, `Idempotency-Key`, `SIGN-IN-WITH-X`, `X-Confirm-Drain`, `X-Confirm-Delete`. ## Authentication surfaces Seven authentication shapes, never mixed on the same request: | Header / shape | Where it works | Provided by | |---|---|---| | `X-402-Payment: ` | Paid endpoints (`POST /tiers/v1/:tier`, `POST /generate-image/v1`, `POST /contracts/v1/call`, `POST /contracts/v1/signers`, `POST /contracts/v1/signers/:signer_id/drain`, `POST /faucet/v1`) | `@x402/fetch` client; the SDK does this for you. | | `SIGN-IN-WITH-X: ` | Wallet-level free actions (`POST /projects/v1`, all `/apply/v1/*` writes, `POST /fork/v1`, `POST /message/v1`, `GET /ping/v1`, `POST /agent/v1/contact`, `GET /tiers/v1/status`) | `@x402/extensions/sign-in-with-x` helpers. EVM (EIP-191) or Solana (Ed25519). | | `Authorization: Bearer ` | Project-admin operations (`/projects/v1/admin/:project_id/*`, `/contracts/v1/signers/*`, `/mailboxes/v1/*`, `/email/v1/domains*`, `/subdomains/v1` writes, `/domains/v1` writes, `/ai/v1/translate`, `/ai/v1/moderate`, `/ai/v1/usage`) | Returned by `POST /projects/v1`. Never expires. **Never embed in browser code.** | | `apikey: ` *(or ``)* | All client-facing surfaces: `/rest/v1/*`, `/auth/v1/*`, `/storage/v1/*`, `/content/v1/*`, `/functions/v1/:name`, `GET /apply/v1/operations/:operation_id`, `GET /apply/v1/operations/:operation_id/events`, `POST /apply/v1/operations/:operation_id/resume` (read paths) | `anon_key` returned by `POST /projects/v1`. RLS applies. | | `apikey + Authorization: Bearer ` | Per-user actions: `GET /auth/v1/user`, `POST /auth/v1/logout`, `PUT /auth/v1/user/password`, RLS-scoped writes on `/rest/v1/*` | `access_token` from `POST /auth/v1/token`. | | `Authorization: Bearer ` | Seven CI-callable routes: all `/content/v1/plans*` writes, all `/apply/v1/plans*` writes, `GET /apply/v1/operations/:operation_id` + `/events`, `POST /apply/v1/operations/:operation_id/resume`. Token has `token_use === "ci_session"`. | Returned by `POST /ci/v1/token-exchange` after exchanging a GitHub Actions OIDC JWT. 15-min TTL bounded by binding expiry. See "OIDC federation for CI/CD" below. | | `Authorization: Bearer ` | Read-only operator-console reads (`GET /agent/v1/operator/overview`, `GET /agent/v1/operator/projects/:project_id/contents`, `POST /agent/v1/operator/session/refresh`). Token has `token_use === "operator_session"`, `scope ["operator.read"]`, `aud "run402.agent.v1.operator"`. Rejected at every mutating route. | Minted by `POST /agent/v1/operator/session/email(/verify)` (magic-link) or `/passkey/options`+`/passkey/verify` (WebAuthn login). 30-min TTL, 12h absolute cap, revocable. See "Operator console" below. | | Admin-key (operator only) | `POST /ai/v1/addons`, `DELETE /ai/v1/addons`, `POST /mailboxes/v1/:mailbox_id/status`, `GET /admin/v1/operator/overview` | Run402 platform operators. Not agent-facing. | `anon_key` and `service_key` never expire. Lease enforcement is server-side. Access tokens (`/auth/v1/token`) expire in 1h; refresh tokens expire in 30d, single-use. **Ownership & authorization (v1.77 — org-owned control plane).** A wallet address **authenticates**, it does not own. SIWX resolves your wallet to a control-plane *principal*; a project is owned by an **org (organization)** via `projects.organization_id`, and what a principal may do is decided by its org *membership* role (`owner > admin > developer > billing > viewer`) or a per-project *grant* (used for agent/CI principals that aren't broad members) — never by `wallet_address == signer`. Effects: project objects expose `org_id` (owning org) + `created_by` (provisioning principal) instead of `wallet_address`; control-plane denials return `403 FORBIDDEN` (never 404 — project existence isn't leaked); high-stakes ops (delete, transfer-of-ownership, membership change) require an active **owner** membership regardless of principal type. **Agent-first cold-start is unchanged:** a fresh wallet that subscribes + provisions becomes the owner of its own org-of-one automatically (no email/passkey/claim/approval) — the org/membership layer is invisible until a second principal joins. Co-ownership and individual revocation (one membership row, no key rotation) follow directly. The `run402` CLI / MCP / SDK surface for `org`/membership management ships as a fast-follow; SIWX auth itself is unchanged. ## Operator console — read-only organization overview An API-first, **read-only** surface for the human operator (or the agent acting on their behalf). It authenticates by **email control** — no signing wallet, no custody — and grants read of every wallet whose verified `agent_contacts` email matches the session email. The reference web console is just a consumer of these endpoints. **Endpoints:** | Endpoint | Method | Auth | Purpose | |---|---|---|---| | `/agent/v1/operator/session/email` | POST | none (rate-limited) | Send a magic-link sign-in (no account-existence oracle) | | `/agent/v1/operator/session/email/verify` | POST | magic-link token | Exchange the fragment token → operator session | | `/agent/v1/operator/session/passkey/options` | POST | none | WebAuthn login options | | `/agent/v1/operator/session/passkey/verify` | POST | WebAuthn assertion | Exchange assertion → operator session | | `/agent/v1/operator/session/passkey/enroll/options` | POST | operator session | WebAuthn registration options (enroll a passkey) | | `/agent/v1/operator/session/passkey/enroll/verify` | POST | operator session | Verify registration → persist the passkey | | `/agent/v1/operator/session/refresh` | POST | operator session | Rotate + re-issue the session token | | `/agent/v1/operator/session/device` | POST | none (email-less) | Start a CLI device-authorization (RFC 8628); returns `device_code`, `user_code`, verification URIs | | `/agent/v1/operator/session/device/approve` | POST | operator session (recent auth) | Approve/deny a pending `user_code` (binds the decision; mints no session) | | `/agent/v1/operator/session/device/token` | POST | none (`device_code` is the credential) | Poll for the session; RFC-8628 error body (`authorization_pending` / `slow_down` / `access_denied` / `expired_token`) until approved | | `/agent/v1/operator/session/revoke` | POST | operator session | Sign out — revoke the current session (its `jti`); idempotent `204` | | `/agent/v1/operator/overview` | GET | operator session **or** control-plane session **or** SIWX | Tier-A account summary (counts only) | | `/agent/v1/operator/projects` | GET | operator session **or** SIWX | Named cross-account project inventory (`projects list --all`): name + site_url + custom domains + owning org | | `/agent/v1/operator/projects/{project_id}/contents` | GET | operator session (passkey) **or** SIWX | Tier-B inventory names (never values) | **Auth: the operator session.** A read-scoped HS256 bearer (`typ: "run402.operator-session+jwt"`, `aud: "run402.agent.v1.operator"`, `scope: ["operator.read"]`, `token_use: "operator_session"`), signed with a dedicated secret (asserted distinct from the tenant-JWT and CI-session secrets). Minted two ways; both return `{ operator_session_token, token_type: "Bearer", expires_in, absolute_expires_at, email, wallets[] }`: - **Magic-link:** `POST /agent/v1/operator/session/email {email}` returns an **identical** 200 whether or not the email has an account (no account-existence oracle); a single-use link (token in the URL **fragment**) is emailed only to an email controlling ≥1 verified wallet. Exchange the fragment token via `POST /agent/v1/operator/session/email/verify {token}`. - **Passkey-login:** `POST /agent/v1/operator/session/passkey/options {email}` → `POST /agent/v1/operator/session/passkey/verify {email, response}` (standard WebAuthn authentication against the operator's enrolled passkey; no PRF, no signing wallet). - **Refresh:** `POST /agent/v1/operator/session/refresh` (with the operator-session bearer) rotates the token. 30-min access TTL; 12h absolute re-auth cap; server-side revocable. - **CLI device login (RFC 8628):** `POST /agent/v1/operator/session/device` (no email) returns a `device_code` + `user_code` + `verification_uri`/`verification_uri_complete`; the operator opens the URL and `POST /agent/v1/operator/session/device/approve {user_code, decision?}` (operator-session bearer authenticated within the last few minutes) binds the approve/deny decision without minting a session; the CLI polls `POST /agent/v1/operator/session/device/token {device_code}` and receives the session on approval (an RFC-8628 `{ error }` body — `authorization_pending` / `slow_down` / `access_denied` / `expired_token` — until then). - **Sign out:** `POST /agent/v1/operator/session/revoke` (operator-session bearer) revokes the current session by its `jti`; idempotent `204`, effective on the next request. **Tier-A summary read:** `GET /agent/v1/operator/overview` — accepts an operator-session bearer (union across the email's verified wallets), a **control-plane session** bearer (the console's write-capable sign-in reading its OWN footprint: union across the principal's verified emails plus its siwx wallet authenticators; `scope.kind` is `"principal"`), **or** a `SIGN-IN-WITH-X` header (that wallet's slice). The bearer is classified by its `token_use` claim — no silent fallback. Service/admin keys are NOT accepted here, and a control-plane session is Tier-A only (it is NOT accepted on the Tier-B contents read). Returns a multi-organization shape `{ scope, operator, rollup, organizations[], wallets[], advisories[] }` with **counts only** (functions/secrets/domains/mailboxes) — never inventory names. **Cross-account project inventory (`projects list --all`):** `GET /agent/v1/operator/projects` — the named, domain-aware project inventory across the operator's verified-email wallet union (operator-session bearer), or a single wallet's slice (`SIGN-IN-WITH-X`). Returns `{ projects: [{ project_id, name, tier, site_url, custom_domains, status, org_id, created_by, created_at }], scope }` — the same row shape as `GET /projects/v1`, so one renderer covers both. Soft-deleted (tombstone) projects are never listed; archived hidden by default — opt in with `?include=archived` (any other value → 400). Names + public addressing only; no secret/key value. Service/admin keys are NOT accepted here. Authority parity with the overview: a project appears iff one of the union's wallets resolves to a principal holding an active org membership on its owning org (or an active project grant). **Tier-B inventory read:** `GET /agent/v1/operator/projects/:project_id/contents` — secret **names** (never values), function names, domains, subdomains, mailbox slugs. An operator session needs **fresh proof** (passkey-login, or a magic-link auth within the last 5 min); SIWX is inherently fresh. The project must be owned by a wallet in the caller's set (fresh DB read). **Disclosure ceiling:** read-only by construction — the operator session 401s at every mutating route (enforced by a central route auth/effect manifest + a CI gate, not by convention). No secret value, private key, or token appears in any tier. Support/debug reads go through the admin-only, audited `GET /admin/v1/operator/overview` (mandatory `email|wallet|org_id` target). Later phases add email-safe action-scoped mutations and a browser signing wallet; Phase 0 (this) is strictly read-only. ## Control-plane console — hosted owner login (write-capable session) Distinct from the read-only operator session above. The **control-plane session** is the human owner's **write-capable** login for org management (members, invites, audit, project handoffs). It is a different actor, token class, and data scope from the tenant end-user hosted auth UI (`/auth/sign-in`, the `@run402/astro` `` surface, which logs in a *project's* end-users) — the two `/auth/*`-shaped flows are never conflated. The hosted browser pages live on the console origin (`console.run402.com`); the gateway owns the auth endpoints (the console holds no privileged secret — it is a pure-static consumer). Tokens are held **in memory only** (never `localStorage`). **Endpoints (`/agent/v1/control-plane/*`):** | Endpoint | Method | Auth | Purpose | |---|---|---|---| | `/agent/v1/control-plane/session/email` | POST | none (rate-limited) | Send a magic-link sign-in (no account-existence oracle) | | `/agent/v1/control-plane/session/email/verify` | POST | magic-link token | Exchange the fragment token → control-plane session | | `/agent/v1/control-plane/session/passkey/options` | POST | none | WebAuthn login options | | `/agent/v1/control-plane/session/passkey/verify` | POST | WebAuthn assertion | Exchange assertion → control-plane session | | `/agent/v1/control-plane/oauth/{provider}/start` | GET | none | 302 to the provider (`google` / `github`). `503` until OAuth creds are provisioned | | `/agent/v1/control-plane/oauth/{provider}/callback` | GET | provider redirect | Resolve identity **by verified email** → mint → land on `CONSOLE_ORIGIN#cp_session` (or `#oauth_needs_confirmation` on an email conflict) | | `/agent/v1/control-plane/session` | GET | control-plane session | Whoami — principal, `amr`, active memberships | | `/agent/v1/control-plane/session/refresh` | POST | control-plane session | Rotate + re-issue the session token | | `/agent/v1/control-plane/session/revoke` | POST | control-plane session | Sign out — revoke the current session (its `jti`) | | `/agent/v1/control-plane/passkey/enroll/options` | POST | control-plane session | WebAuthn registration options (enroll a passkey) | | `/agent/v1/control-plane/passkey/enroll/verify` | POST | control-plane session (step-up) | Verify registration → persist the passkey. Also claims any pending org invites deferred on the passkey requirement (response carries `invites_claimed`) | | `/agent/v1/control-plane/step-up/options` | POST | control-plane session | Fresh-WebAuthn step-up options for a high-stakes op class | | `/agent/v1/control-plane/step-up/verify` | POST | control-plane session | Record the fresh passkey elevation (then retry the op) | | `/agent/v1/control-plane/recovery/issue` | POST | control-plane session (step-up) | (Re)issue recovery codes — shown once | | `/agent/v1/control-plane/recovery/consume` | POST | recovery code | Recovery ceremony → session (`amr: ["recovery_code"]`, never satisfies step-up) | | `/agent/v1/control-plane/authenticators` | GET | control-plane session | List active authenticators | | `/agent/v1/control-plane/authenticators/{authenticator_id}` | DELETE | control-plane session (step-up) | Revoke one authenticator (last-owner-passkey guarded) | | `/agent/v1/control-plane/cli/authorize/{flow_id}` | GET | control-plane session | CLI loopback-PKCE: load the pending flow for the approve page | | `/agent/v1/control-plane/cli/approve` | POST | control-plane session (step-up) | Approve the CLI flow → return the loopback redirect URL with the auth code | | `/agent/v1/control-plane/oauth/{provider}/link` | POST | control-plane session (step-up) | Start an OAuth flow in LINK mode → `{ auth_url }`; the callback attaches the identity to the CURRENT principal (completes `needs_confirmation`) | | `/agent/v1/control-plane/emails` | POST | control-plane session (step-up) | Request attaching an additional email (generic response — no account-existence oracle) | | `/agent/v1/control-plane/write-auth/challenges` | POST | control-plane session | Open a passkey write-intent ceremony for one action+target → `{ challenge_id, confirm_url, expires_at }` | | `/agent/v1/control-plane/write-auth/verify` | POST | WebAuthn assertion | Verify the write-intent assertion → mint the target-scoped write-auth session (opaque token, returned once). In CLI loopback mode the mint is deferred — returns `{ delivery: "cli_loopback", redirect_to }` instead | | `/agent/v1/control-plane/write-auth/cli/token` | POST | claim code + PKCE | Headless-CLI delivery: exchange the loopback claim `code` + `code_verifier` + `state` → mint the write-auth session, return the token once. Open the challenge with `cli_redirect_uri` + `code_challenge` (S256) + `state` to use this leg | | `/agent/v1/control-plane/write-auth/sessions` | GET | control-plane session | List active write-auth sessions bound to this session | | `/agent/v1/control-plane/write-auth/sessions/{write_auth_session_id}/revoke` | POST | control-plane session | Revoke one write-auth session | | `/agent/v1/control-plane/write-auth/revoke-all` | POST | control-plane session | Revoke every write-auth session for the principal | | `/agent/v1/control-plane/emails/verify` | POST | control-plane session + attach token | Confirm the attach (inbox + same-session proof) → verified email on this principal; `409 EMAIL_BELONGS_TO_OTHER_PRINCIPAL` → merge | | `/agent/v1/control-plane/merge` | POST | control-plane session (step-up, passkey) | Merge a duplicate principal into this one — body `{ source_session_token, source_principal_id? }`; Phase 1 needs an authority-empty source | **Auth: the control-plane session.** A bearer JWT `typ: "run402.control-plane-session+jwt"`, signed with `CONTROL_PLANE_SESSION_SECRET` (asserted distinct from `JWT_SECRET`, `CI_SESSION_SECRET`, and `OPERATOR_SESSION_SECRET` — the fourth token-class-confusion guard). Login mints it; `resolveAuthContext` turns it into the AuthContext the org policy matrix authorizes against. High-stakes ops (delete, transfer-of-ownership, membership/invite change, authenticator change, CLI approve) require a fresh **passkey** step-up — a recent password/magic-link proof does NOT satisfy it; the gateway returns `STEP_UP_REQUIRED` and the client re-runs the step-up ceremony then retries. OAuth identities **link by verified email** (an OAuth account whose email already belongs to an owner is *not* auto-linked — the callback lands on `#oauth_needs_confirmation`; sign in as that owner, then complete the attach with the explicit link flow: `POST /agent/v1/control-plane/oauth/{provider}/link` from account settings). One human with identities split across two principals (e.g. a GitHub login keyed to a different email) consolidates them with `POST /agent/v1/control-plane/merge` — both-sides possession proof, atomic credential re-pointing, source disabled with audit traceability. **Hosted browser legs (on `console.run402.com`):** the sign-in / sign-up pages drive the email / passkey / OAuth endpoints; an OAuth-handoff landing reads the `#cp_session=` (or `#oauth_needs_confirmation=1&email=…&provider=…`) URL fragment, scrubs it via `replaceState`, and establishes the in-memory session; a CLI loopback approve / passkey-ceremony page completes `run402 operator login` end-to-end (the gateway 302s the browser there from the CLI loopback authorize, the user runs the passkey ceremony + approves, and the browser is sent to the `127.0.0.1` loopback redirect with the auth code). The public SDK/CLI client surface (`r.operator.session.*`, nested `r.org.*`, `StepUpRequiredError`, operator loopback-PKCE login) shipped in `run402` v2.40.0 / v2.41.0. **OAuth provisioning runbook (deploy-time, no code change).** `services/control-plane-oauth.ts` fails closed with `503 "Control-plane OAuth is not configured"` whenever `CONTROL_PLANE_{GOOGLE,GITHUB}_CLIENT_ID/SECRET` are empty. To go live: (1) register the Google + GitHub OAuth apps with redirect URI = `CONTROL_PLANE_OAUTH_REDIRECT_BASE` + `/agent/v1/control-plane/oauth/:provider/callback`; (2) provision `CONTROL_PLANE_GOOGLE_CLIENT_ID/SECRET` + `CONTROL_PLANE_GITHUB_CLIENT_ID/SECRET` in AWS Secrets Manager and wire them into the gateway task definition (alongside `CONTROL_PLANE_SESSION_SECRET`); (3) redeploy the gateway and verify `/oauth/{google,github}/start` no longer 503, the provider redirect fires, and the callback links by verified email and completes the `CONSOLE_ORIGIN#cp_session` handoff. CORS for the `Authorization`-bearing control-plane + operator routes allowlists the console origin (not `*`). ## Tiers, projects & lifecycle | Tier | Cost | Lease | Storage | API calls | Functions | Max function bundle | |------|------|-------|---------|-----------|-----------|---------------------| | Prototype | **FREE** (testnet USDC, $0.10 to verify x402 setup) | 7 days | 250 MB | 500K | 15 | 1 MB | | Hobby | $5 | 30 days | 1 GB | 5M | 50 | 5 MB | | Team | $20 | 30 days | 10 GB | 50M | 1500 | 25 MB | Real-money tiers: USDC on Base (chain `eip155:8453`) or pathUSD on Tempo (MPP) or Stripe credits. Testnet is Base Sepolia (`eip155:84532`). Same wallet key works on both rails. ``` POST /tiers/v1/:tier # x402; auto-detects subscribe / renew / upgrade GET /tiers/v1 # tier pricing, no auth GET /tiers/v1/status # current tier, organization_lifecycle_state, lease_perpetual, non-terminal projects (SIWX) POST /projects/v1/quote # estimate provisioning cost (free, no auth) ``` ``` POST /projects/v1 # SIWX; optional { org_id }; returns { project_id, org_id, anon_key, service_key, schema_slot } GET /projects/v1 # SIWX, control-plane session (operator console; membership-scoped), or admin; server inventory: name + site_url + custom_domains + owning org (org_id) + status; ?limit=50&after=cursor&org_id= (org filter is authorize-before-reveal). Soft-deleted (tombstone) projects are never listed; archived hidden by default — opt in with ?include=archived (any other include value → 400) GET /projects/v1/:project_id # control-plane session or SIWX (viewer+ on owning org, or admin); authoritative single-project read - identity, org_id, tier, lifecycle, site_url + custom_domains, last_deploy, mailbox, usage vs tier limits; no secret values; authorize-before-reveal PATCH /projects/v1/:project_id # control-plane session or SIWX; rename — body { name }; admin+ on owning org (or project:write grant); authorize-before-reveal DELETE /projects/v1/:project_id # service_key/admin, or owner via session (passkey step-up) / SIWX (fresh signature); cascade purge GET /wallets/v1/:address/projects # SIWX (signer must equal :address) or admin; lists projects owned by orgs the address's principal is an active member of GET /wallets/v1/:address/label # public; { address, label } — server-side display name for a wallet (null if unset) PUT /wallets/v1/:address/label # SIWX (signer must equal :address); body { label }; null|"" clears. Display metadata only ``` **Org members & project grants (v1.77 authority graph — the owner-gated management door).** SIWX authenticates → a control-plane *principal*; ownership/authorization is org membership (role lattice `owner > admin > developer > billing > viewer`) or per-project grant. These routes manage them. Member mutations require an active **owner** membership on the org; grant mutations require **owner** on the project's owning org; every mutation is audited and the org always keeps ≥1 active owner (last-owner guard). ``` GET /agent/v1/whoami # SIWX; { principal, memberships:[{org_id,display_name,role,status}] } GET /orgs/v1 # SIWX; orgs the caller is an active member of, with role + display_name POST /orgs/v1 # SIWX/session + step-up; create an empty org (prototype) { display_name? } → { org_id }. Creator = owner; no tier at create; soft per-owner free-org cap (FREE_ORG_OWNER_LIMIT_EXCEEDED) GET /orgs/v1/:org_id # SIWX/session (any active member); one org { org_id, display_name, tier, role }. Authorize-before-reveal PATCH /orgs/v1/:org_id # SIWX/session (owner) + step-up; rename org { display_name } (null/"" clears). Authorize-before-reveal GET /orgs/v1/:org_id/members # SIWX (any active member); list members { principal_id, type, role, status, wallet } POST /orgs/v1/:org_id/members # SIWX (owner); add a member { wallet, role }. New wallet → provisioned as a human; existing wallets added by their principal (type is informational) PATCH /orgs/v1/:org_id/members/:principal_id # SIWX (owner); change role { role }. Last-owner guarded DELETE /orgs/v1/:org_id/members/:principal_id # SIWX (owner); revoke member (status=revoked, one row, no key rotation). Last-owner guarded POST /projects/v1/:project_id/grants # SIWX (owner of project's org); issue a grant { wallet, capability, policy?, expires_at? } to an agent/ci principal DELETE /projects/v1/:project_id/grants/:grant_id # SIWX (owner of project's org); revoke a grant GET /orgs/v1/:org_id/audit # SIWX/session (admin+); control-plane audit trail (?limit=&before=) GET /orgs/v1/:org_id/invites # SIWX/session (any member); list pending email invites POST /orgs/v1/:org_id/invites # SIWX/session (owner) + step-up; invite by email { email, role, invite_ttl_hours? } → claimed at first login. Sends a notification email (re-POST = resend, rate-limited to 1 send per org+email per 5 min); 201 carries email_sent: true|false for pending invites DELETE /orgs/v1/:org_id/invites/:principal_id # SIWX/session (owner) + step-up; revoke a pending invite POST /agent/v1/operator/claim-wallet-org/challenge # session (no step-up — step-up is on the claim); issue a single-use nonce to claim a wallet-owned org { wallet } → { nonce } POST /agent/v1/operator/claim-wallet-org # session + step-up + SIGN-IN-WITH-X wallet proof (signs the nonce); become owner of the wallet-agent's org (ownership transfer; wallet stays on agent; agent→developer). { org_id?, display_name? }. Multi-org → { status:select_org, selectable_orgs } ``` **Control-plane login + session + step-up (v1.78 — passkey-principals-onboarding).** A person logs in (passkey / email magic-link / Google / GitHub) → a principal-bound **control-plane session** bearer (distinct from tenant `internal.sessions`), WRITE-capable subject to org role + **step-up**. High-stakes ops (delete / transfer / membership / invite) require a fresh passkey (a recent magic-link does NOT satisfy a passkey requirement; recovery codes NEVER satisfy step-up). The control-plane session bearer is sent as `Authorization: Bearer ` and is accepted everywhere a SIWX wallet is. ``` POST /agent/v1/control-plane/session/email # public; send a magic link (rate-limited; generic response, no oracle) POST /agent/v1/control-plane/session/email/verify # public; consume token → mint session (amr=email) { control_plane_session_token, expires_in, principal_id } POST /agent/v1/control-plane/session/passkey/options # public; WebAuthn login options for an email's passkeys POST /agent/v1/control-plane/session/passkey/verify # public; verify assertion → mint session (amr=passkey) GET /agent/v1/control-plane/session # control-plane session; whoami { principal, memberships, amr, amr_times } POST /agent/v1/control-plane/session/refresh # control-plane session; rotate the access token POST /agent/v1/control-plane/session/revoke # control-plane session; sign out POST /agent/v1/control-plane/passkey/enroll/options # control-plane session + step-up; register a passkey (options) POST /agent/v1/control-plane/passkey/enroll/verify # control-plane session + step-up; register a passkey (verify) POST /agent/v1/control-plane/step-up/options # control-plane session; step-up WebAuthn options for a high-stakes op POST /agent/v1/control-plane/step-up/verify # control-plane session; verify step-up → freshness + action-bound elevation POST /agent/v1/control-plane/recovery/issue # control-plane session + step-up; (re)issue recovery codes (shown once) POST /agent/v1/control-plane/recovery/consume # public; recovery ceremony → session (amr=recovery_code; must enrol a passkey before admin actions) GET /agent/v1/control-plane/authenticators # control-plane session; list my active authenticators (no secrets) DELETE /agent/v1/control-plane/authenticators/:authenticator_id # control-plane session + step-up; revoke one (owner-passkey policy enforced) GET /agent/v1/control-plane/cli/authorize/:flow_id # control-plane session; read a CLI loopback-PKCE flow (client + loopback port) POST /agent/v1/control-plane/cli/approve # control-plane session + step-up (passkey); approve a CLI login → { redirect_url } POST /agent/v1/control-plane/cli/token # public; exchange { code, code_verifier, state } → session (provenance=loopback_pkce) POST /agent/v1/control-plane/oauth/:provider/link # control-plane session + step-up; start OAuth in LINK mode → { auth_url } (attach the identity to the CURRENT principal) POST /agent/v1/control-plane/emails # control-plane session + step-up; request attaching an additional email (generic response, no oracle) POST /agent/v1/control-plane/emails/verify # control-plane session; confirm the attach (inbox + same-session proof); 409 EMAIL_BELONGS_TO_OTHER_PRINCIPAL → merge POST /agent/v1/control-plane/merge # control-plane session + step-up (passkey); merge a duplicate principal { source_session_token, source_principal_id? } POST /agent/v1/control-plane/write-auth/challenges # control-plane session; open a passkey write-intent ceremony { action, org_id|project_id } → { challenge_id, confirm_url } POST /agent/v1/control-plane/write-auth/verify # public (the WebAuthn assertion IS the proof); mint → { write_auth_token } (once), OR in CLI mode → { delivery:"cli_loopback", redirect_to } POST /agent/v1/control-plane/write-auth/cli/token # public; headless-CLI loopback delivery: { code, code_verifier, state } (PKCE S256) → { write_auth_token } (once) GET /agent/v1/control-plane/write-auth/sessions # control-plane session; list active write-auth sessions bound to this session POST /agent/v1/control-plane/write-auth/sessions/:write_auth_session_id/revoke # control-plane session; revoke one POST /agent/v1/control-plane/write-auth/revoke-all # control-plane session; revoke all for the principal ``` **Passkey write-auth (human-write-auth, v1.85).** A signed-in human (email/passkey — no wallet) gains WRITE authority on provision + deploy via a **passkey signed intent**: open a challenge for ONE action on ONE target (`org.project.create` on an org, or `project.deploy` on a project), approve it on the gateway-rendered confirmation page (`confirm_url`, console origin, raw WebAuthn — the action sheet is server-rendered so page JS can't rewrite it), and receive an opaque **write-auth token**. Send it as `X-Run402-Write-Auth: Bearer ` ALONGSIDE the control-plane session bearer on `POST /projects/v1`, `POST /apply/v1/plans[/:plan_id/commit]`, `POST /apply/v1/operations/:operation_id/resume`, and `POST /content/v1/plans[/:plan_id/commit]`. The session is target-scoped (never platform-wide), 30 min idle / 4 h absolute, dies with the cp session, and covers **routine** slices only — destructive/membership/payment/secret-reveal surfaces always require a SIWX wallet or fresh step-up. Authorization (org membership/grant) is re-checked live on every write; the write-auth session adds the signed-intent requirement, it never replaces authz. SIWX wallet auth on these routes is unchanged. Passkeys authenticate; they never pay — paid operations keep the existing x402/Stripe rails. **CLI write-login (loopback-PKCE, RFC 8252).** A headless CLI gets a write-capable control-plane session without a stored secret: it starts a `127.0.0.1:PORT` server, opens the browser to `GET /agent/v1/control-plane/cli/authorize?redirect_uri=&code_challenge=&state=&nonce=` (302 → the console runs the passkey ceremony + approves), receives the auth code on the loopback redirect, then exchanges it at `/cli/token` (PKCE S256 + state) for a session minted with `provenance=loopback_pkce`. A `device_flow` session is read/low-stakes only — high-stakes ops return `STEP_UP_REQUIRED` with `details.reason="device_flow_forbidden"`. **Email-addressed transfers (folded into the unified transfer noun).** To hand a project to someone by **email**, address the unified initiate route with `to_email` instead of `to_wallet`: `POST /projects/v1/:project_id/transfers { to_email, message?, retain_collaborator?: { role: "developer" } }` (owner/admin SIWX or control-plane session + step-up). The recipient claims it via `POST /agent/v1/transfers/:transfer_id/claim` into an org they own (or a new wallet-less one); preview/cancel/inbox use the same kind-agnostic `/agent/v1/transfers/*` endpoints. Reuses the transfer engine's atomic ownership flip + authority re-home (grants + CI delegates revoked) + audit. See the Project Transfers table below. (The former standalone `/agent/v1/handoffs/*` surface was removed.) **Retain-the-builder on handoff (v1.91, add-project-handoff-retain-collaborator).** A handoff normally severs the sender's access entirely (authority is re-homed to the new org). The sender MAY instead offer to stay on by building the project for the new owner: pass `retain_collaborator: { role: "developer" }` on init (the subject is always the initiating owner — you can only offer to keep *yourself* on; `developer` is the only accepted role, capped so a sender can never retain owner/admin of the recipient's org). The recipient sees a `retain_collaborator` block in the preview (who, the role, and that the access spans the WHOLE org) and must **explicitly** pass `accept_retained_collaborator: true` at claim — opt-in; omitting it severs exactly as before. On acceptance the sender's principal becomes a plain `developer` member of the new org (created in the claim transaction, after the authority wipe), with no expiry; the new owner can remove them anytime via the member-revoke route. Tradeoff: a membership is org-wide, so if the new owner later adds other projects to that same org the retained developer can reach them too (until removed) — surfaced in the preview block. **Delegates (v1.78 — the credential an agent actually holds).** A *delegate* is a scoped, capped, expiring, revocable credential an owner mints for an agent/ci principal so the thing the agent holds equals the authority its grant was given. A delegate only ever NARROWS a grant (authz = grant ∩ scope ∩ cap ∩ expiry), is NEVER an owner (cannot delete/transfer/manage members), fails closed if the project is transferred to another org, and is killed instantly by one revoke. Per-rail kinds: `run402_agent_key` (control-plane bearer, returned once), `ci_oidc` (the GitHub-OIDC CI federation), `tempo_access_key` + `base_disposable_eoa` (payment rails, spend-cap enforced). Owner-gated, audited. ``` POST /projects/v1/:project_id/delegates # SIWX (owner of project's org); issue { grant_id, kind, scope:{v:1,capabilities[],projects?}, spend_cap?, expires_at? }. run402_agent_key → token returned ONCE GET /projects/v1/:project_id/delegates # SIWX (owner of project's org); list delegates (names/scope/cap only — never secret material) DELETE /projects/v1/:project_id/delegates/:delegate_id # SIWX (owner of project's org); revoke (immediate; base rail stops refills + sweeps) POST /projects/v1/:project_id/delegates/:delegate_id/rotate # SIWX (owner of project's org); rotate (revoke + reissue same scope/cap; fresh token for run402_agent_key) ``` ``` GET /projects/v1/admin/:project_id/usage # service_key; usage vs limits GET /projects/v1/admin/:project_id/schema # service_key; tables, columns, RLS policies POST /projects/v1/admin/:project_id/sql # service_key + lifecycleGate; run SQL (DDL + queries). Returns { rows, row_count } POST /projects/v1/expose/validate # SIWX; validate manifest without live project schema POST /projects/v1/admin/:project_id/expose/validate # service_key; validate manifest against project schema + optional migration SQL POST /projects/v1/admin/:project_id/expose # service_key + lifecycleGate; apply RLS manifest (see "Expose manifest") GET /projects/v1/admin/:project_id/expose # service_key; current manifest, with source: "applied" | "introspected" POST /projects/v1/admin/:project_id/promote-user # service_key + lifecycleGate; { email } -> grants project_admin POST /projects/v1/admin/:project_id/demote-user # service_key + lifecycleGate ``` ### Lifecycle (~104-day soft-delete grace) After lease expires, a project moves through `active → past_due → frozen → dormant → purged`: | State | Day | Control plane | Data plane | Scheduled fns | Subdomain | |-------|-----|---------------|------------|---------------|-----------| | `active` | — | read+write | read+write | running | claimed | | `past_due` | 0 | read+write | read+write | running | claimed | | `frozen` | +14 | **403** | read+write | running | reserved | | `dormant` | +44 | **403** | read+write | **paused** | reserved | | `purged` | +104 | terminal | terminal | terminal | claimable +14d | The data plane keeps serving end users throughout grace. Only owner control-plane writes (deploys, SQL execution, expose apply, secret rotation, mailbox and KMS signer mutations, subdomain/domain claims, function upload, and auth/admin writes) are gated. Read endpoints remain available where they do not mutate project state. Tier renewal at any point reactivates the project and clears all timers in one transaction. Lifecycle gating returns **HTTP 403** with `code: PROJECT_PAST_DUE` / `PROJECT_FROZEN` / `PROJECT_DORMANT` and `details: { lifecycle_state, entered_state_at, next_transition_at }`. (HTTP 402 is reserved for x402 payment challenges from the `@x402/express` middleware — overloading 402 caused `@x402/fetch` to mis-parse Run402 envelopes as x402 challenges.) ## Provisioning (typed example) ```typescript import { x402Client, wrapFetchWithPayment } from "@x402/fetch"; import { ExactEvmScheme } from "@x402/evm/exact/client"; import { toClientEvmSigner } from "@x402/evm"; import { createSIWxPayload, encodeSIWxHeader } from "@x402/extensions/sign-in-with-x"; import { privateKeyToAccount } from "viem/accounts"; import { createPublicClient, http } from "viem"; import { baseSepolia } from "viem/chains"; import { randomBytes } from "node:crypto"; const account = privateKeyToAccount(process.env.PRIVATE_KEY as `0x${string}`); const publicClient = createPublicClient({ chain: baseSepolia, transport: http() }); const signer = toClientEvmSigner(account, publicClient); const x402 = new x402Client(); x402.register("eip155:84532", new ExactEvmScheme(signer)); // testnet — register the SPECIFIC chain, not eip155:* const fetchPaid = wrapFetchWithPayment(fetch, x402); // 1) Subscribe to prototype tier await fetchPaid("https://api.run402.com/tiers/v1/prototype", { method: "POST" }); // 2) Provision a project (SIWX-authenticated) const siwxPayload = await createSIWxPayload({ domain: "api.run402.com", uri: "https://api.run402.com/projects/v1", statement: "Sign in to Run402", version: "1", nonce: randomBytes(16).toString("hex"), issuedAt: new Date().toISOString(), expirationTime: new Date(Date.now() + 5 * 60_000).toISOString(), chainId: "eip155:84532", type: "eip191", }, account); const res = await fetch("https://api.run402.com/projects/v1", { method: "POST", headers: { "Content-Type": "application/json", "SIGN-IN-WITH-X": encodeSIWxHeader(siwxPayload) }, body: JSON.stringify({ name: "my-app" }), }); const { project_id, anon_key, service_key } = await res.json(); ``` `anon_key` and `service_key` are permanent project identifiers; lease enforcement is server-side. **Don't embed `service_key` in browser code** — CORS is intentionally open (`*`), so a leaked service_key is exploitable from any origin. ## REST API (PostgREST proxy) | Path | Method | Auth | Description | |------|--------|------|-------------| | `/rest/v1/:table` | GET | apikey | Read rows | | `/rest/v1/:table` | POST | apikey | Insert rows (single object or array for bulk) | | `/rest/v1/:table` | PATCH | apikey | Update rows (chain with `?col=eq.val`) | | `/rest/v1/:table` | DELETE | apikey | Delete rows | PostgREST query syntax: `?select=`, `?col=eq.value`, `?col=gt.5`, `?order=col.desc`, `?limit=N`, `?offset=N`. `Prefer: return=representation` returns the inserted/updated row. `apikey` is auto-forwarded as `Authorization: Bearer` to PostgREST. Pick: - `apikey: ` → role `anon` (RLS applies) - `apikey: ` → role `service_role` (bypasses RLS, server-side only) - `apikey: ` + `Authorization: Bearer ` → role `authenticated` (RLS scoped to user) ## Expose manifest — dark-by-default tables **Tables you create are unreachable via `/rest/v1/*` until your manifest declares them with `expose: true`.** This eliminates the "agent created a table, forgot RLS, data leaked" footgun. The manifest is the single source of truth; convergent (applying the same manifest twice is a no-op; items removed between applies have their policies, grants, triggers, and views dropped). JSON Schema: . Validate before applying: - `POST /projects/v1/expose/validate` uses SIWX wallet auth (active tier not required) and validates against an empty project schema plus optional `migration_sql`. - `POST /projects/v1/admin/:project_id/expose/validate` uses the project `service_key` and validates against the current project schema plus optional `migration_sql`. Both validation endpoints are non-mutating: they do not apply the manifest, execute SQL, write deploy plans, upload content, commit releases, update `internal.project_manifest`, or reload PostgREST. Body: ```json { "manifest": { "version": "1", "tables": [], "views": [], "rpcs": [] }, "migration_sql": "optional CREATE TABLE/VIEW/FUNCTION or ALTER TABLE ADD COLUMN SQL, parsed only" } ``` They return `200 OK` when validation runs, even when `has_errors: true`: ```json { "has_errors": false, "errors": [], "warnings": [] } ``` Issues use `{ "type", "severity", "detail", "fix?" }`. Manifest shape failures are returned as `schema-shape` errors in the same envelope. Statements in `migration_sql` that look like DDL but are not understood by the lightweight parser produce `validation-inconclusive` warnings; SQL is never executed. ```bash curl -X POST https://api.run402.com/projects/v1/admin/$PROJECT_ID/expose \ -H "Authorization: Bearer $SERVICE_KEY" \ -H "Content-Type: application/json" \ -d '{ "$schema": "https://run402.com/schemas/manifest.v1.json", "version": "1", "tables": [ { "name": "items", "expose": true, "policy": "user_owns_rows", "owner_column": "user_id", "force_owner_on_insert": true }, { "name": "audit", "expose": false } ], "views": [ { "name": "leaderboard", "base": "items", "select": ["user_id", "score"], "expose": true } ], "rpcs": [ { "name": "compute_streak", "signature": "(user_id uuid)", "grant_to": ["authenticated"] } ] }' ``` Built-in policies: | Policy | Allows | |---|---| | `user_owns_rows` | Rows where `owner_column = auth.uid()`. With `force_owner_on_insert: true`, a BEFORE INSERT trigger sets it automatically. **Default for user-scoped data.** | | `public_read_authenticated_write` | Anyone reads. Any authenticated user writes any row. | | `public_read_write_UNRESTRICTED` | Fully open. Requires `i_understand_this_is_unrestricted: true`. | | `custom` | Provide `custom_sql` with `CREATE POLICY` statements. | Views run with `security_invoker=true` — they inherit the underlying table's RLS. RPCs need explicit listing in `rpcs[]` (a database event trigger revokes PUBLIC EXECUTE on every newly-created function; views and RPCs not in the manifest are unreachable). `GET /projects/v1/admin/:project_id/expose` returns the live state with `source: "applied"` (came from a prior apply) or `"introspected"` (no manifest applied; reconstructed from DB state). **Preferred path: ship the manifest as `database.expose` in your `ApplySpec`.** The gateway validates it against the migration SQL atomically with the rest of the apply — see "Unified apply" below. ## SQL guardrails `POST /projects/v1/admin/:project_id/sql` blocks: `CREATE EXTENSION`, `COPY ... PROGRAM`, `ALTER SYSTEM`, `SET search_path`, `CREATE/DROP SCHEMA`, `GRANT/REVOKE`, `CREATE/DROP ROLE`. Use the manifest for access control instead of `GRANT`. Each project lives in its own Postgres schema. Cross-schema access is blocked. The PostgREST `db-pre-request` hook validates JWT claims match the project. Schema names use the form `pNNNN`. ### Idempotent migrations `CREATE TABLE IF NOT EXISTS` only handles "already exists" — it won't add new columns. For evolving schemas, wrap `ALTER TABLE` in a `DO` block: ```sql CREATE TABLE IF NOT EXISTS items (id serial PRIMARY KEY, title text NOT NULL); DO $$ BEGIN ALTER TABLE items ADD COLUMN priority int DEFAULT 0; EXCEPTION WHEN duplicate_column THEN NULL; END $$; ``` Safe to re-run on every deploy. ## Unified apply — `/apply/v1` The canonical write primitive (v1.48+). One `ApplySpec` lands the entire app — migrations + RLS manifest + secrets + functions + site files + CDN assets + web routes + subdomains — atomically. The spec type is `ApplySpec` (which extends `ReleaseSpec` with an optional `assets` slice). Bytes flow through content-addressed storage (CAS) so unchanged files dedupe across writes. ### State machine ``` plan POST /apply/v1/plans upload PUT presigned S3 URLs (per missing content sha; bytes never go through the gateway body) commit POST /apply/v1/plans/:plan_id/commit → validate (spec well-formed; references resolve; CAS bytes present for every SHA) → stage (function versions, site deployment, secret set, subdomain reservation) → migrate-gate (only if migrations present; /rest/v1/* returns 503 + Retry-After for ~60s) → migrate (advisory-locked transaction; new id = run, same id+checksum = noop, mismatch = hard error) → schema-settle (canary SELECT until PostgREST reloads; up to 12×500ms) → activate (single transaction: flip release pointers, clear gate) → ready poll GET /apply/v1/operations/:operation_id (until status terminal: ready | failed | rolled_back) events GET /apply/v1/operations/:operation_id/events (phase event stream, polling target) list GET /apply/v1/operations (pageable operations, newest first) resume POST /apply/v1/operations/:operation_id/resume (if schema_settling or activation_pending) ``` ### Routes | Path | Method | Auth | Cost | Description | |------|--------|------|------|-------------| | `/apply/v1/plans` | POST | SIWX + lifecycleGate | Free with tier | Plan a release. Returns the v2 plan envelope: `kind: "plan_response"`, nullable `plan_id`/`operation_id`, gateway-computed `manifest_digest`, `missing_content[]`, top-level resource diff buckets, `summary`, `warnings[]`, `expected_events[]`, optional `payment_required`. Plan body limit 5 MB; bytes always go direct-to-S3. Naturally idempotent on `(project_id, manifest_digest)`. Add `?dry_run=true` to compute the same envelope without creating plan/operation rows; dry-run requires an inline spec and rejects `manifest_ref`. | | `/apply/v1/plans/:plan_id/commit` | POST | SIWX + lifecycleGate | Free with tier | Drive the state machine. Idempotency-Key honored. Returns `{ operation_id, release_id, status, urls?, error? }`. | | `/apply/v1/operations` | GET | apikey | Free | Operations for the project, newest first. Query: `limit` (default 50, max 100), `before` (operation id cursor), `status` (single or comma-separated), `since` (ISO timestamp), `project_id` (must match authenticated project), `include_total=true` (opt-in exact count). Response includes `operations`, `has_more`, `next_cursor`, and `total` only when requested. | | `/apply/v1/operations/:operation_id` | GET | apikey | Free | Snapshot the operation: `status`, `payment_required`, structured `error`, `activate_attempts`, `target_release_id`. | | `/apply/v1/operations/:operation_id/events` | GET | apikey | Free | Phase event stream. Polling target until terminal. | | `/apply/v1/operations/:operation_id/resume` | POST | SIWX | Free | Re-run the failed phase forward when in `activation_pending` or `schema_settling`. **NOT lifecycle-gated** — completes already-authorized work. Migrations are NEVER replayed. | | `/apply/v1/releases/:release_id/promote` | POST | SIWX or CI session (`deploy`) + lifecycleGate | Free | Pointer-swap recovery: point `live_release_id` at a prior release without re-running the apply pipeline. Body `{ project_id, allow_warning_codes? }`. Refuses non-`ready` releases and no-op self-promotes. Emits structured warnings (`MIGRATIONS_NOT_REVERSIBLE`, `FUNCTION_VERSION_MISMATCH`, `ASSET_GC`) that the caller can opt into via `allow_warning_codes`. Flushes `ssr_cache` for the project's bound hosts. Logs to operation history as `operation_kind: "promote"`. | | `/apply/v1/releases/:release_id` | GET | apikey | Free | Get the activation-time materialized state of a specific release. Response: `kind: "release_inventory"`, `state_kind: "effective"` (active/superseded) or `"desired_manifest"` (failed/staged). Capability `agent-deploy-observability`. | | `/apply/v1/releases/active` | GET | apikey | Free | Get the active release's CURRENT LIVE state — reads live tables. A `setSecret` call between activation and now appears here. Response's `state_kind: "current_live"`. Distinct from `/releases/:release_id` which is the activation-time snapshot. 404 `NO_ACTIVE_RELEASE` if no active release. | | `/apply/v1/releases/diff` | GET | apikey | Free | Diff two releases. Query params: `from=&to=`. Cross-project → 404 `RESOURCE_NOT_FOUND`. `from === to` → 400 `DIFF_SAME_RELEASE`. `to=empty` → 400 `INVALID_DIFF_TARGET`. Migrations are MONOTONIC (`applied_between_releases: string[]`), distinct from the plan response's `{new, noop}` shape. | | `/apply/v1/resolve` | GET | apikey | Free | Diagnose stable host/path resolution for a project-owned host. Query params: `host=`, optional `path` (default `/`) and `method` (default `GET`). Returns binding status, active release id/generation, route/static manifest SHAs, static manifest metadata, normalized path, match kind, static SHA, cache class/policy, authorization result, fallback state, and legacy immutable-risk diagnostics. | ### Plan response and dry-run `POST /apply/v1/plans` now returns the agent-deploy-observability plan envelope: ```json { "kind": "plan_response", "schema_version": "agent-deploy-observability.v1", "plan_id": "plan_...", "operation_id": "op_...", "base_release_id": "rel_...", "manifest_digest": "", "is_noop": false, "summary": "Adds one site path", "warnings": [], "expected_events": ["stage.start", "activate.start", "ready"], "missing_content": [{ "sha256": "", "size": 123, "present": false }], "migrations": { "new": [], "noop": [] }, "site": { "added": [], "removed": [], "changed": [] }, "functions": { "added": [], "removed": [], "changed": [] }, "secrets": { "added": [], "removed": [] }, "subdomains": { "added": [], "removed": [] }, "routes": { "added": [], "removed": [], "changed": [] }, "static_assets": { "unchanged": 0, "changed": 0, "added": 1, "removed": 0, "newly_uploaded_cas_bytes": 123, "reused_cas_bytes": 0, "deployment_copy_bytes_eliminated": 0, "legacy_immutable_warnings": [], "previous_immutable_failures": [], "cas_authorization_failures": [] } } ``` With `POST /apply/v1/plans?dry_run=true`, the gateway validates, resolves the base, checks missing content, computes the same diff/warnings/events envelope, and returns `plan_id: null` and `operation_id: null`. No `internal.apply_plans`, `internal.apply_operations`, or idempotency-key rows are written. Dry-run requires the full spec inline; `manifest_ref` returns HTTP 400 `DRY_RUN_REQUIRES_INLINE_SPEC`. Static site releases now materialize a canonical `run402.static_manifest.v1` alongside the route manifest. The `static_assets` diff summarizes how many static paths are unchanged/changed/added/removed, how many CAS bytes are newly required vs reused, legacy immutable-cache risks, previous immutable same-path hard failures, and any CAS authorization failures. Activation verifies the static manifest and every referenced CAS object before flipping the active release pointer. ### Static public paths By default, apply-v1 uses implicit public paths for backwards compatibility: a release static asset such as `events.html` is directly reachable as `/events.html`, `index.html` is reachable as `/`, and `docs/index.html` is reachable as `/docs/`. Use `site.public_paths` when the public URL table should be separate from private release asset filenames: ```json { "site": { "replace": { "events.html": { "sha256": "", "size": 4096, "content_type": "text/html" } }, "public_paths": { "mode": "explicit", "replace": { "/events": { "asset": "events.html", "cache_class": "html" } } } } } ``` In explicit mode, only paths in `public_paths.replace` are direct static URLs. The asset filename above is not directly reachable as `/events.html`. Explicit mode is sticky: later `site.patch` changes that omit `site.public_paths` inherit the existing explicit public table, so newly added assets do not become public by filename. To change public paths in v1, send a complete `public_paths.replace` table. To intentionally restore filename-derived reachability, send `site.public_paths: { "mode": "implicit" }`; plan warnings flag this because public reachability may widen. Public path keys must be absolute paths such as `/events` with no query string, fragment, raw or encoded slash/backslash separator tricks, dot segments, control characters, duplicate canonical paths, or internal Run402 namespaces such as `/_cas/*` and `/_run402/*`. Entries currently accept only `{ "asset", "cache_class" }`; arbitrary response headers are rejected in v1. Each `asset` must be a relative release static asset path in the final materialized release. Release inventory includes `static_public_paths[]` with `public_path`, `asset_path`, `reachability_authority` (`implicit_file_path`, `explicit_public_path`, or `route_static_alias`), `direct`, `cache_class`, and route-only `methods` when applicable. Authenticated `GET /apply/v1/resolve` diagnostics include the matched `asset_path`, `reachability_authority`, and `direct` flag. ### Secrets are out-of-band, not in the spec As of capability `secrets-isolation` (2026-05-03), `secrets.set` and `secrets.replace_all` are no longer accepted by `/apply/v1/plans` (HTTP 400 `INVALID_SPEC`). Set values via `POST /projects/v1/admin/{id}/secrets` with body `{ "key": "KEY1", "value": "..." }` BEFORE the deploy, then declare them on the deploy with `secrets.require: ["KEY1","KEY2"]`. The plan response includes a `MISSING_REQUIRED_SECRET` warning for keys that don't exist yet (HTTP 201 — non-blocking). The commit hard-errors with HTTP 422 if a required key is deleted between plan and commit. `secrets.delete[]` removes keys atomically with the activate phase. **Hard limit: 4 KiB per secret value (UTF-8 byte length)**; larger values reject with HTTP 413 `SECRET_VALUE_TOO_LARGE`. **Secret keys must match `^[A-Z_][A-Z0-9_]{0,127}$`.** Caveat: secret values are still passed to AWS Lambda as environment variables, so they are visible in CloudWatch Logs Insights queries against the function's log group. Treat the Lambda environment as the residual exposure surface; the platform does not encrypt them inside Lambda's environment block. ### Plan-time warning envelope (`WarningEntry`) Every `/apply/v1/plans` response carries a `warnings: WarningEntry[]` field (always present; empty array when no warnings). The shape: ```json { "code": "MISSING_REQUIRED_SECRET", "severity": "high", "requires_confirmation": true, "message": "Required secret keys are not yet set: OPENAI_API_KEY", "affected": ["OPENAI_API_KEY"], "details": { "missing_keys": ["OPENAI_API_KEY"] } } ``` Agents that want a "set then deploy" UX should detect `code: "MISSING_REQUIRED_SECRET"` in `warnings[]` and prompt the user (or auto-call setSecret) before committing. The plan stays valid for 24h; the warning is recomputed on every plan-cache hit so a key set after the initial plan is reflected on the next read. ### Release reads (capability `agent-deploy-observability`) Three GET endpoints expose materialized release state for agents that need to answer "what is currently deployed?" or "what changed between two releases?": - `GET /apply/v1/releases/` returns the **activation-time snapshot** — the materialized state captured when the release was activated. Persists across `setSecret` / function-config / subdomain mutations. `state_kind: "effective"` for active/superseded releases (snapshot read); `state_kind: "desired_manifest"` for failed/staged releases (manifest replay). - `GET /apply/v1/releases/active` returns the **CURRENT LIVE state** of the project — reads live tables. A `setSecret` call between activation and now WILL appear in this response. `state_kind: "current_live"`. Returns 404 `NO_ACTIVE_RELEASE` if the project has no active release. Use this when the agent needs to know what the deployed app sees RIGHT NOW. - `GET /apply/v1/releases/diff?from=&to=` returns the same diff envelope shape as the plan response, with `kind: "release_diff"`. Migrations are monotonic (`applied_between_releases: string[]`), distinct from the plan response's `{new, noop}` shape. All three endpoints are always available and always return JSON envelopes for errors — never HTML 404 (that was the bug #106/#107 closed). ### Stable host/path diagnostics `GET /apply/v1/resolve?host=&path=/assets/app.js&method=GET` is an authenticated read for agent diagnostics. The `apikey` must belong to the project bound to the host. Run402 subdomains and active custom domains resolve to the current live release; deployment-specific preview hosts stay on the deployment-id path and are not rewritten through this stable-host resolver. Successful responses include: ```json { "hostname": "example.com", "host_binding_id": "custom:example.com", "binding_status": "active", "project_id": "prj_1741340000_0042", "channel": "production", "release_id": "rel_...", "release_generation": 12, "route_manifest_sha256": "", "static_manifest_sha256": "", "static_manifest_metadata": { "file_count": 4, "total_bytes": 12345, "cache_classes": { "html": 1, "immutable_versioned": 2, "revalidating_asset": 1 }, "cache_class_sources": { "inferred": 4 }, "spa_fallback": "/index.html" }, "normalized_path": "/assets/app.abc123.js", "match": "static_exact", "static_sha256": "", "asset_path": "assets/app.abc123.js", "reachability_authority": "implicit_file_path", "direct": true, "content_type": "application/javascript", "cache_class": "immutable_versioned", "cache_policy": "public, max-age=31536000, immutable", "authorized": true, "authorization_result": "authorized", "cas_object": { "sha256": "", "exists": true, "expected_size": 1234, "actual_size": 1234 }, "fallback_state": "not_used", "legacy_immutable_risk": [], "emergency_fallback": { "enabled": false }, "result": 200 } ``` For matched static objects, `authorization_result` is `authorized`, `missing_cas_object`, `unauthorized_cas_object`, `size_mismatch`, or `unfinalized_or_deleting_cas_object`; static manifest compatibility failures use `match`, `authorization_result`, and `fallback_state` value `unsupported_manifest_version`; cached stable-host misses can report `fallback_state: "negative_cache_hit"`. Bad CAS states report `result: 503` without leaking internal CAS URLs. HTML static hits also include `response_variant` diagnostics with `varies_by: ["hostname"]` and a `variant_inputs_hash`, because transformed HTML cache identity can differ by canonical hostname even when the raw static SHA is shared. Misses are diagnostic, not redirects: absent paths return `match: "none"`/`result: 404`; missing manifests or missing fallback targets return `result: 503`; invalid canonical paths return `match: "path_error"` with `error_code`. The response never exposes internal CAS URLs. ### Content negotiation (`/content/v1/*`) Bytes always travel through CAS. The plan response lists missing SHAs; you negotiate uploads via: | Path | Method | Auth | Description | |------|--------|------|-------------| | `/content/v1/plans` | POST | apikey | Tell the gateway which SHAs you have, get presigned PUT URLs (single or multipart) for the missing ones. | | `/content/v1/plans/:plan_id/commit` | POST | apikey | Finalize: complete multipart uploads, promote staged objects to CAS. | PUT each `parts[].url` directly; for multipart, capture each `ETag` and pass them in the `commit` body as `parts: [{ part_number, etag }]`. ### SSR ISR cache (`/cache/v1/*`) — capability `ssr-isr-cache` (v1.52) Paired with `@run402/astro` v1.0+ (the Astro SSR preset). The gateway holds an origin-side ISR cache backed by CAS — every cacheable SSR response is stored as `_cas//` keyed by canonical `{host}:{release_id}:{locale}:{method}:{pathname}{?normalizedSearch}`. Cache writes are generation-guarded; concurrent MISS renders for the same key on one task share a single Lambda invocation (in-process single-flight dedup). | Path | Method | Auth | Description | |------|--------|------|-------------| | `/cache/v1/invalidate` | POST | Bearer service_key | Invalidate cache rows. Body discriminates on `kind`: `"exact"` (`{ host, path }`), `"prefix"` (`{ host, prefix }`), `"all"` (`{ host }`), or `"many"` (`{ urls: [...] }`). Host MUST be owned by caller's project; cross-project returns 403 `R402_CACHE_INVALIDATION_HOST_FORBIDDEN`. DELETE + per-(project, host) generation increment happen atomically. | | `/cache/v1/inspect` | GET | Bearer service_key | Inspect cache row state for a canonical key. Query params: `host`, `path`, `locale?` (default project default), `release_id?` (default project active). Returns `{ status: "HIT" \| "MISS", host?, path?, locale?, releaseId?, cachedAt?, expiresAt?, writtenUnderGeneration?, contentSha256?, headers? }`. Status is NEVER `BYPASS` — inspect does not issue a request. | **Cacheability rules** (response is stored ONLY when ALL apply): - Method is GET or HEAD - Status is in default cacheable set: `200, 301, 302, 410` - Response has `Cache-Control: public, s-maxage=N` with `N > 0` - No `Set-Cookie` header - No `Cache-Control: private` or `no-store` - No `Vary` (except `Accept-Language`, which is in the cache key as `locale`) - Body ≤ 1 MiB - No `auth.*` helper called during render (taint check via SSR Lambda metadata envelope; any `auth.user()` / `auth.requireUser()` / payment primitives flip `cacheBypassTainted` and disqualify the response from ISR storage) **Read-time bypass** (lookup skipped entirely): method ≠ GET/HEAD, ANY `Cookie` header, ANY `Authorization` header. Bypass reason emitted as `x-run402-cache-reason: { method | cookie | auth }`. **Debug headers** on every SSR response (gateway-generated, overrides user-set values of the same name): `x-run402-request-id`, `x-run402-release-id`, `x-run402-function`, `x-run402-cache` (`HIT` / `MISS` / `BYPASS`), `x-run402-cache-reason` (on `BYPASS` only — one of `no_s_maxage | private | auth | cookie | set_cookie | unsupported_vary | method | too_large | non_cacheable_status`), `x-run402-cache-age` (on `HIT` only, in seconds), `x-run402-locale`. On uncaught function error: adds `x-run402-error-code: R402_SSR_RUNTIME_ERROR`. **Cleanup** runs on the hourly scheduler tick: expired rows past 1h grace, stale-release rows (`release_id != project.live_release_id`) past 24h grace. Underlying CAS objects reaped on the normal hourly CAS GC tick once the last `ssr_cache` ref drops. ### `spec.i18n` — routed locale context ```json "i18n": { "defaultLocale": "en", "locales": ["en", "es", "fr", "pt-BR"], "detect": ["cookie:r402_locale", "accept-language"] } ``` Top-level slice on `ReleaseSpec`, NOT under a `spec` wrapper — `i18n` sits alongside `database`, `functions`, `site`, `assets`, etc. inside the JSON manifest you POST to `/apply/v1/plans`. The "spec" prefix in prose is the TypeScript type name (`ReleaseSpec.i18n`), not a JSON path. **Cookie naming convention.** The platform-canonical cookie name is **`r402_locale`** — use it unless you have a reason not to. Server-side negotiation only sees cookies + headers (never `localStorage`), so apps with a language switcher MUST write the cookie alongside whatever client-side persistence they do: ```js document.cookie = `r402_locale=${lang}; path=/; max-age=31536000; samesite=lax`; ``` If your app already ships with a different name (e.g., `wl_locale` from a Next.js/wrangler import) you can keep it — just pass `"cookie:wl_locale"` in `detect[]`. The matching is exact, so a typo (`cookie:r402-locale` vs `cookie:r402_locale`) is a silent miss. **Canonical casing — validated at deploy time (landed).** Locales must be in their RFC 5646 §2.1.1 canonical form: primary language subtag lowercase, 4-alpha script subtag Titlecase, 2-alpha region subtag UPPERCASE, 3-digit region subtag preserved, variant + extension + private-use subtags lowercase. So `pt-BR` not `pt-br`, `zh-Hant` not `zh-hant`, `es-419` not `ES-419`. Both `locales[]` entries AND `defaultLocale` are validated. The platform rejects non-canonical entries at `/apply/v1/plans` with `R402_LOCALE_NOT_CANONICAL` carrying both `input` and `canonical` (the suggested fix) in the envelope's `fix:` field, plus a human-readable "Did you mean ..." message. The reason for reject-rather-than-canonicalize: your translation table (e.g. `section_translations.language`) needs to match byte-for-byte, and canonicalizing-on-input would create a silent split between the spec and your DB column. Better to reject at deploy than to surface as a runtime 404. Pick canonical, use it consistently. Underscore separators (`pt_BR`, common when copied from Java/Linux locale strings) are also rejected — RFC 5646 §2.1 specifies `-` as the subtag separator. **Negotiation runtime.** Walks `detect[]` in order; first hit wins. Accept-Language gets RFC 4647 §3.4 longest-prefix lookup (`zh-Hant-TW` → `zh-Hant` → `zh`) with a stable q-tie sort. Cookie matching is case-insensitive on the cookie value, case-sensitive on the cookie name. The negotiated value is byte-identical to a `locales[]` entry by construction. Surfaces in user code as: - `Astro.locals.run402.locale` (in `@run402/astro` pages / middleware) - `getRun402Context(request).locale` (in plain Node22 functions via `@run402/functions`) - `x-run402-locale` + `x-run402-default-locale` request headers (raw access) - `event.context.locale` + `.defaultLocale` (raw routed-envelope consumers) When the active release has no `i18n` slice, all of the above are `null` (not "default-locale"). Apps that need to render *something* should `?? 'en'` (or whatever fallback they prefer) at the read site. ### `ApplySpec` shape `ApplySpec` extends `ReleaseSpec` with an optional `assets` slice for CDN-served key/value content. Release-scoped slices are `database`, `secrets`, `functions`, `site`, `subdomains`, `routes`, `checks`. A spec with only release-scoped slices and no `assets` is a valid `ApplySpec` — release-only intent is structurally inferred, not a separate method. ```jsonc { "project_id": "prj_1741340000_0042", "base": { "release": "current" }, // or { "release": "empty" } for a clean slate "database": { "migrations": [ { "id": "001_init", "checksum": "", "sql": "CREATE TABLE IF NOT EXISTS items (id serial PRIMARY KEY, title text NOT NULL);" } ], "expose": { "version": "1", "tables": [ { "name": "items", "expose": true, "policy": "user_owns_rows", "owner_column": "user_id", "force_owner_on_insert": true } ] } }, "secrets": { "require": ["OPENAI_API_KEY"], "delete": ["OLD_KEY"] }, "functions": { "replace": { "summarize": { "runtime": "node22", "source": { "sha256": "", "size": 1234, "content_type": "application/javascript" }, "config": { "timeout_seconds": 10, "memory_mb": 256 }, "schedule": null } }, "patch": { "delete": ["legacy-func"] } }, "routes": { "replace": [ { "pattern": "/api/*", "methods": ["GET", "POST"], "target": { "type": "function", "name": "api" } } ] }, "site": { "replace": { "index.html": { "sha256": "", "size": 512, "content_type": "text/html" } }, "public_paths": { "mode": "explicit", "replace": { "/": { "asset": "index.html", "cache_class": "html" } } } }, "subdomains": { "set": ["my-app"] } } ``` **Replace vs patch per resource.** `site.replace` = "this is the whole site" (paths absent are removed). `site.patch.put` / `patch.delete` = surgical updates. Same replace/patch shape applies to `functions`; `routes.replace` replaces the route table; `routes: null` or omitted carries routes forward; `routes.replace: []` clears the route table. `secrets` is intentionally value-free (`require[]` / `delete[]` only), and `subdomains` has its own set/add/remove/delete shape. Top-level absence = leave untouched. Migrations are append-only (a new `id` runs once; same `id`+`checksum` is a noop; a different checksum is `MIGRATION_CHECKSUM_MISMATCH`). ### Web Routes `ApplySpec.routes` maps same-origin browser paths on deployed sites to serverless functions or exact static URL aliases. Route targets are public browser ingress and do not require a Run402 API key at the public edge; direct `/functions/v1/:name` remains API-key protected. Author routes as a replace-mode array, not as a path-keyed map: ```json { "functions": { "replace": { "admin": { "runtime": "node22", "source": { "sha256": "", "size": 2048 } }, "api": { "runtime": "node22", "source": { "sha256": "", "size": 2048 } } } }, "site": { "replace": { "events.html": { "sha256": "", "size": 4096, "content_type": "text/html" } } }, "routes": { "replace": [ { "pattern": "/admin", "methods": ["GET"], "target": { "type": "function", "name": "admin" } }, { "pattern": "/admin/*", "target": { "type": "function", "name": "admin" } }, { "pattern": "/api/*", "methods": ["GET", "POST", "OPTIONS"], "target": { "type": "function", "name": "api" } }, { "pattern": "/events", "methods": ["GET"], "target": { "type": "static", "file": "events.html" } } ] } } ``` `routes: null` or omitted carries the base release routes forward. `routes.replace: []` clears the route table. Each target function must exist in the same materialized release. Each static target file must exist in the final materialized static site after applying `site.replace` or `site.patch`, including when a carried-forward route table is preserved across a site deletion. Supported route patterns: - Exact absolute paths: `/admin`, `/login`, `/og.png`. - Final prefix wildcards only for function targets: `/api/*`, `/admin/*`. - No query strings, no regex, no mid-pattern wildcards, max 100 routes, max 256 bytes per pattern. Function target shape: ```json { "type": "function", "name": "api" } ``` Function route `methods` may be omitted for all supported methods, or set to any non-empty subset of `GET`, `HEAD`, `POST`, `PUT`, `PATCH`, `DELETE`, and `OPTIONS`. `GET` routes also match `HEAD`. Static alias target shape: ```json { "type": "static", "file": "events.html" } ``` Static aliases are exact-only. Prefix patterns such as `/docs/*` are rejected for static targets. Static alias `methods` are required and must be `["GET"]` or `["GET", "HEAD"]`; both materialize to effective `GET` + `HEAD`. `target.file` is a materialized static-site file path, relative to the site root: no leading slash, query, fragment, backslash, trailing slash/directory shorthand, empty segment, `.` segment, or `..` segment. The same normalized path may appear more than once only when effective methods are disjoint. For example, `/login` can serve a static alias for `GET`/`HEAD` and a function for `POST`; overlapping effective methods are rejected. Matching is deterministic and identical on managed subdomains, deployment hosts, and verified custom domains. Exact routes beat prefix routes; among prefixes, the longest prefix wins. Exact `/admin` matches both `/admin` and `/admin/`. `/admin/*` matches `/admin/settings` but not `/admin`, `/admin/`, `/admin.css`, or `/administrator`. Query strings do not affect selection and are forwarded unchanged for functions. `GET` routes also match `HEAD`. If a method path-matches a route but no allowed method matches, Run402 returns `405 Method Not Allowed` with the union of allowed methods for that path; matched route failures fail closed and never fall through to static HTML. Static aliases serve the target file bytes at the public alias URL. They do not redirect to or expose the underlying file path, rewrite query strings, run header rules, or invoke framework routing. In the static manifest they materialize as route-only entries (`direct: false`, `reachability_authority: "route_static_alias"`) backed by release static assets. Direct static serving and static aliases share content type, cache-control, HTML mutation, conditional request behavior, and `HEAD` response behavior. A missing static alias target returns a platform static-route error and does not fall back to SPA HTML. Plan/read surfaces include `routes: { manifest_sha256, entries }` on release inventory and `routes: { added, removed, changed }` in plan/release diffs. Static aliases render as route entries with `target: { "type": "static", "file": "" }`; warning and error `affected[]` strings use phrasing such as `/events -> static file events.html`. Route warnings include `PUBLIC_ROUTED_FUNCTION`, `ROUTE_TARGET_CARRIED_FORWARD`, `ROUTE_SHADOWS_STATIC_PATH`, `WILDCARD_ROUTE_SHADOWS_STATIC_PATHS`, `ROUTE_TABLE_NEAR_LIMIT`, `STATIC_ALIAS_SHADOWS_STATIC_PATH`, `STATIC_ALIAS_RELATIVE_ASSET_RISK`, `STATIC_ALIAS_DUPLICATE_CANONICAL_URL`, `STATIC_ALIAS_REDUNDANT_PUBLIC_PATH`, `STATIC_ALIAS_EXTENSIONLESS_NON_HTML`, `STATIC_ALIAS_TABLE_NEAR_LIMIT`, and `PUBLIC_PATH_MODE_WIDENS_TO_IMPLICIT`. Static aliases currently count toward the same temporary combined 100-entry route cap as function routes; `STATIC_ALIAS_TABLE_NEAR_LIMIT` includes `details.limit_scope: "combined_routes_temporary"` until a separate static-alias cap lands. Non-goals: static aliases are not rewrites, redirects, Web Output, framework adapters, edge runtime, streaming, ISR, image optimization, query transforms, header rules, or route-level x402 policy. Function targets keep the standard Node 22 Fetch Request -> Response contract: `export default async (req: Request) => Response`. For routed function requests, `req.method` is the original browser method and `req.url` is the full public URL, including scheme, host, path, and query string (`https://.run402.com/...`, deployment host, or the verified custom domain). ```js export default async function handler(req) { const url = new URL(req.url); if (url.pathname === "/admin/oauth/google") { const redirectUri = new URL("/admin/oauth/google/callback", url.origin); const authUrl = new URL("https://accounts.google.com/o/oauth2/v2/auth"); authUrl.searchParams.set("client_id", process.env.GOOGLE_CLIENT_ID ?? "replace-me"); authUrl.searchParams.set("redirect_uri", redirectUri.toString()); authUrl.searchParams.set("response_type", "code"); authUrl.searchParams.set("scope", "openid email profile"); authUrl.searchParams.set("state", crypto.randomUUID()); return Response.redirect(authUrl, 302); } if (url.pathname === "/admin/oauth/google/callback") { const headers = new Headers({ location: "/admin" }); headers.append("Set-Cookie", "sid=example; HttpOnly; Secure; SameSite=Lax; Path=/"); headers.append("Set-Cookie", "flash=welcome; Secure; SameSite=Lax; Path=/; Max-Age=30"); return new Response(null, { status: 303, headers }); } return new Response("

Admin

", { headers: { "content-type": "text/html; charset=utf-8" }, }); } ``` The OAuth `redirect_uri` above is derived from `req.url`, so the same code works on `*.run402.com`, deployment hosts, and verified custom domains. Routed request fields available to user code: | Field | Contract | |---|---| | `req.method` | Original browser method. `GET` routes also match `HEAD`; a `HEAD` request still reaches the handler as `HEAD`. | | `req.url` | Full public URL with scheme, host, path, and query. Use `new URL(req.url)` for routing and OAuth callback URLs. | | `req.headers` | Browser headers forwarded through a duplicate-safe internal list and exposed through Fetch `Headers`. Cookie data is available through the `cookie` header. | | `await req.text/json/arrayBuffer()` | Buffered request body, max 6 MiB. Streaming request bodies are not supported. | | Route metadata | Internal only. Run402 transports `run402.routed_http.v1` context between edge/gateway and Lambda; user handlers should use Fetch `Request`, not the raw envelope. | Routed response behavior from user code: | Field | Contract | |---|---| | `Response.status` | HTTP 200-599 except `101 Switching Protocols`. WebSockets are not supported. | | `Response.headers` | Forwarded as duplicate-safe headers. Append multiple `Set-Cookie` values with `headers.append("Set-Cookie", value)`; Run402 preserves them as separate browser headers. | | `Location` | Redirect targets are forwarded unchanged. Use absolute URLs when leaving the current origin. | | `Response.body` | Buffered response body, max 6 MiB. `HEAD` responses send headers without body bytes. SSE and streaming responses are not supported. | | Cache | Dynamic route responses are never stored in Run402's shared CDN cache. If no `Cache-Control` is set, Run402 adds `Cache-Control: private, no-store` and `x-run402-cache: dynamic-bypass`. | | CORS | Run402 does not add default wildcard CORS. Implement `OPTIONS` and CORS headers in the function when cross-origin access is intended. | Internally, Run402 converts the Fetch response to `run402.routed_http.v1`; agents should treat this as implementation detail unless they are debugging gateway internals: ```json { "status": 302, "headers": [["location", "/dashboard"]], "cookies": ["sid=abc; HttpOnly; Secure; SameSite=Lax; Path=/"], "body": null } ``` Custom-domain readiness checklist: - Verify the route first on the managed subdomain or deployment host, then verify the same path on the custom domain. - Confirm the deploy operation is `ready` and release/deployment inventory shows `routes.entries` plus a non-null `routes.manifest_sha256`. - Confirm the custom domain is verified and serving the intended deployment. Custom domains use the Cloudflare Worker route manifest but must behave the same as managed domains. - If the managed domain works but the custom domain returns a routed failure, check for `ROUTED_INVOKE_WORKER_SECRET_MISSING` or `ROUTE_MANIFEST_LOAD_FAILED`; those indicate Worker/gateway configuration or manifest propagation, not user handler code. Troubleshooting route failures: | Code | Meaning | Recovery | |---|---|---| | `ROUTE_MANIFEST_LOAD_FAILED` | The host selected a route, but the active route manifest could not be loaded. | Wait for the operation to reach `ready`, confirm `routes.manifest_sha256` is present, then redeploy or contact Run402 support if the manifest stays unavailable. | | `ROUTED_INVOKE_WORKER_SECRET_MISSING` | Custom-domain Worker is missing the shared gateway invoke secret. | Managed domains may still work. Re-sync/deploy Worker config or contact Run402 support. | | `ROUTED_INVOKE_AUTH_FAILED` | Gateway rejected the internal routed invoke signature. | Retry after propagation; persistent failures indicate shared secret or clock/config drift. | | `ROUTED_ROUTE_STALE` | The selected route no longer matches the active release or host after revalidation. | Wait for route propagation, then retry or redeploy the same route table. | | `ROUTE_METHOD_NOT_ALLOWED` | The path matched a route, but the HTTP method was not allowed. | Add the method to `methods`, add an `OPTIONS` route for CORS preflight, or remove the method restriction. | | `STATIC_ROUTE_TARGET_NOT_FOUND` | A static alias matched, but its materialized target file could not be served. | Confirm the route target `file` exists in the final release's static site and redeploy. | | `ROUTED_RESPONSE_TOO_LARGE` | Handler response body exceeded the 6 MiB routed response limit. | Return a smaller body, move large assets to static deploy/storage, or redirect to a downloadable object. | For browser sessions, functions own app auth, CSRF checks, OAuth callbacks, cookie flags (`HttpOnly`, `Secure`, `SameSite`), Origin checks, and Fetch Metadata checks. ### Plan response ```json { "plan_id": "plan_...", "operation_id": "op_...", "base_release_id": "rel_...", "manifest_digest": "", "missing_content": [{ "sha256": "", "size": 512, "present": false }], "diff": { "is_noop": false, "resources": { "site": { "paths_added": ["index.html"], "paths_changed": [], "paths_removed": [] }, "database": { "migrations": [{ "id": "001_init", "status": "new" }] }, "functions": { "added": ["summarize"], "changed": [], "removed": [] }, "secrets": { "added": ["OPENAI_API_KEY"], "changed": [], "removed": [] }, "subdomains": { "added": ["my-app"], "removed": [] }, "routes": { "added": [], "removed": [], "changed": [] } } }, "payment_required": null } ``` For each `missing_content[i]` where `present: false`, hand the SHA + size to `POST /content/v1/plans` to get presigned PUT URLs. ### Commit response ```json { "operation_id": "op_...", "release_id": "rel_...", "status": "ready", "urls": { "site": "https://.run402.com" } } ``` If `status` is non-terminal (`schema_settling`, `activation_pending`), poll `GET /apply/v1/operations/:operation_id`. The auto-resume worker retries automatically; agents can also call `POST /apply/v1/operations/:operation_id/resume` to re-drive the failed phase forward immediately. ### Error fields specific to deploys `code` is one of `INVALID_SPEC`, `MIGRATION_FAILED`, `MIGRATION_CHECKSUM_MISMATCH`, `MIGRATE_GATE_ACTIVE`, `PLAN_NOT_FOUND`, `OPERATION_NOT_FOUND`, `NOT_RESUMABLE`. Two additional fields: `phase` (`validate | plan | stage | migrate | schema_settling | activate | commit | resume`) and `operation_id` for correlation. `mutation_state: "rolled_back"` means the active release is unchanged (commonly with `MIGRATION_CHECKSUM_MISMATCH`). `mutation_state: "unknown"` means commit returned 5xx — use `Idempotency-Key` to retry safely or check `GET /apply/v1/operations/:operation_id` first. ## OIDC federation for CI/CD — `/ci/v1/*` (v1.36) GitHub Actions push-to-deploy without storing wallet keys in CI. A wallet-signed delegation links a GitHub OIDC subject pattern to a project; the workflow exchanges its GitHub OIDC JWT for a 15-min run402 session JWT and uses it on the seven CI-callable deploy routes (`/content/v1/plans*`, `/apply/v1/plans*`, `/apply/v1/operations/:operation_id` + `/events` + `/resume`). Same trust model as Vercel, Supabase, Netlify, Cloudflare etc. — connecting the workflow grants the workflow runtime authority over the project. **Dual-authority disclosure.** Code deployed via CI runs with the project's runtime authority — `RUN402_SERVICE_KEY` in env, `adminDb()` (BYPASSRLS), and configured secrets via `process.env`. Database authority too — `spec.database` ships migrations, RLS/expose changes, and schema-altering SQL. The wallet-signed delegation's `Statement` field discloses both surfaces verbatim — the wallet UI shows the consent text at sign time. Revoking a binding stops future CI gateway requests but does NOT undo already-deployed code, stop in-flight deploy operations, rotate exfiltrated keys, or remove deployed functions. Compromise recovery: revoke + SIWE-deploy a known-good release + rotate any service-role key the deployed code may have read. ### Routes | Path | Method | Auth | Description | |------|--------|------|-------------| | `/ci/v1/bindings` | POST | SIWX (project owner) | Create a binding. Body: `{ project_id, provider: "github-actions", subject_match, allowed_actions: ["deploy"], allowed_events?, route_scopes?, github_repository_id?, expires_at?, nonce, signed_delegation }`. `route_scopes` is optional and defaults to `[]` (no route authority); when set, use exact/final-wildcard route patterns such as `["/admin","/admin/*"]`. The `signed_delegation` is a SIWE/SIWX-shaped wallet-signed message; gateway parses + verifies it byte-for-byte against canonical `Statement` and `Resources` builders. Returns 201 with the binding row, 409 on duplicate-active `(project_id, issuer, subject_match)`, 400 on `nonce_replay` (same wallet+nonce already used). | | `/ci/v1/bindings` | GET | SIWX | List bindings for a project. Query: `?project=` (required). Includes revoked. | | `/ci/v1/bindings/:binding_id` | GET | SIWX | Binding detail with full parsed `created_sig`. | | `/ci/v1/bindings/:binding_id/revoke` | POST | SIWX | Idempotent kill switch. Sets `revoked_at = NOW()`. Existing CI sessions are rejected on their next gateway request (per-request DB recheck). | | `/ci/v1/bindings/:binding_id/asset-scopes` | POST | SIWX (binding owner) | Replace `asset_key_scopes` on the binding — gates the `spec.assets` slice for CI sessions. Body: `{ asset_key_scopes: string[] }`. Each scope is either an exact key (`astro/hero.jpg`) or a wildcard prefix (`astro/*`); bare `*` / `**` rejected. Capped at 64 entries, 256 chars each. Empty array means "no asset authority" (CI sessions hitting `assets.put` / `assets.sync` get 403 `CI_ASSET_SCOPE_DENIED`). 404 on unknown binding, 409 on revoked binding (cannot mutate), 403 on wallet/owner mismatch. Idempotent — same scope list is a no-op. | | `/ci/v1/token-exchange` | POST | **none — the OIDC JWT is the auth** | RFC 8693-shaped JSON token exchange. Body: `{ grant_type: "urn:ietf:params:oauth:grant-type:token-exchange", subject_token: , subject_token_type: "urn:ietf:params:oauth:token-type:jwt", project_id }`. JSON only (NOT form-encoded). Body cap 12 KiB; `subject_token` cap 8192 bytes. Returns `{ access_token, token_type: "Bearer", expires_in, scope: "deploy" }`. `expires_in` may be < 900 if the binding's `expires_at` is closer than 15 minutes. | ### Token exchange — verification order 1. Validate RFC 8693 request shape and `subject_token.length <= 8192`. 2. Decode the JWT payload (NOT header) to read `iss`. Look up in `internal.trusted_oidc_issuers`. Unknown issuers → 401 `invalid_token` **without any external JWKS fetch**. 3. `jose.jwtVerify` with `algorithms: ["RS256"]` pinned and `clockTolerance: 30s`. Post-check `payload.aud === "https://api.run402.com"` exactly (rejects array-containing). 4. Validate required GitHub claims as non-empty strings: `sub`, `run_id`, `sha`, `actor`, `workflow_ref`, `repository`, `repository_id`, `event_name`. 5. Fetch candidates via the partial index `(project_id, issuer) WHERE revoked_at IS NULL`. Filter in app code by subject-pattern: exact match wins over wildcard, longer prefix wins among wildcards. Equal specificity → 403 `ambiguous_binding`. 6. Enforce `event_name ∈ binding.allowed_events`. Mismatch → 403 `event_not_allowed`. Default `allowed_events` is `['push', 'workflow_dispatch']` — `pull_request` and `pull_request_target` denied unless explicitly opted in. **This catches `pull_request_target` (the dangerous fork-PR variant) which substring-matching `:pull_request` in `sub` would miss.** 7. Enforce `payload.repository_id === binding.github_repository_id` if non-NULL. Mismatch → 403 `repository_id_mismatch`. NULL = soft-bound, accepted on `subject_match` alone (audit-logged distinctly as `ci_token_exchanged_softly_bound`). 8. Atomic conditional UPDATE on the binding (`WHERE id = $1 AND revoked_at IS NULL AND expires_at not stale`). 0 rows → 403 `access_denied` (revoked between SELECT and UPDATE). ### Subject pattern syntax `subject_match` accepts an exact OIDC subject string OR a string ending in a single `*` wildcard (matched as string-prefix, NOT regex). Reject: empty, control chars, multiple `*`, `*` not at the end, bare `*`, length > 256. Characters like `.`, `+`, `[`, `]`, `?` are accepted as literals — they're legal in git refs/tags/environment names. Examples: - Exact: `repo:user/repo:ref:refs/heads/main` — only the `main` branch. - Branch wildcard: `repo:user/repo:ref:refs/heads/*` — any branch. - Tag pattern: `repo:user/repo:ref:refs/tags/v*` — any `v*` tag. - Environment-gated (recommended for prod): `repo:user/repo:environment:production` — only workflows declaring `environment: production` (which can require manual approval in GitHub). **Caveat:** any matching workflow in the repo with `permissions: id-token: write` can attempt token-exchange — not necessarily only the intended deploy workflow. Use environment-gated subject patterns and branch protection to mitigate. `workflow_ref` constraint columns are deferred to a follow-up. ### CI deploy spec restriction (allowlist, not blocklist) When a CI session calls `POST /apply/v1/plans`, the gateway enforces a strict allowlist on the request body: - **Allowed top-level `spec` fields:** `project`, `database`, `functions`, `site`, `assets`, `base`, `routes`. Anything else (including `secrets`, `subdomains`, `checks`, or future `ApplySpec` additions) → 403 `forbidden_spec_field`. - **Forbidden by property presence** (rejected even when empty): `spec.secrets`, `spec.subdomains`, `spec.checks`. So `spec.secrets: {}` rejects. - **`spec.routes` requires delegated route scopes.** Bindings without `route_scopes` reject non-null `spec.routes` with 403 `forbidden_spec_field`. With scopes, the plan step compares the replace-mode route table to the current base table and rejects 403 `CI_ROUTE_SCOPE_DENIED` if any added, removed, or changed route is outside those scopes. Unchanged out-of-scope routes may be re-applied; `spec.routes: null` is allowed as preserve semantics. - **`spec.assets` requires delegated asset key scopes.** Mutations to `assets.put` / `assets.sync` / `assets.delete` reject 403 `CI_ASSET_SCOPE_DENIED` for any key falling outside `asset_key_scopes` on the binding. Scopes are either exact keys or `prefix/*` wildcards. `assets.sync.prune` requires a wildcard scope that fully contains the sync `prefix`. Defaults to NULL (closed) on bindings created via `POST /ci/v1/bindings`; mutate after the fact via `POST /ci/v1/bindings/:binding_id/asset-scopes`. - **`spec.base` restricted** to absent OR exactly `{ release: "current" }`. `{ release: "empty" }` and `{ release_id: ... }` reject (could nuke or pull state from a forbidden release). - **`manifest_ref` rejected** when non-null. CI is limited to inline specs under the 5 MB body cap. Public-repo CLI must do client-side preflight rejection on these fields when in CI mode. SIWE-authed requests bypass the restriction (the wallet has full authority). ### CI plan / operation tagging Plans created via a CI session are tagged with `created_via_ci_binding_id`. CI commit (`POST /apply/v1/plans/:plan_id/commit`) and resume (`POST /apply/v1/operations/:operation_id/resume`) reject HTTP 403 `forbidden_plan` if the plan was not created by the same binding — **same-binding-only**. This blocks the bypass where a CI session would commit a wallet-created plan that contains forbidden fields, AND prevents one CI binding from completing another binding's work. Wallet-authenticated commit/resume bypasses the guard (operator override). ### Error codes specific to CI federation `invalid_token`, `access_denied`, `insufficient_scope`, `event_not_allowed`, `repository_id_mismatch`, `ambiguous_binding`, `forbidden_spec_field`, `CI_ROUTE_SCOPE_DENIED`, `CI_ASSET_SCOPE_DENIED`, `forbidden_plan`, `nonce_replay`, `delegation_statement_mismatch`, `delegation_resource_uri_mismatch`, `signer_mismatch`, `delegation_oversized`, `delegation_parse_failed`, `delegation_signature_invalid`. ### Composition on the seven CI-callable routes The seven routes accept either their existing auth OR a CI session. Mandatory route order on the CI path: **auth helper → ciProjectResolver → lifecycleGate → restrictDeploySpecForCi (plan only) → ciPlanGuard (commit/resume only — runs BEFORE idempotency) → idempotencyMiddleware (commit only) → handler.** | Route | Existing auth | Also accepts CI session | |---|---|---| | `POST /content/v1/plans` | apikey | ✓ | | `POST /content/v1/plans/:plan_id/commit` | apikey | ✓ | | `POST /apply/v1/plans` | SIWX | ✓ (with deploy-spec restriction) | | `POST /apply/v1/plans/:plan_id/commit` | SIWX | ✓ (with plan guard) | | `GET /apply/v1/operations/:operation_id` | apikey | ✓ | | `GET /apply/v1/operations/:operation_id/events` | apikey | ✓ | | `POST /apply/v1/operations/:operation_id/resume` | SIWX | ✓ (with plan guard) | Routes outside this list reject CI session bearers with their normal auth response. ## Managed jobs Managed jobs are run402-owned async compute shapes for workloads that exceed request-time functions. Job types are run402-configured allowlist entries; no public kysigned FFLONK job type is currently registered. Callers submit input and a hard cost cap for a supported `job_type`; run402 owns image selection, instance type, AZ, retries, billing, logs, and artifact storage. Auth: `Authorization: Bearer `. Do not send public image names or resource knobs; unsupported fields are rejected. | Path | Method | Auth | Description | |------|--------|------|-------------| | `/jobs/v1/runs` | POST | service_key + lifecycleGate | Submit a job. Requires `Idempotency-Key`. Accepts an optional `callback_url` (see below). Returns `202` for a new job, `200` for an idempotency hit, `409` when the same key is reused with a different body, `429` for project concurrency/queue quota, and `503 Retry-After` when the platform queue is full. | | `/jobs/v1/runs` | DELETE | service_key + lifecycleGate | Purge all project-scoped managed-job run records. Queued/running jobs are included in the purge; any known EC2 runner instances are terminated before records are deleted. Returns `{deleted_jobs, cancelled_active_jobs, terminated_instances}`. | | `/jobs/v1/runs/:job_id` | GET | service_key | Poll project-scoped job status/result. | | `/jobs/v1/runs/:job_id` | DELETE | service_key + lifecycleGate | Cancel queued/running jobs. Terminal jobs are returned unchanged. Running cancellation terminates the runner instance when known. | | `/jobs/v1/runs/:job_id/logs` | GET | service_key | CloudWatch-backed runner logs. Query: `tail` (default 100, max 1000), `since` (inclusive epoch ms). Returns chronological `{ logs: [...] }`. | | `/jobs/v1/runs/:job_id/artifacts/:filename` | GET | service_key | Download a completed job's recorded artifact. Streams the raw bytes with the artifact's content-type — same Bearer auth as the rest of `/jobs/v1`. `404` if the job isn't completed or the filename wasn't recorded. | Submit body: ```json { "job_type": "example.managed_job.v1", "input": { "input_json": { "envelopeId": "env_123" } }, "max_cost_usd_micros": 50000, "callback_url": "https://hooks.example.com/run402/jobs" } ``` `callback_url` (optional) is an absolute `https://` URL (max 2048 chars; `http://` and malformed URLs are rejected with `400 invalid_job_request`, code `INVALID_CALLBACK_URL`). When set, run402 pushes one durable webhook to it as soon as the job reaches a terminal state (`completed` / `failed` / `cancelled`), so you do not need to poll. Delivery is at-least-once (bounded retries with exponential backoff, then a dead-letter state) and **unsigned**. The body is the canonical webhook envelope: ```json { "id": "job_abc123:terminal", "type": "job_completed", "created_at": "2026-05-17T10:09:38.000Z", "schema_version": "1", "idempotency_key": "job_abc123:terminal", "payload": { "job_id": "job_abc123", "status": "completed", "artifacts": { "result.json": { "url": "https://api.run402.com/jobs/v1/runs/job_abc123/artifacts/result.json", "content_type": "application/json", "sha256": "9b21fa…", "size_bytes": 1234 } } } } ``` `type` is one of `job_completed` / `job_failed` / `job_cancelled`; `payload.artifacts` is present on success and `payload.error` (`{ code, message }`) on failure. The `Run402-Webhook-Id` header equals the `idempotency_key` (`:terminal`). Because delivery is at-least-once, **dedupe on that header and re-fetch authoritative state via `GET /jobs/v1/runs/:job_id` before acting** — the callback is a trigger, not the source of truth. `callback_url` is submit-only and is never returned by the GET endpoint. Status response shape is intentionally compact: ```json { "job_id": "job_abc123", "job_type": "example.managed_job.v1", "status": "running", "created_at": "2026-05-17T10:00:00.000Z", "started_at": "2026-05-17T10:01:12.000Z" } ``` Terminal success includes artifact references and derivation metadata: ```json { "job_id": "job_abc123", "job_type": "example.managed_job.v1", "status": "completed", "created_at": "2026-05-17T10:00:00.000Z", "started_at": "2026-05-17T10:01:12.000Z", "completed_at": "2026-05-17T10:09:38.000Z", "artifacts": { "result.json": { "url": "https://api.run402.com/jobs/v1/runs/job_abc123/artifacts/result.json", "content_type": "application/json", "sha256": "9b21fa…", "size_bytes": 1234 }, "worker.log": { "url": "https://api.run402.com/jobs/v1/runs/job_abc123/artifacts/worker.log", "content_type": "text/plain", "sha256": "c1d4e9…", "size_bytes": 88210 } }, "metadata": { "wall_seconds": 506, "cost_usd_micros": 40600, "raw_cost_usd_micros": 40600, "absorbed_overage_usd_micros": 0, "image_digest": "sha256:abc123...", "spot_rate_usd_hr_micros": 288800, "instance_type": "r5.4xlarge", "az": "us-east-1d", "peak_rss_gb": 61.1, "interrupt_count": 0, "attempt_count": 1, "billing_status": "charged" } } ``` Each artifact entry is `{ url, content_type, sha256?, size_bytes? }`. Download the bytes with `GET ` (equivalently `GET /jobs/v1/runs/:job_id/artifacts/:filename`) using the same `Authorization: Bearer ` as the rest of `/jobs/v1` — it streams the raw artifact, not JSON, with `Cache-Control: private, no-store`. `sha256`/`size_bytes` let you verify integrity before use and are omitted for jobs created before per-artifact capture (the `url` still serves). Statuses: `queued`, `running`, `completed`, `failed`, `cancelled`. Spot interruptions are retried internally; callers observe a later `running` attempt unless retry/fallback would exceed `max_cost_usd_micros`, in which case the job fails with `MAX_COST_EXCEEDED`. Other stable failure codes include `SPOT_INTERRUPTED`, `JOB_TIMEOUT`, `RUNTIME_ERROR`, `RUNNER_LAUNCH_FAILED`, `INPUT_UNAVAILABLE`, and `IMAGE_UNAVAILABLE`. Billing: successful jobs debit the project organization once with ledger kind `managed_job`, idempotency key `managed_job:`, reference type/id `managed_job`/``, and per-attempt derivation metadata. The charge is capped at `max_cost_usd_micros`; any estimation overage above the cap is absorbed by run402 and reported as `absorbed_overage_usd_micros`. ## File storage (content-addressed) **Writes go through `apply`, not a separate upload endpoint.** The `ApplySpec.assets` slice (or the SDK's `r.project(id).assets.*` namespace) is the canonical write path; bytes flow through CAS the same way site files and function source do. Reads, signed URLs, listings, and CDN diagnose stay on `/storage/v1/*` below. ### Writing assets (the apply path) Put the keys in your `ApplySpec`: ```jsonc { "project_id": "prj_...", "assets": { "put": { "logo.svg": { "sha256": "", "size": 1024, "content_type": "image/svg+xml" }, "docs/spec.pdf": { "sha256": "", "size": 92341, "content_type": "application/pdf", "visibility": "private" } }, "delete": ["old-asset.png"], "sync": { "prefix": "static/", "prune": true, "delete_set_digest": "", "base_revision": "" } } } ``` Submit `POST /apply/v1/plans` to get presigned PUT URLs for any missing SHAs (via the same `missing_content[]` and `/content/v1/plans` flow the deploy slices use), upload the bytes, then `POST /apply/v1/plans/:plan_id/commit`. Asset promotion happens in the same activation transaction that flips the release pointer — assets and the site/HTML that references them flip live at the same instant. SDK equivalents (preferred — typed, handles plan/commit/retry for you): - `r.project(id).apply({ assets: {...} })` — explicit slice, mixes freely with `database`/`site`/`functions`/etc. - `r.project(id).assets.put(key, source, opts?)` — single asset; sugar over a 1-item apply. - `r.project(id).assets.putMany(items, opts?)` — isomorphic in-memory batch. - `r.project(id).assets.uploadDir(dir, opts?)` — Node directory walk, additive. - `r.project(id).assets.syncDir(dir, { prune: true, confirm })` — Node directory walk with explicit prune (drift-protected via `base_revision`; HTTP 409 `ASSET_SYNC_DRIFT` if inventory changed between plan and commit). - `r.project(id).assets.prepareDir(dir, opts?)` — returns `{ manifest, applySlice }` with deterministic CDN URLs + SRI hashes pre-commit; lets you render HTML against final URLs and commit HTML + assets atomically. `visibility: "public"` (default) and `immutable: true` (default) together yield a content-addressed `cdn_url` with `Cache-Control: public, max-age=31536000, immutable` and an `sri` value for `