# Run402 CLI -- Postgres & Static Hosting for AI Agents > CLI: npm install -g run402 > SDK: npm install @run402/sdk (typed TS client for custom code — same namespaces as the CLI, works in Node/Deno/Bun/V8 isolates) > Docs URL: https://docs.run402.com/llms-cli.txt > API Docs: https://run402.com/llms.txt > Operator: Kychee, Inc. > Terms: https://run402.com/humans/terms.html > Contact: run402 message send "your message" (requires active tier) ## TL;DR Run402 is a Postgres + REST + Auth + Storage + static site hosting + Node 22 serverless functions + email + image generation platform, exposed through one CLI. Three things make it different from stitching together a DB host, a static-site host, and a functions runtime: 1. One atomic apply across the whole stack. `run402 deploy apply --manifest app.json` ships DB migrations, site files, function code, secrets, and the route table as a single transaction. Partial failures roll back as a unit — there is no "frontend deployed, DB migration failed, now what" state to clean up. 2. No platform access token to mint or rotate. The credential is a local allowance file (`~/.config/run402/allowance.json`) the agent owns and signs with — no Run402 dashboard token, no service-account JSON, no expiring CLI key. Per-project `anon_key` / `service_key` exist for the runtime data plane (PostgREST, Storage, Functions) but never expire either; they're embedded in client code, not rotated. 3. The agent pays for its own usage. Provisioning, deploys, and metered calls authorize via x402 USDC on Base or MPP pathUSD on Tempo, signed by the allowance — no human-in-the-loop billing handoff. A human can top up the allowance with Stripe credits; the agent doesn't notice the funding source. **Install + deploy in 5 commands:** ```bash npm install -g run402 run402 init run402 tier set prototype run402 projects provision --name "my-app" # → returns anon_key, service_key, project_id run402 deploy apply --manifest app.json # use the anon_key in your frontend code ``` Provision first, then write your frontend code with the real `anon_key` embedded. The key has no expiry — it's a permanent project identifier. The `prototype` tier is free with the testnet faucet (it verifies your allowance and x402 signing work end-to-end without spending real money); switch to `hobby` / `team` in Step 3 when you're ready for mainnet. ## Core facts - Allowance: `~/.config/run402/allowance.json` (0600). Projects: `~/.config/run402/projects.json` (0600) - Credentials saved automatically after provision or fork - `` in commands = `project_id` from `run402 projects list` - All output is JSON on stdout; errors are JSON on stderr; exit 0 = success, non-zero = error. See Output Contract below. - The CLI handles x402 signing automatically — do not request private keys or set up payment libraries manually - GitHub Actions deploys use OIDC: link once with `run402 ci link github`, then the generated workflow calls the existing `run402 deploy apply` command with `permissions: id-token: write` - Projects, sites, subdomains, forks, functions, secrets, blob storage: free with active tier. Only image generation ($0.03) is per-call - Env overrides: `RUN402_API_BASE` (default `https://api.run402.com`), `RUN402_CONFIG_DIR` (base credential dir, default `~/.config/run402`), `RUN402_WALLET` (active named wallet/profile, default `default`; alias `RUN402_PROFILE`), `RUN402_ALLOWANCE_PATH` (custom allowance file path, default `{config_dir}/allowance.json`) - Multiple wallets on one machine: `run402 wallets` manages named wallets (profiles). Select per-command with `--wallet ` (alias `--profile`), `RUN402_WALLET`, or a per-directory `.run402.json` binding (walk-up, commit-safe — holds only a name). Precedence: `--wallet` > `RUN402_WALLET` > `.run402.json`/`.run402.local.json` > `wallets use` default > `default`. A `RUN402_WALLET` that conflicts with a `.run402.json` binding is a hard error unless `--wallet` is passed. The `default` wallet stays at the config-dir root (zero migration); named wallets live under `{base}/profiles//`. The active wallet name is echoed on stderr for non-default selections and appears in `run402 status` / `run402 wallets current`. ## Output Contract Every CLI subcommand follows the same stdout/stderr/exit-code contract. Read this once; it applies uniformly to every command in the rest of this file. Stdout (success) — the natural JSON payload, never wrapped. No top-level `status` field. The payload's shape depends on the command: - Reads / lists / info — emit the resource directly (`projects info` returns `{ project_id, anon_key, ... }`; `projects list` returns `{ projects: [...], scope?, has_more?, next_cursor? }`). - Mutations with no natural payload — echo the affected resource identifiers plus an explicit boolean state field naming what happened, e.g. `{ key, project_id, set: true }`, `{ name, project_id, deleted: true }`, `{ domain, project_id, released: true }`. Never an empty object. - Local-state inspection — `run402 status` and `run402 allowance status` use typed nullable payload fields: `{ wallet: null, hint: "Run: run402 init" }` when absent, and the populated account/wallet payload when present. Absence exits 0 — it is an informational read, not an error. - Plain text — a small set of commands (e.g. `run402 allowance export` returning a wallet address) emit a plain-text value with a trailing newline; their help text documents this. Stderr (failure) — a structured JSON error envelope with `status: "error"` and non-zero exit. The `status` field is the sentinel that says "this is an error" — it is preserved on stderr only and never appears on stdout. The envelope's other canonical fields are described under Error JSON and Safe Retry below. Exit code — `0` means the subcommand completed; non-zero means it failed and the structured envelope is on stderr. Validation commands that find issues (e.g. `validate-expose` reporting `hasErrors: true`) still exit 0 — the command itself succeeded; agents read `hasErrors` from the payload to decide what to do. Per-payload `status` fields are not envelopes. `run402 doctor` reports per-check items inside a `checks[]` array, each of which has its own `status: "ok" | "warning" | "missing" | "skipped"` label. Those are payload contents, not the top-level envelope, and they are NOT the same as the (forbidden) top-level `status` wrapper. This contract is enforced by a drift-protection test (`cli-output-contract.test.mjs`) that fails CI on any new top-level `JSON.stringify({ status: ... })` emission outside `cli/lib/sdk-errors.mjs`. If you find a command violating this contract, file an issue — it is a regression, not a feature. **Breaking change in v3.0:** Earlier `run402` CLI versions wrapped many success payloads as `{ status: "ok", ...payload }`. v3.0 dropped the wrapper across every subcommand. Consumers should drop any `.status === "ok"` checks and gate on exit code instead. The stderr error envelope is unchanged. ## Error JSON and Safe Retry CLI errors are JSON on stderr with an outer `"status": "error"` sentinel. Run402 JSON bodies may be merged into that object with canonical fields. Branch on `code`, not English `message` or legacy `error` text. Canonical fields: - `code`: stable machine-readable reason, e.g. `PROJECT_FROZEN`, `PAYMENT_REQUIRED`, `MIGRATION_FAILED`, `MIGRATE_GATE_ACTIVE`. Client-side validation failures (missing flag, malformed JSON, unknown local project) default to `BAD_USAGE`; specific client-side cases use richer codes e.g. `UNKNOWN_FLAG`, `BAD_FLAG`, `PROJECT_NOT_FOUND` (with `details.source: "local_registry"`), `NO_DEPLOYMENT`, `NO_ALLOWANCE`, `BAD_JSON_FLAG`, `CONFIRMATION_REQUIRED`. - `retryable`: the same request may succeed later - `safe_to_retry`: repeating the same request should not duplicate or corrupt a mutation - `mutation_state`: one of `none`, `not_started`, `committed`, `rolled_back`, `partial`, `unknown` - `trace_id`: include this when reporting the issue - `request_id`: routed/function failure handle; use `run402 functions logs --request-id ` for function diagnostics. This is distinct from gateway `trace_id`. - `details`: structured route-specific context - `next_actions`: advisory suggestions e.g. `authenticate`, `submit_payment`, `renew_tier`, `check_usage`, `retry`, `resume_deploy`, `edit_request`, `edit_migration`, or `deploy_site_first`; do not execute route-like suggestions without validating method/path/auth/safety Retry policy: - If `retryable: true` and `safe_to_retry: true`, retry the same request; for mutating commands reuse the same idempotency key when available. - `safe_to_retry: true` alone is not a retry signal; it means duplicate-safe, not likely-to-succeed. Lifecycle-gated writes, auth token exchanges, and passkey verifies need the indicated action before retrying. - `run402 deploy apply` already handles safe `BASE_RELEASE_CONFLICT` release races for omitted/current-base deploy specs: it re-plans, emits `deploy.retry` events on stderr, and stops after its bounded SDK retry budget. Exhausted deploy retries include `attempts`, `max_retries`, and `last_retry_code` in the error envelope. Do not hand-roll this specific retry loop around the CLI unless you intentionally disabled SDK retries upstream. - If a mutating command returns 5xx with `safe_to_retry: false`, or `mutation_state` is `committed`, `partial`, or `unknown`, inspect/poll/reconcile state before retrying. For deploys, prefer `run402 deploy events ` or `run402 deploy resume ` over starting a duplicate operation. - Lifecycle/payment errors usually need an action: `PROJECT_FROZEN`/`PROJECT_DORMANT`/`PROJECT_PAST_DUE` -> `run402 projects usage ` or `run402 tier set `; `PAYMENT_REQUIRED`/`INSUFFICIENT_FUNDS` -> submit payment or fund allowance. - `NOT_AUTHORIZED` (HTTP 403) is an org-owned-control-plane authorization denial (v1.77+), distinct from auth or payment: the wallet *authenticated*, but its resolved principal lacks the org role or per-project grant the action needs. `details` carries `required_role` / `required_capability` / `reason`. Not retryable without obtaining a covering org membership/role or grant; high-stakes ops (delete, transfer-of-ownership, membership change) require an active `owner` membership. The gateway returns 403 even when the project does not exist (so existence isn't leaked) — re-check the `` too. The CLI envelope adds an actionable `hint`. - `STEP_UP_REQUIRED` (HTTP 403) is a freshness/provenance demand for a high-stakes control-plane op: the session is valid but not fresh enough, or was minted by a read/device-flow path that can't satisfy a passkey step-up. `details` carries `required_amr` / `max_age_seconds` / `challenge_url` / `reason`, plus `next_actions[]`. The SDK raises a typed `StepUpRequiredError` (`isStepUpRequired()` guard). Resolve with `run402 operator login --step-up` on the same client, then retry. Distinct from `NOT_AUTHORIZED` (a role/grant gap, not a freshness gap). - Client-side `BAD_JSON_FLAG` errors include `details.flag` (the offending flag, e.g. `--abi`) and `details.value_preview` (truncated value) so callers know which flag value to fix. - CLI commands reject unknown flags and missing flag values locally with `UNKNOWN_FLAG` or `BAD_FLAG` before network work. Numeric and wei-like flags are strict decimal integers: malformed, fractional, negative, and scientific-notation values fail locally instead of being forwarded to the API. - Commands with fixed positional shapes also reject extra positional arguments locally. This includes deploy resume/list/events/release subcommands, functions list/delete, and blob get/rm/sign/diagnose. Examples: ```json { "status": "error", "http": 403, "message": "Project is frozen.", "code": "PROJECT_FROZEN", "category": "lifecycle", "retryable": false, "safe_to_retry": true, "mutation_state": "none", "next_actions": [{ "action": "renew_tier" }, { "action": "check_usage" }] } ``` ```json { "status": "error", "http": 402, "message": "Payment required.", "code": "PAYMENT_REQUIRED", "category": "payment", "retryable": true, "safe_to_retry": true, "next_actions": [{ "action": "submit_payment" }] } ``` ```json { "status": "error", "message": "Migration failed.", "code": "MIGRATION_FAILED", "category": "deploy", "retryable": false, "safe_to_retry": true, "mutation_state": "rolled_back", "trace_id": "trc_...", "details": { "operation_id": "op_...", "phase": "migrate" }, "next_actions": [{ "action": "edit_migration" }] } ``` --- ## Step 1: Install Requires Node.js >= 22. ```bash npm install -g run402 ``` ## Step 2: Set Up Allowance and Funding The fastest way -- does everything in one command: ```bash run402 init ``` This will: 1. Create a local agent allowance (if none exists) 2. Check your USDC balance 3. Request free testnet USDC from the faucet (if balance is zero) 4. Show your tier status and projects Or do it step by step: ```bash run402 allowance create # Generate a new allowance run402 allowance fund # Get free testnet USDC (Base Sepolia) run402 allowance balance # Check USDC balance (mainnet + testnet + billing) ``` The allowance is stored locally at `~/.config/run402/allowance.json` with 0600 permissions. The CLI handles all x402 payment signing automatically -- you never need to touch private keys or payment libraries. ## Step 3: Subscribe to a Tier ```bash run402 tier set prototype # $0.10/7d, free with testnet faucet (verifies allowance setup, no real money) run402 tier set hobby # $5 for 30 days (real money) run402 tier set team # $20 for 30 days (real money) ``` Tier is per billing account, not per project. One subscribe / renew / upgrade applies immediately to every project on the account, and `api_calls` / `storage_bytes` quotas are enforced against the pooled total across every project on the account. Multi-wallet accounts (via `run402 billing link-wallet`) share that same pool. Quota-denial error envelopes include `details.scope: "account" | "project"` -- `"account"` for the pooled path, `"project"` for the orphan fallback (project whose billing account row was purged but cascade has not yet run). `run402 tier set` refetches `/tiers/v1/status` after the call and includes the refreshed account-pool snapshot as `status_after` in the JSON output so you can see the new pooled `api_calls` / `storage_bytes` totals in one step. The server auto-detects the action based on your allowance state: - No tier or expired -> subscribe - Same tier, active -> renew (extends from current expiry) - Higher tier -> upgrade (prorated refund to billing allowance) - Lower tier, active -> downgrade (prorated refund if usage fits) Check your tier status: ```bash run402 tier status ``` `tier status` `pool_usage` sums `api_calls` and `storage_bytes` across every project on the billing account (across every linked wallet), not the requesting wallet's projects. After subscribing, you can create unlimited projects, deploy unlimited sites, and fork apps -- all free with your allowance signature, subject to the account-pooled api_calls and storage_bytes caps for your tier. Only image generation ($0.03/image) has a per-call cost. --- ## Deploying Apps ### Unified Deploy (v1.34 → v2.0 unified apply, recommended) The canonical deploy primitive. All bytes ride through CAS (no inline-body cap), supports both `replace` and `patch` semantics per resource, atomic multi-resource activation, and resumable recovery from partial failures. This is the path the SDK exposes as `r.project(id).apply(...)`. ⚠️ You still need the `anon_key` BEFORE writing your manifest -- provision first, then embed the real key in your HTML. ```bash run402 projects provision --name "my-app" # → copy anon_key from output into your HTML ``` Manifest format mirrors a v2 `ReleaseSpec`. For editor autocomplete, use top-level `"$schema": "https://run402.com/schemas/release-spec.v1.json"`; the CLI accepts that metadata and strips it before planning. ```json { "$schema": "https://run402.com/schemas/release-spec.v1.json", "project_id": "prj_1741340000_42", "database": { "migrations": [ { "id": "001_init", "sql": "CREATE TABLE IF NOT EXISTS items (id serial PRIMARY KEY, title text NOT NULL); INSERT INTO items (title) VALUES ('Buy groceries');" } ], "expose": { "version": "1", "tables": [ { "name": "items", "expose": true, "policy": "public_read_authenticated_write" } ] } }, "secrets": { "require": ["OPENAI_API_KEY"], "delete": ["OLD_KEY"] }, "functions": { "replace": { "api": { "runtime": "node22", "source": { "data": "export default async (req) => new Response('ok')" }, "config": { "timeoutSeconds": 30, "memoryMb": 256 }, "schedule": "*/15 * * * *" } } }, "site": { "replace": { "index.html": { "data": "..." }, "assets/logo.png": { "data": "iVBORw0KGgo...", "encoding": "base64" } } }, "subdomains": { "set": ["my-app"] }, "routes": { "replace": [ { "pattern": "/api/*", "methods": ["GET", "POST"], "target": { "type": "function", "name": "api" } } ] }, "i18n": { "defaultLocale": "en", "locales": ["en", "es", "fr"], "detect": ["cookie:wl_locale", "accept-language"] } } ``` File entries accept a bare UTF-8 string, `{ "data": "...", "encoding": "utf-8" | "base64", "contentType": "..." }`, or `{ "path": "dist/index.html", "contentType": "text/html" }`. In `--manifest` files, relative paths resolve against the manifest file's directory; in `--spec` / stdin they resolve against the current working directory. Migration entries may use `"sql_path"` or `"sql_file"` instead of inline `"sql"`. The CLI and MCP adapters share the SDK's typed `normalizeDeployManifest` path, so the same JSON can become SDK-native `ReleaseSpec` code without reimplementing base64 or path handling. The adapter is strict: only top-level `$schema` metadata is ignored; unknown fields and no-op specs are rejected before deploy planning, so typos like `"subdomain"`, `"site.replcae"`, `"functions.replace.api.deps"`, or `"functions.replace.api.config.schedule"` do not become partial deploys. Function specs in unified deploy accept `runtime` (`"node22"`), exactly one code source (`source` for a single module, or `files` plus `entrypoint` for multi-file functions), `config.timeoutSeconds`, `config.memoryMb`, and `schedule` as a sibling of `config`. In patch mode, omit `schedule` to leave it unchanged and use `"schedule": null` to remove an existing schedule. Unified deploy manifests do not accept `deps` yet; bundle dependencies into the function files or use `run402 functions deploy --deps` for the standalone function path. Deploy preflights obvious tier function caps after manifest normalization but before CAS upload or `/apply/v1/plans`: timeout, memory, cron interval, and scheduled-function count. Structured local failures use `code: "BAD_FIELD"` with `details.field`, `details.value`, `details.tier`, the relevant limit e.g. `tier_max` or `min_interval_minutes`, and `details.limit_source` (`tier_status` or `local_static_fallback`). Current authoring caps: prototype 10s / 128 MB / 1 scheduled fn / 15 min, hobby 30s / 256 MB / 3 / 5 min, team 60s / 512 MB / 10 / 1 min. `run402 tier status` shows live function caps and current scheduled usage when the gateway returns them. Subdomain specs use one mode per deploy: `"set"` replaces the release's managed subdomain list, `"add"` appends without removing existing entries, and `"remove"` deletes named entries. Current gateway support allows at most one subdomain in `subdomains.set` for a project; multi-subdomain `set` fails locally with `SUBDOMAIN_MULTI_NOT_SUPPORTED`. Complete static site + function + route manifest: ```json { "project_id": "prj_...", "site": { "replace": { "index.html": { "data": "
" }, "events.html": { "data": "

Events

" } }, "public_paths": { "mode": "explicit", "replace": { "/events": { "asset": "events.html", "cache_class": "html" } } } }, "functions": { "replace": { "api": { "runtime": "node22", "source": { "data": "export default async function handler(req) { const url = new URL(req.url); return Response.json({ ok: true, path: url.pathname }); }" } }, "login": { "runtime": "node22", "source": { "data": "export default async function handler(req) { return Response.json({ ok: true }); }" } } } }, "routes": { "replace": [ { "pattern": "/api/*", "methods": ["GET", "POST", "OPTIONS"], "target": { "type": "function", "name": "api" } }, { "pattern": "/login", "methods": ["POST"], "target": { "type": "function", "name": "login" } } ] } } ``` Static public paths: - Release static asset paths and public browser paths are distinct. In the example, `events.html` is the release asset and `/events` is the browser-visible static URL declared through `site.public_paths`. - `site.public_paths.mode: "explicit"` means only the complete `public_paths.replace` table is directly reachable as static URLs. `/events.html` is not public unless separately declared. - `site.public_paths.mode: "implicit"` restores filename-derived public reachability and can widen access. Review gateway warnings before confirming a deploy that switches to implicit mode. - Known `cache_class` inputs are `"html"`, `"immutable_versioned"`, and `"revalidating_asset"`. Preserve unknown future cache-class strings returned by release inventory, diff, or diagnostics instead of branching as if the list is closed. - A site spec containing only `public_paths` is meaningful deploy content: `{ "site": { "public_paths": { "mode": "explicit", "replace": {} } } }` removes direct public static URLs without changing release assets. Route semantics: - Omit `routes` or pass `routes: null` to carry forward base routes. `routes: { "replace": [] }` clears dynamic routes. `routes: { "replace": [...] }` replaces the route table atomically with the release. - Route entries use `pattern`, optional non-empty `methods` (`GET`, `HEAD`, `POST`, `PUT`, `PATCH`, `DELETE`, `OPTIONS`), and a target. Function targets are `{ "type": "function", "name": "" }`. Prefer `site.public_paths` for ordinary clean static URLs e.g. `/events -> events.html`. Static route targets are exact, method-aware route-table aliases e.g. `{ "pattern": "/events", "methods": ["GET", "HEAD"], "target": { "type": "static", "file": "events.html" } }`; the `file` is a release static asset path, not a public path, URL, CAS hash, rewrite, or redirect. Static targets require exact patterns only and methods `["GET"]` or `["GET","HEAD"]`; the file path is relative to deployed site content with no leading slash, wildcard, directory shorthand, query, or fragment. Path-keyed maps like `"routes": { "/api/*": { "function": "api" } }` are invalid. - Exact patterns look like `/admin`; prefix wildcard patterns use final `/*`, like `/admin/*`. `/admin/*` does not match `/admin`, `/admin/`, `/admin.css`, or `/administrator`, so deploy both `/admin` and `/admin/*` for a dynamic section root. - Query strings are ignored for matching and preserved in the handler's full public `req.url`. Exact beats prefix, longest prefix wins, and method-compatible dynamic routes beat static assets. - A `POST /login` route can coexist with static `GET /login` HTML. Unsafe method mismatch returns `405`, not SPA HTML. Matched dynamic route failures fail closed and do not fall back to static files. - Routed browser ingress uses Node 22 Fetch Request -> Response: `export default async function handler(req) { ... }`. `req.url` is the full public URL on managed subdomains, deployment hosts, and verified custom domains, so derive OAuth callbacks from `new URL(req.url).origin`. The raw `run402.routed_http.v1` envelope is internal; do not write route handlers against it. Direct `/functions/v1/:name` remains API-key protected. The function owns app auth, CSRF, CORS/`OPTIONS`, cookies, redirects, and spoofed forwarding-header hygiene. - Anti-patterns: routing every static file, broad method lists by default, wildcard static route targets, leading-slash static files, directory shorthand, one-static-route-target-per-page route-table exhaustion, wildcard function routes shadowing direct public static paths, and confusing omitted/null `routes` with `routes: { "replace": [] }`. Apply it: ```bash run402 deploy apply --manifest app.json ``` Stdout returns `{ status: "ok", release_id, operation_id, urls, ... }`. Stderr streams structured progress events as JSON-line (one event per line). Pass `--quiet` or `--final-only` to silence stderr while preserving the final stdout JSON envelope. Patch semantics — only the listed file changes: ```json { "project_id": "prj_...", "site": { "patch": { "put": { "index.html": { "data": "

v2

" } } } } } ``` Or via `--spec` for a one-line CLI invocation: ```bash run402 deploy apply --spec '{"project_id":"prj_...","site":{"patch":{"delete":["old.html"]}}}' ``` Astro builds: pass `--dir ` to read `dist/run402/adapter.json` and merge the multi-slice ReleaseSpec (site + functions + routes) from the build product. Combine with `--manifest` to declare cross-cutting slices (database, secrets, subdomains, i18n) in the manifest while the slice carries the build output: ```bash # Astro-only: --dir is the whole spec source (requires @run402/astro installed) run402 deploy apply --dir ./dist --project prj_... # Astro + cross-cutting slices: --dir owns site/functions/routes, --manifest owns the rest run402 deploy apply --dir ./dist --manifest run402.config.json --project prj_... ``` The CLI dynamically imports `@run402/astro/release-slice` (must be installed in the consuming project, **`@run402/astro >=1.2.1` + `@run402/sdk >=2.18.0`** — older SDKs reject `FunctionSpec.class: 'ssr'` locally in `validateKnownFields` before the spec ever reaches the gateway; the helper preflights this and emits `R402_ASTRO_SDK_VERSION_TOO_OLD` with a precise upgrade command). The helper bundles the SSR server output with esbuild into a single `source` (the gateway rejects multi-file function specs), roots the site at the resolved `build.client` dir (`dist/run402/client/`, NOT `dist/`), **omits `routes` entirely from the returned slice** (the gateway routes every unmatched path to the project's `class: 'ssr'` function automatically, so no route table is needed; omitting `routes` — rather than sending an empty `replace` that would CLEAR the table — carries forward any base-release routes such as a separately-declared `/api/*` function and keeps the slice safe to submit from a CI OIDC session without route scopes), defaults `site.public_paths: { mode: "implicit" }` to opt out of inherited explicit-mode paths from the base release, and colocates the `_assets-manifest.json` inside the resolved `build.client` dir (so the slice's site replace dir carries it). On a missing/incompatible manifest the error envelope carries `code: "R402_ASTRO_ADAPTER_MANIFEST_MISSING"` or `R402_ASTRO_ADAPTER_MANIFEST_VERSION_UNSUPPORTED` with `hint` + `docs` fields pointing at run402.com/errors. Direct-SDK callers get the same primitive via `import { buildAstroReleaseSlice } from "@run402/astro/release-slice"`. Do NOT hand-roll `site` / `public_paths`: if a deploy ships the adapter build tree (`run402/adapter.json`, `run402/server/**`) as site content — the symptom of rooting a file source at `dist/` instead of `dist/run402/client/` — the SDK rejects it locally with `ASTRO_ADAPTER_TREE_IN_SITE` before any upload, and the gateway warns `SITE_NO_REACHABLE_HTML` when a release ships HTML that isn't reachable at any public path (kychee-com/run402#411). Recovery from a stuck deploy: when an `apply` ends in `activation_pending` (rare; transient gateway failure between SQL commit and the pointer-swap activation), the gateway auto-resumes on the hourly tick. Static spec/config activation failures are classified promptly and thrown as structured deploy errors instead of polling until timeout. For genuinely resumable operations, call resume explicitly: ```bash run402 deploy resume ``` The gateway re-runs only the failed phase forward — SQL is never replayed. Recovery from a destructive apply (v1.58+): when an `apply` shipped content the operator regrets (accidental destructive prune, content overwrite, broken release), `run402 deploy promote ` re-points the project's live release at a prior release row WITHOUT re-running the apply pipeline. No bytes-upload, no bundling, no migration — just a pointer swap on `internal.projects.live_release_id` plus an ssr_cache flush. Designed for "oops on a real project ID" recovery in seconds rather than re-deploying. ```bash # rel_old (good) → rel_new (bad, destructive) → promote back run402 deploy promote rel_old_abc123 --project prj_xyz \ --allow-warning MIGRATIONS_NOT_REVERSIBLE ``` Surfaces structured warnings. `MIGRATIONS_NOT_REVERSIBLE` (requires_confirmation: true) fires when the target release predates migrations applied since — the migrations remain applied, so the post-promote release runs against the current schema. Ack with `--allow-warning MIGRATIONS_NOT_REVERSIBLE`. `FUNCTION_VERSION_MISMATCH` (informational) fires when overlapping function names have different code_hashes; the Lambda code is whatever's currently `$LATEST`. Rejected codes: `PROMOTE_TARGET_NOT_FOUND` (release id doesn't exist), `PROMOTE_PROJECT_MISMATCH` (release belongs to another project), `PROMOTE_RELEASE_NOT_READY` (release status not promotable: must be `ready`, `active`, or `superseded`), `PROMOTE_NO_OP` (target IS already the current live; use `cache.invalidateAll` to refresh cache instead), `PROMOTE_WARNING_REQUIRES_ACK` (at least one blocking warning unacked). Inspect deploy history: list recent operations for a project, or pull the recorded phase-event stream for a specific operation (after the fact — for live progress events during an in-flight deploy, the `apply` command already streams them on stderr): ```bash run402 deploy list --project prj_... --limit 10 run402 deploy events --project prj_... run402 deploy release active --project prj_... --site-limit 5000 run402 deploy release get rel_... --project prj_... run402 deploy release diff --from empty --to active --project prj_... --limit 1000 run402 deploy diagnose --project prj_123 https://example.com/events --method GET run402 deploy resolve --project prj_123 --url https://example.com/events?utm=x#hero --method GET run402 deploy resolve --project prj_123 --host example.com --path /events --method GET ``` `list` returns `{ operations: [...], cursor }`; the SDK and MCP `deploy_list` surface accept that cursor for pagination when it is non-null. `events` returns the same `DeployEvent` shapes that `apply` emits inline. `release active` and `release get` return `{ "release": ReleaseInventory }`. Inventories include release metadata, `state_kind` (`current_live`, `effective`, or `desired_manifest`), site paths (capped by `--site-limit`), `static_public_paths` when returned, functions, secret keys only, subdomains, materialized routes, applied migrations, `release_generation`, `static_manifest_sha256`, nullable `static_manifest_metadata`, the release's `i18n` slice (object with `defaultLocale` / `locales` / `detect`, or `null` when the release has no slice), and inventory warnings when returned. `site.paths` is the release static asset inventory; `static_public_paths[]` is the browser reachability inventory with `public_path`, `asset_path`, `reachability_authority`, `direct`, cache class, and content type. `static_manifest_metadata: null` means unavailable, not zero; when present it includes `file_count`, `total_bytes`, `cache_classes`, `cache_class_sources`, and `spa_fallback`. Use `i18n` as the positive verification readback after a deploy — `jq '.release.i18n'` confirms `spec.i18n` shipped, since `apply()` not throwing is necessary but not sufficient. Older gateways predating the inventory-i18n change may omit the field entirely; treat absent (not `null`) as "unknown on this gateway." `release diff` returns `{ "diff": ReleaseToReleaseDiff }`. `--from` accepts `empty`, `active`, or a release id. `--to` accepts `active` or a release id. Diff migrations use `migrations.applied_between_releases`; secrets and subdomains expose `added` / `removed` only; routes expose `added` / `removed` / `changed`; `static_assets` exposes unchanged/changed/added/removed files, `newly_uploaded_cas_bytes`, `reused_cas_bytes`, `deployment_copy_bytes_eliminated`, `legacy_immutable_warnings`, `previous_immutable_failures`, and `cas_authorization_failures`. `deploy diagnose` is the URL-first public diagnostic: `run402 deploy diagnose --project prj_123 https://example.com/events --method GET`. `deploy resolve` is lower-level parity with the SDK/endpoint: pass `--url` or pass `--host` with optional `--path`; do not combine `--url` with `--host`/`--path`. Both commands print a structured JSON envelope with `status`, `would_serve`, `diagnostic_status`, `match`, `summary`, normalized `request`, `warnings`, full `resolution`, and structured `next_steps`. Query strings/fragments in URL mode are ignored for route/static lookup and reported under `request.ignored` and warnings. When returned, `asset_path`, `reachability_authority`, and `direct` explain which release asset backs the public URL and whether reachability came from implicit file-path mode, explicit `site.public_paths`, or a route-only static alias. Diagnostic misses e.g. host misses exit 0 when the resolver call succeeds; branch on `would_serve: false`. Stable-host diagnostics may include `authorization_result`, `cas_object` (`sha256`, `exists`, `expected_size`, `actual_size`), hostname-specific `response_variant`, and route/static fields e.g. `allow`, `route_pattern`, `target_type`, `target_name`, and `target_file`. Known current resolve `match` literals are `host_missing`, `manifest_missing`, `active_release_missing`, `unsupported_manifest_version`, `path_error`, `none`, `static_exact`, `static_index`, `spa_fallback`, `spa_fallback_missing`, `route_function`, `route_static_alias`, and `route_method_miss`; preserve unknown future strings. Known `authorization_result` values include `authorized`, `not_public`, `not_applicable`, `manifest_missing`, `target_missing`, `active_release_missing`, `unsupported_manifest_version`, `path_error`, `missing_cas_object`, `unfinalized_or_deleting_cas_object`, `size_mismatch`, and `unauthorized_cas_object`. Known `fallback_state` values include `active_release_missing`, `unsupported_manifest_version`, and `negative_cache_hit`; preserve unknown future strings. `result` is diagnostic body status, not HTTP transport status. Do not treat resolve/diagnose as a fetch, cache purge, or cache-policy oracle; branch on structured JSON e.g. `cache_class`, `allow`, and `cas_object`, and preserve unknown cache classes. Route warning guidance: | Code | Meaning | Recover | |---|---|---| | `PUBLIC_ROUTED_FUNCTION` | Function becomes public same-origin browser ingress. | Review app auth, CSRF, CORS/`OPTIONS`, and cookies; direct `/functions/v1/:name` remains API-key protected. Prefer `--allow-warning PUBLIC_ROUTED_FUNCTION` after review; use `--allow-warnings` only after every warning was reviewed. | | `ROUTE_TARGET_CARRIED_FORWARD` | Carried-forward route still targets a base-release function. | Inspect `run402 deploy release active` and deploy a replacement route table if needed. | | `ROUTE_SHADOWS_STATIC_PATH` / `WILDCARD_ROUTE_SHADOWS_STATIC_PATHS` | Dynamic route shadows direct public static content. | Inspect warning details, active routes, `static_public_paths`, and resolve diagnostics; confirm only when intentional. | | `METHOD_SPECIFIC_ROUTE_ALLOWS_GET_STATIC_FALLBACK` | Unmatched methods can serve static content. | Confirm fallback is intended or add method coverage. | | `WILDCARD_ROUTE_EXCLUDES_MUTATION_METHODS` | Wildcard function route only allows `GET`/`HEAD`. | Add mutation methods e.g. `POST`, omit methods for an API prefix, or set `acknowledge_readonly: true` on an intentionally read-only GET/HEAD final-wildcard function route. `--allow-warning WILDCARD_ROUTE_EXCLUDES_MUTATION_METHODS` is a reviewed CLI escape hatch; broad `--allow-warnings` is last resort. | | `ROUTE_TABLE_NEAR_LIMIT` | Route table is near a limit. | Consolidate or remove routes. | | `ROUTES_NOT_ENABLED` | Routes are disabled for the project/environment. | Deploy without `routes` or request enablement; direct function invoke is not a browser-route substitute. | | `STATIC_ALIAS_SHADOWS_STATIC_PATH` / `STATIC_ALIAS_RELATIVE_ASSET_RISK` | Route-only static alias conflicts with a direct public static path or has relative-asset risk. | Inspect active routes, `static_public_paths`, and the backing `asset_path`; prefer `site.public_paths` for ordinary clean URLs and confirm only when intentional. | | `STATIC_ALIAS_DUPLICATE_CANONICAL_URL` / `STATIC_ALIAS_EXTENSIONLESS_NON_HTML` | Route-only static alias may duplicate another direct public path or expose extensionless non-HTML. | Use one canonical public path per page and reserve exact static route targets for method-aware aliases. | | `STATIC_ALIAS_TABLE_NEAR_LIMIT` | Static route targets are near route-table limits. | Avoid one-static-route-target-per-page tables; consolidate. | Runtime route failure codes to branch on: `ROUTE_MANIFEST_LOAD_FAILED` (manifest/propagation), `ROUTED_INVOKE_WORKER_SECRET_MISSING` (custom-domain Worker secret), `ROUTED_INVOKE_AUTH_FAILED` (internal invoke signature), `ROUTED_ROUTE_STALE` (selected route failed release revalidation), `ROUTE_METHOD_NOT_ALLOWED` (method mismatch), and `ROUTED_RESPONSE_TOO_LARGE` (body over 6 MiB). **Routed functions: locale awareness.** Declare supported locales with a `spec.i18n` release slice and the gateway negotiates a locale per routed-function request, surfacing the result to user code via `x-run402-locale` and `x-run402-default-locale` request headers (omitted entirely when the active release has no `i18n` slice — additive-compat for pre-i18n functions). The slice has its own carry-forward rules — simpler than `routes`, no `{ replace }` envelope: ```json { "i18n": { "defaultLocale": "en", "locales": ["en", "es", "fr", "zh-Hant"], "detect": ["cookie:wl_locale", "accept-language"] } } ``` - Omit `i18n` to carry forward from the base release; pass `"i18n": null` to clear the slice on the new release; pass `{ defaultLocale, locales, detect? }` to replace. - `defaultLocale` MUST be byte-identical to one entry in `locales[]`. The platform does NOT silently canonicalize; the CLI/SDK validate this client-side before planning. - Locale tags MUST match `/^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/` AND be in RFC 5646 canonical casing: primary subtag lowercase, script subtag Titlecase, 2-alpha region UPPERCASE, 3-digit (UN M.49) region preserved, variants/extensions lowercase. Examples: `pt-BR`, `zh-Hant`, `zh-Hant-TW`, `de-1996`. The gateway rejects non-canonical casing at deploy time with `code: "R402_LOCALE_NOT_CANONICAL"` (HTTP 400) carrying `fix: { input, canonical }` so agents can auto-correct and retry. The platform does NOT silently canonicalize because translations are typically keyed on the literal locale string in your DB (e.g. `section_translations.language = 'pt-BR'`); auto-canonicalization would create a silent split between the spec and your column values. `locales[]` is non-empty, max 50 entries. - Negotiation returns canonical casing from `locales[]`, NOT the request's casing. - `detect[]` (default `["accept-language"]`, max 10, `[]` allowed and means "always default") is walked in order; first match wins. Sources: `"accept-language"` (RFC 9110 parsing, RFC 4647 §3.4 lookup-style truncation `zh-Hant-TW` → `zh-Hant` → `zh`; a generic request tag does NOT match a more specific `locales[]` entry — `Accept-Language: es` does NOT match `locales: ["es-MX"]`) and `"cookie:"` (RFC 6265 cookie-name grammar `/^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/`, raw value matched case-insensitively against `locales[]`). - Static-route hits do NOT receive locale negotiation; only routed HTTP function invocations do. - Run402 does NOT inject `Vary` headers — apps that return public-cacheable responses varying by locale must set their own `Vary` until per-locale edge caching ships. Routed-function read pattern (single-arg `(req)` signature, not `(req, ctx)` — the bundled `@run402/functions` runtime translates the routed envelope into a Web-standard `Request` before calling user code, so the envelope's `context.locale` is NOT visible to typical user functions): ```ts export default async (req) => { const locale = req.headers.get('x-run402-locale'); const defaultLocale = req.headers.get('x-run402-default-locale'); if (locale && locale !== defaultLocale) { return renderWithTranslations({ locale }); } return renderBase({ locale: defaultLocale ?? 'en' }); }; ``` Client-side gotcha — language switchers must write a cookie. Apps that persist locale to `localStorage` only (a common pattern from Astro/Next i18n tutorials) won't be seen by Run402's server-side negotiation. Mirror the locale to a cookie so the next request hits the right translations, then declare a cookie source in `spec.i18n.detect`: ```js function setLanguage(lang) { localStorage.setItem('wl_locale', lang); document.cookie = `wl_locale=${encodeURIComponent(lang)}; path=/; max-age=31536000; samesite=lax`; } ``` Then deploy with `"detect": ["cookie:wl_locale", "accept-language"]` so the server honors the user's choice on the next request. Migration registry: each migration is identified by `(id, checksum)`. Re-shipping the same `id` + same SQL is a registry noop; same `id` + different SQL is a hard error (`MIGRATION_CHECKSUM_MISMATCH`). Ship idempotent migrations (`CREATE TABLE IF NOT EXISTS`, `ADD COLUMN IF NOT EXISTS` in a `DO` block) and re-deploys are free. --- ### GitHub Actions OIDC Deploys Use this when the same repo should deploy itself from GitHub Actions without storing Run402 service keys, allowance files, or API keys in GitHub secrets. KISS rule: link once locally, then CI runs the same `run402 deploy apply` command agents already know. Local setup: ```bash run402 ci link github --project prj_... --manifest run402.deploy.json run402 ci link github --project prj_... --manifest run402.deploy.json --route-scope /admin --route-scope /api/* ``` Full link syntax: ```bash run402 ci link github \ [--project ] \ [--manifest ] \ [--repo ] \ [--branch | --environment ] \ [--repository-id ] \ [--workflow ] \ [--expires-at ] \ [--route-scope ...] \ [--force] ``` Defaults: - `--project`: active project - `--manifest`: `run402.deploy.json` - `--repo`: inferred from `git remote get-url origin` - `--branch`: current branch from `git branch --show-current` - `--workflow`: `.github/workflows/run402-deploy.yml` - `--route-scope`: omitted by default, which means no CI route-declaration authority; repeat for exact paths like `/admin` or final wildcard prefixes like `/api/*` - allowed events: fixed to `push` and `workflow_dispatch` - allowed action: fixed to `deploy` The command fetches GitHub's numeric repository id using `GITHUB_TOKEN` or `GH_TOKEN` when available. If lookup fails, pass `--repository-id ` explicitly. The subject is generated from `--branch` as `repo::ref:refs/heads/`, or from `--environment` as `repo::environment:`. Generated workflow shape: ```yaml name: Run402 Deploy on: push: branches: ["main"] workflow_dispatch: permissions: contents: read id-token: write jobs: deploy: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Deploy to run402 run: npx --yes run402@1.60.0 deploy apply --manifest 'run402.deploy.json' --project 'prj_...' < /dev/null ``` Output on success: ```json { "binding_id": "cib_...", "project_id": "prj_...", "provider": "github-actions", "subject_match": "repo:owner/name:ref:refs/heads/main", "allowed_events": ["push", "workflow_dispatch"], "allowed_actions": ["deploy"], "route_scopes": ["/admin", "/api/*"], "github_repository_id": "123456789", "github_repository_id_status": "verified", "workflow_path": ".github/workflows/run402-deploy.yml", "manifest_path": "run402.deploy.json", "run402_version": "1.60.0", "delegation_chain_id": "eip155:84532", "bootstrap_caveat": "Commit the generated workflow and manifest before expecting GitHub Actions deploys.", "consent_summary": ["..."], "revocation_residuals": ["..."] } ``` Management: ```bash run402 ci list [--project ] run402 ci revoke ``` `list` prints `{ "project_id": "...", "bindings": [...] }`. `revoke` prints `{ "binding": {...}, "revoked": true, "revocation_residuals": [...] }`. Intentional omissions in v1: no raw `--subject`, no wildcard flag, no `--allow-event`, no PR deploy flags, and no `--no-repository-id`. Use `--branch` or `--environment`; create a follow-up design before broadening trust. CI deploy restrictions: when `run402 deploy apply` runs inside GitHub Actions with OIDC env vars present, it uses the GitHub subject token, exchanges it for a Run402 CI session, and skips the local allowance preflight. CI manifests may include only `project_id`, `database`, `functions`, `site`, absent/current `base`, and route declarations covered by the binding's `route_scopes`. Without `--route-scope`, CI cannot ship `routes`. CI cannot ship `secrets`, `subdomains`, `checks`, unknown future top-level fields, non-current base, or oversized manifests that require `manifest_ref`. Common CI error codes: - `invalid_token`: check `permissions: id-token: write` and the workflow's OIDC environment - `access_denied`: no active binding matched this repo/branch/environment - `event_not_allowed`: v1 allows only `push` and `workflow_dispatch` - `repository_id_mismatch`: re-link from the current repo or pass the correct numeric `--repository-id` - `forbidden_spec_field` / `forbidden_plan`: remove disallowed CI manifest fields or run the deploy locally - `CI_ROUTE_SCOPE_DENIED`: re-link with covering `--route-scope` patterns e.g. `/admin` or `/api/*`, or run the route-changing deploy locally - `payment_required`: renew/upgrade/fund the project tier outside CI, then rerun the workflow --- ### Unified Deploy Use `run402 deploy apply --manifest app.json` for full-stack releases. The manifest is a v2 `ReleaseSpec` with explicit replace-vs-patch semantics per resource. Add top-level `"$schema": "https://run402.com/schemas/release-spec.v1.json"` for editor autocomplete; the CLI strips it before planning. ⚠️ You need the anon_key BEFORE writing your manifest. Run provision first, then write your HTML with the real key: ```bash run402 projects provision --name "my-app" ``` → copy anon_key from output into your HTML, then: Create a manifest file `app.json` (use the `project_id` from provision): ```json { "$schema": "https://run402.com/schemas/release-spec.v1.json", "project_id": "prj_1741340000_42", "database": { "migrations": [ { "id": "001_init", "sql": "CREATE TABLE items (id serial PRIMARY KEY, title text NOT NULL, done boolean DEFAULT false);" } ], "expose": { "$schema": "https://run402.com/schemas/manifest.v1.json", "version": "1", "tables": [ { "name": "items", "expose": true, "policy": "public_read_authenticated_write" } ] } }, "functions": { "replace": { "my-fn": { "runtime": "node22", "source": { "data": "export default async (req) => new Response('ok')" }, "config": { "timeoutSeconds": 30, "memoryMb": 256 }, "schedule": "*/15 * * * *" } } }, "site": { "replace": { "index.html": { "data": "..." }, "style.css": { "data": "body { margin: 0; }" }, "logo.png": { "data": "iVBORw0KGgo...", "encoding": "base64" } } }, "subdomains": { "set": ["my-app"] } } ``` `project_id` is required (from `run402 projects provision`). All other fields are optional; omitted top-level sections are carried forward from the current release. The adapter is strict: only top-level `$schema` metadata is ignored; typo fields e.g. `subdomain`, `site.replcae`, `functions.replace.my-fn.deps`, or `functions.replace.my-fn.config.schedule` fail before planning. Function specs accept `runtime: "node22"`, exactly one code source (`source`, or `files` plus `entrypoint`), `config.timeoutSeconds`, `config.memoryMb`, a sibling `schedule`, and the v1.51+ auth-gate fields `requireAuth` / `requireRole` (see "Function-level auth gates" below). Omit `schedule` in patch mode to leave it unchanged; set it to `null` to remove the schedule. Unified deploy manifests do not accept `deps`; bundle dependencies into the function files or use `run402 functions deploy --deps`. Function-level auth gates (v1.51+). Two optional declarative fields on each function spec: - `requireAuth: true` — gateway rejects callers without a valid project user JWT with `401`. No DB lookup. Independent from `requireRole`. - `requireRole: { table, idColumn, roleColumn, allowed[], cacheTtl? } | null` — gateway resolves the caller's role from the project-schema table (RLS-bypass) and rejects callers whose role is not in `allowed` with `403`. Implies authentication (no JWT → 401). Pass `null` in patch mode to remove an existing gate. `cacheTtl` is seconds; default 60, max 600, 0 disables caching (use for instant-revocation paths). When a gate passes, the gateway injects `x-run402-user-id` (always when any gate ran) and `x-run402-user-role` (only when `requireRole` ran) into the request. In-function code reads them with `getUserId(req)` / `getRole(req)` from `@run402/functions` 2.5+. Worked example — manifest fragment: ```json { "functions": { "patch": { "set": { "list-my-items": { "source": { "path": "functions/list.ts" }, "requireAuth": true }, "delete-content": { "source": { "path": "functions/delete.ts" }, "requireRole": { "table": "members", "idColumn": "user_id", "roleColumn": "role", "allowed": ["admin"], "cacheTtl": 60 } }, "moderate-content": { "source": { "path": "functions/moderate.ts" }, "requireRole": { "table": "members", "idColumn": "user_id", "roleColumn": "role", "allowed": ["admin", "moderator"] } } } } } } ``` Validation rules: - One role table per release. All `requireRole` blocks in a single release must share the same `(table, idColumn, roleColumn)` triple. Different `allowed` sets are fine; different tables are rejected at plan time with the canonical `INVALID_SPEC` envelope. - Unqualified identifiers only. Schema-qualified names (e.g. `"public.members"`) are rejected with `INVALID_SPEC`. The project schema is resolved server-side. - `cacheTtl` range. `0 ≤ cacheTtl ≤ 600`; out-of-range rejected with `INVALID_SPEC`. - Empty `allowed`. Rejected with `INVALID_SPEC` — set the role values explicitly. - Deploy-time validation. Missing table or column at activation fails with `DEPLOY_INVALID_ROLE_GATE` (HTTP 422) *before* flipping the live release. `run402 deploy apply` surfaces the structured envelope on stderr. Reading the role in-function — TWO approaches (`@run402/functions` 3.4.0+; `{ from }` since 3.5.0). The edge gate now authenticates BOTH `Authorization: Bearer` callers and cookie-session SSR browsers (capability `ssr-aware-role-gate`), so pick by your function TOPOLOGY, not by how the caller authenticates. 1. Dedicated function / route → the deploy-spec `requireRole` EDGE gate (above). It authenticates Bearer JWT AND the browser cookie session, enforces before dispatch, and caches the role lookup (TTL). In-function, `await auth.requireRole("operator")` returns `{ user, role }` (throwing a *distinct* `RoleGateNotConfiguredError` (500) if no gate is declared vs `InsufficientRoleError` (403) for a mismatch); for multi-role gates read `await auth.role()` and branch — `requireRole(x)` requires `x` in `allowed`. For a browser console, add `onDeny: "redirect"` + `signInPath` (a same-origin path) to the gate so an unauthenticated HTML request gets a `303` to sign-in (401-class only — an authenticated wrong-role 403 stays a JSON envelope, no redirect loop). The gate is PER-FUNCTION. 2. Catch-all SSR function, or finer per-path control → the in-function `{ from }` guard. Because the edge gate is per-function, a single catch-all function (one ssr function = `/admin/*` console AND the public 404 fallback) can't gate just `/admin/*` — an edge gate would also gate the public 404s and the public `/admin/login`. Use `{ from }`: it resolves the cookie user (`auth.user()`) and reads their role from your tenant table (RLS-bypass), scoped in-app to your admin pages, no gate required: ```ts const { user } = await auth.requireRole("operator", { from: { table: "staff", idColumn: "user_id", roleColumn: "role" } }); // or, for an .astro page (a throw in frontmatter renders a 500, not a redirect) use the non-throwing read: const role = await auth.role({ from: { table: "staff", idColumn: "user_id", roleColumn: "role" } }); if (role !== "operator") return Astro.redirect("/admin/login", 303); ``` Worked example — an Astro SSR hybrid where ONE ssr function serves `/admin/*` AND the public catch-all/404 fallback: use the in-function `{ from }` guard (an edge gate, being per-function, would also gate the public 404s + `/admin/login`). If your console is instead a DEDICATED function/route, prefer the edge gate — it's cookie-aware, supports `onDeny: "redirect"` to sign-in, and caches the role lookup. Scaffolding + first-operator bootstrap. `run402 auth scaffold-roles --roles operator` emits the conventional `app_roles(user_id uuid, role text)` migration, the matching `requireRole` snippet, and a service-role `INSERT` to grant the FIRST role — the table starts empty, so the first grant must bypass RLS with the service key. The gate keys on the tenant user id (`internal.users.id` / JWT `sub`), NOT a wallet. The conventional table is a default — `requireRole` accepts any `(table, idColumn, roleColumn)`. The gate applies to both routed (`/your/route`) and direct (`POST /functions/v1/:name` with API key plus user JWT) invocation. Direct invocation still requires the API key at the edge; the gate runs after API-key auth, against the user JWT. Binary files (images, fonts, PDFs): Set `"encoding": "base64"` and provide base64-encoded data. MIME types are auto-detected from the file extension (`.png` → `image/png`, `.woff2` → `font/woff2`, etc.). Text files use `"encoding": "utf-8"` (the default — can be omitted). Assets slice (v2.0+, bulk content-addressed storage in the apply transaction). `assets` is a top-level `ReleaseSpec` slice — the same atomic transaction promotes site bytes, function code, secrets, *and* asset entries. Two shapes: ```json "assets": { "put": [ { "key": "static/app.css", "sha256": "<64-hex>", "size_bytes": 1234, "content_type": "text/css", "visibility": "public", "immutable": true } ] } ``` Additive batch. Each entry carries the locally-computed `sha256` so the gateway can deduplicate against the CAS substrate — only new shas trigger an upload via the same direct-to-S3 presigned URL flow as `run402 assets put`. Defaults: `visibility: "public"`, `immutable: true`. Existing keys outside the batch are left untouched. ```json "assets": { "put": [...], "sync": { "prefix": "static/", "prune": true, "confirm": { "base_revision": "", "delete_set_digest": "", "expected_delete_count": 42 } } } ``` Declarative sync. `prune: true` deletes keys under `prefix` that aren't in the new `put` batch. Requires an explicit `prefix` — no implicit project-root prune. First apply without `confirm` returns the `asset_sync` block in the plan response (`base_revision`, `delete_set_digest`, `expected_delete_count`, `sample_keys`); surface those to the user, then re-run with `confirm` populated. Gateway activation re-checks via `ASSET_SYNC_DRIFT` if inventory mutates between commit and activation. Why this exists: the SDK's Node helpers (`r.assets.uploadDir(path, { project })`, `syncDir`, `prepareDir`, `putMany`) walk a local directory, compute SHA-256s in streaming chunks, and submit through this same apply slice. The CLI doesn't ship a `run402 assets sync` subcommand — write the manifest and use `deploy apply`, or call the Node SDK directly from a script. Migrations can be inline (`sql`) or read from a file with `sql_path` / `sql_file` on each migration entry. Make migrations idempotent: Use `CREATE TABLE IF NOT EXISTS` and `CREATE INDEX IF NOT EXISTS`. If you redeploy to the same project, non-idempotent migrations will fail with a 422 error (e.g., `relation "items" already exists`). Evolving your schema: `CREATE TABLE IF NOT EXISTS` only prevents "table already exists" errors — it will not add new columns to an existing table. When your app needs new columns, use `ALTER TABLE ... ADD COLUMN` wrapped in a DO block to make it idempotent: ```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 $$; ``` This pattern is safe to re-run on every deploy. Put your `CREATE TABLE IF NOT EXISTS` first, then one `DO` block per new column (or group them in a single block). Authorization (the `manifest.json` file pattern) Tables you create are dark by default — `anon` and `authenticated` can't read them until your manifest declares them with `expose: true`. Closes the "agent created a table, forgot to set RLS, data leaked" footgun. JSON Schema: `https://run402.com/schemas/manifest.v1.json` — point your editor's `$schema` at it for autocomplete. The preferred way to declare authorization is to ship a `manifest.json` file in the bundle's `files[]` (shown above). The platform reads it, validates it against the migration SQL, applies it, and strips it from `files[]` before the site deploys so it's never publicly reachable on your subdomain. The deploy response includes `manifest_applied: true` on success. If the manifest references a table your migration doesn't create, the deploy is rejected with HTTP 400 and a structured `errors` array listing every violation. For a non-mutating feedback loop before applying, run `run402 projects validate-expose [project_id] --file manifest.json [--migration-file setup.sql]`. It validates the auth/expose manifest used by `manifest.json`, `database.expose`, and `apply-expose`; it is not deploy-manifest validation. Migration SQL is used only for reference checks and is not executed as a PostgreSQL dry run. The command prints `{ "hasErrors": boolean, "errors": [...], "warnings": [...] }` and exits 0 even when `hasErrors` is true — the validator ran, so the command succeeded; agents read `hasErrors` to decide what to do. Built-in policies (one per table): - `user_owns_rows` — owner column matches `auth.uid()`. Requires `owner_column`. With `force_owner_on_insert: true`, a per-table `BEFORE INSERT` trigger named `_set_owner` calls `
_set_owner_fn()` and fills the owner only when `NEW.` is `NULL`: `IF NEW."" IS NULL THEN NEW."" := auth.uid(); END IF;`. It does not overwrite explicit non-null owners; ordinary authenticated inserts with a different explicit owner still fail the `WITH CHECK (owner_column = auth.uid())` policy. `service_key` / service-role writes bypass RLS, but the trigger still runs; if no JWT subject is present then `auth.uid()` is null, so admin writes should set the owner explicitly when needed. Best for user-scoped data (todos, workouts, messages). `uuid` owner columns get index-friendly policies; other types fall back to a `::text` cast with a warning; a btree index is auto-created. - `public_read_authenticated_write` — anyone reads; any authenticated user can INSERT/UPDATE/DELETE any row (not just their own). For collaborative content (shared boards, announcements). - `public_read_write_UNRESTRICTED` — ⚠ fully open; `anon_key` can read AND write any row. For intentionally public tables only (guestbooks, waitlists, feedback forms). Requires `"i_understand_this_is_unrestricted": true` on the table entry. - `custom` — escape hatch. Provide `custom_sql` containing `CREATE POLICY` statements; they run inside the apply transaction after RLS is enabled + forced. | Policy | anon SELECT | anon writes | auth SELECT | auth writes | |---|:---:|:---:|:---:|:---:| | (omitted from manifest) | — | — | — | — | | `user_owns_rows` | — | — | own rows | own rows | | `public_read_authenticated_write` | all | — | all | all rows | | `public_read_write_UNRESTRICTED` | all | yes | all | yes | `—` = denied. `service_key` bypasses all policies. Views are always created with `security_invoker=true` — they inherit the underlying table's RLS. RPCs require an entry in `rpcs[*]` with `grant_to` to be callable as `/rest/v1/rpc/` (since v1.30, `CREATE FUNCTION` revokes PUBLIC EXECUTE automatically). Worked example covering all three slices (drop in as `manifest.json` or under `database.expose` in a deploy manifest): ```json { "$schema": "https://run402.com/schemas/manifest.v1.json", "version": "1", "tables": [ { "name": "posts", "expose": true, "policy": "public_read_authenticated_write" }, { "name": "notes", "expose": true, "policy": "user_owns_rows", "owner_column": "user_id", "force_owner_on_insert": true } ], "views": [ { "name": "posts_public", "base": "posts", "select": ["id", "title", "published_at"], "expose": true } ], "rpcs": [ { "name": "increment_counter", "signature": "(counter_name text)", "grant_to": ["authenticated"] }, { "name": "now_utc", "signature": "()", "grant_to": ["anon", "authenticated"] } ] } ``` `rpcs[*].signature` is the parenthesized argument list — `"()"` for no-arg functions, `"(arg_name type, ...)"` otherwise. The schema regex is `^\([^;]*\)$` (no semicolons, parentheses required). `grant_to` is a non-empty array of Postgres roles — typically `anon`, `authenticated`, `service_role`, or `project_admin`. The function must already exist in your migration SQL with a matching signature; the manifest only grants EXECUTE. Views require `base` plus a non-empty `select`; they are always created `security_invoker=true` and inherit the base table's RLS. **Imperative escape hatch:** for ad-hoc changes outside a deploy, use `run402 projects apply-expose --file manifest.json`. Inspect current state with `run402 projects get-expose ` — `source: "applied"` means it came from a prior apply; `"introspected"` means it was reconstructed from live DB state. The manifest is convergent: applying the same manifest twice is a no-op; items removed between applies have their policies revoked, grants revoked, triggers dropped, views dropped. Include everything you want exposed in every apply. Deploy: ```bash run402 deploy apply --manifest app.json ``` This deploys to an existing project: runs migrations, applies the authorization manifest from `database.expose`, deploys functions, deploys the site, and claims the subdomain. Secret values must be set before deploy with `run402 secrets set`; deploy manifests may only declare `secrets.require` / `secrets.delete`. Provision the project first with `run402 projects provision`. ### Step-by-Step Deploy If you want more control: ```bash # 1. Provision a database run402 projects provision --name my-app # 2. Create tables run402 projects sql "CREATE TABLE items (id serial PRIMARY KEY, title text NOT NULL, done boolean DEFAULT false)" # 3. Insert seed data run402 projects sql "INSERT INTO items (title) VALUES ('Buy groceries'), ('Read a book')" # 4. Declare authorization. Write manifest.json first: # {"version":"1","tables":[{"name":"items","expose":true,"policy":"public_read_authenticated_write"}]} run402 projects validate-expose --file manifest.json run402 projects apply-expose --file manifest.json # 5. Deploy a static site (uses active project automatically) run402 sites deploy --manifest site.json # 6. Claim a subdomain (uses active project + last deployment automatically) run402 subdomains claim my-app ``` --- ## Command Reference ### init - `run402 init` — set up with x402 (Base Sepolia). Creates allowance, requests faucet, checks tier, lists projects. - `run402 init mpp` — set up with MPP (Tempo Moderato testnet). Same steps, different payment rail. ### status `run402 status` — show full account state in one shot (wallet, rail, balances, tier, projects, active project). Read-only, JSON output. Includes a `wallet: { local_label, server_label, address }` object naming the active named wallet (`local_label` is the local selector, `server_label` the server-synced display name or null), a top-level `rail`, and a `balances: { on_chain_usd_micros, on_chain_token, prepaid_credit_usd_micros, held_usd_micros }` object. The on-chain token tracks the rail (USDC on x402, pathUSD on mpp); prepaid credit is rail-independent. ### wallets Manage multiple named wallets (profiles) on one machine. Keys never leave the machine (non-custodial). The `default` wallet lives at the config-dir root; named wallets live under `{config_dir}/profiles//`. - `run402 wallets list` — JSON array of `{ local_label, server_label, address, address_short, rail, active }`. Reads non-secret `meta.json`; never loads private keys. - `run402 wallets current` — the resolved active wallet `{ name, source, source_detail, address, label, warnings }`. `source` ∈ flag|env|binding|config|default. `warnings` surfaces env-vs-binding conflicts and local-name-vs-server-label drift. - `run402 wallets new [--mpp]` — create a new named wallet (generates a key). `{ name, address, rail, created: true }`. - `run402 wallets use ` — set the global default wallet (`config.json` `active_wallet`). `{ name, active: true }`. - `run402 wallets rename ` — rename a wallet; renaming `default` migrates its root files into `profiles//`. `{ from, to, renamed: true }`. - `run402 wallets bind []` — write `./.run402.json` binding this directory to a wallet (defaults to the active one). Safe to commit (holds only a name). `{ wallet, file, bound: true, safe_to_commit: true }`. - `run402 wallets unbind` — remove `./.run402.json`. `{ file, unbound }`. - `run402 wallets import --key ` — adopt an existing 0x-prefixed 64-hex private key (file path or `-` for stdin) as a named wallet. `{ name, address, imported: true }`. - `run402 wallets rm --yes` — delete a wallet and its keys. Requires `--yes` (agent-first: no interactive prompt). Refuses to remove `default`. `{ name, removed: true }`. - Server-side display label: `new`/`rename`/`import` push the wallet's name to a server-side label (signed by the wallet — proof of control) so the same name shows cross-machine and in the operator console (WEB). Best-effort and on by default; `RUN402_WALLET_LABEL_SYNC=0` opts out (fully offline wallet ops). The local folder name is the source of truth; the label is a mirror, and `wallets current` flags any drift. - Selection for ANY command: `--wallet ` (alias `--profile`) > `RUN402_WALLET` > nearest `./.run402.json`/`.run402.local.json` > `wallets use` default > `default`. A conflicting env + binding errors with `WALLET_SELECTION_CONFLICT` (resolve via `--wallet`, `unset RUN402_WALLET`, or `wallets unbind`). Selecting a non-existent wallet errors with `WALLET_NOT_FOUND`. ### allowance - `run402 allowance ` - `run402 allowance checkout --amount ` - `run402 allowance history [--limit ]` ### tier Tier and quotas are per billing account (not per project) — `set` is account-wide, `status.pool_usage` is the pooled total across every project on the account. `set` refetches status after the call and includes it as `status_after`. - `run402 tier status` - `run402 tier set ` ### projects - `run402 projects quote` - `run402 projects list [--org ] [--all]` — SERVER read of the named, domain-aware inventory (NOT the local keystore). Membership-scoped by default: every project owned by an org your wallet is an active member of, each row `{ project_id, name, site_url, custom_domains, org_id, status, active }` (`active` from local state). `--org ` filters to one org (authorize-before-reveal: non-member/guessed id -> 403, non-UUID -> 400). `--all` reads the cross-wallet inventory across every wallet controlling your operator email — run `run402 operator login` first for the union, else it falls back to the current wallet's slice and echoes `scope`. Bare `run402 projects list` is the cold-start path (no login needed). Tier/lifecycle live on the billing account — use `run402 status` / `run402 tier status`. - `run402 projects rename --name
""` - `run402 projects keys ` — print anon_key + service_key as JSON - `run402 projects costs [--window <24h|7d|30d|90d>]` — admin-only per-project finance: revenue, direct cost, margin, and cost breakdown. Uses the configured allowance wallet for admin auth; `RUN402_ADMIN_COOKIE='run402_admin=...'` is an optional browser-session override. - `run402 projects promote-user ` — promote a user to project_admin role - `run402 projects demote-user ` — demote a user from project_admin role - `run402 projects ` - `run402 projects delete --confirm` — cascade deletes all project resources: Lambda functions, subdomains, S3 site files, deployments, secrets, and published app versions. The schema slot is dropped and recreated. This is irreversible. `--confirm` is required. - `run402 projects validate-expose [id] ` — validate an auth/expose manifest without applying it - `run402 projects validate-expose [id] --file manifest.json [--migration-file setup.sql]` — validate file input with optional migration-reference SQL - `run402 projects apply-expose ` — apply a declarative authorization manifest - `run402 projects apply-expose --file manifest.json` — apply from a JSON file - `run402 projects get-expose ` — print the current manifest (`source: applied | introspected`) Provisioning automatically sets the new project as the active project. Other commands that take `` default to the active project when omitted. SQL supports DDL + queries, returns JSON. REST uses PostgREST syntax (`select=`, `eq.`, `order=`, `limit=`). User auth: password + Google OAuth. See "User Auth" section below. ### admin (platform-admin only, v1.57+) - `run402 admin lease-perpetual --enable | --disable` — toggle the account-level escape hatch. When enabled, the account never advances past `active` regardless of lease expiry; every project on the account is pinned. Enabling on a grace-state account (past_due / frozen / dormant) reactivates inline (`reactivated: true` in the response). Replaces the v1.56 `run402 projects pin` (gateway endpoint /projects/v1/admin/:id/pin was removed in v1.57). - `run402 admin archive [--reason "..."]` — operator moderation. Sets `projects.archived_at = NOW()` on a single project; sibling projects on the same billing account keep serving. No-op when already archived (returns `note: "already archived"`). - `run402 admin reactivate ` — un-archive a project (flips `archived_at` back to NULL). In v1.57 this was narrowed: it no longer touches account-level lifecycle. To reactivate a grace-state account, run `run402 tier set ` (the tier flow runs the lifecycle advance inline) or enable `run402 admin lease-perpetual --enable`. All admin subcommands require a platform-admin allowance wallet (or an admin OAuth session). Project owners with a non-admin wallet receive `403 admin_required`. ### deploy - `run402 deploy apply --manifest app.json [--project ] [--quiet|--final-only] [--allow-warning ...] [--allow-warnings]` — unified apply primitive (v1.34 unified deploy → v2.0 unified apply with `assets` slice support) - `run402 deploy resume [--quiet]` — re-run a stuck operation forward - `run402 deploy promote [--project ] [--allow-warning ] [--allow-warnings]` — operator pointer-swap (re-point live release without re-running the apply pipeline); v1.58+ - `run402 deploy list [--project ] [--limit ]` — list recent deploy operations - `run402 deploy events [--project ]` — fetch the recorded event stream for an operation - `run402 deploy release get [--project ] [--site-limit ]` — fetch release inventory - `run402 deploy release active [--project ] [--site-limit ]` — fetch current-live release inventory - `run402 deploy release diff --from --to [--project ] [--limit ]` — diff release targets - `run402 deploy diagnose [--project ] [--method GET]` — URL-first public deploy diagnostics - `run402 deploy resolve [--project ] (--url | --host [--path /x]) [--method GET]` — lower-level resolver parity; `--url` cannot be combined with `--host`/`--path` Requires active tier and a provisioned project. Deploys to an existing project: runs migrations, applies the authorization manifest (from a `manifest.json` entry in `files[]`), deploys functions, deploys static site, and claims subdomain. Secret values are write-only: set them with `printf %s "$OPENAI_API_KEY" | run402 secrets set OPENAI_API_KEY --stdin` or `--file ` before deploy, then use value-free `secrets.require` in `deploy apply` manifests. `deploy apply` stops before upload/commit on confirmation-required warnings unless each warning is covered by repeatable `--allow-warning ` or the broad reviewed `--allow-warnings`. The manifest must include `project_id` (or use `--project` flag, or omit both to use the active project). Inside GitHub Actions, `deploy apply` automatically uses OIDC credentials when `GITHUB_ACTIONS=true`, `ACTIONS_ID_TOKEN_REQUEST_URL`, and `ACTIONS_ID_TOKEN_REQUEST_TOKEN` are present. In that mode, project id resolution is `--project`, then `manifest.project_id`, then the local active project if present, then `RUN402_PROJECT_ID`. ### ci - `run402 ci link github [--project ] [--manifest ] [--repo ] [--branch | --environment ] [--repository-id ] [--workflow ] [--expires-at ] [--route-scope ...] [--force]` — create a GitHub Actions OIDC deploy binding and write a workflow - `run402 ci list [--project ]` — list CI bindings for a project - `run402 ci revoke ` — revoke a binding `link github` requires a local allowance because it signs the delegation. The generated workflow does not require an allowance file or service key in GitHub; it uses GitHub's OIDC token with `id-token: write`. Use repeatable `--route-scope` only when CI should deploy route declarations; no scopes means no CI route authority. ### transfer (v1.59 two-party wallet handoff; v1.78 email->org handoff) One noun, two rails: a **wallet** recipient is a two-party wallet transfer; an **email** recipient is an email->org handoff (the recipient claims it into an org). Both ride the same transfer rail and share a `transfer_id`. - `run402 transfer init --to [--project ] [--billing-policy migrate] [--message ] [--kysigned ]` — initiate (owner-only). `--to` routes by recipient kind: a wallet starts a transfer, an email starts a handoff. Returns `{ transfer_id, expires_at, ... }`. - `run402 transfer preview [--handoff]` — fetch the preview document (either party); `--handoff` previews an email->org handoff. Lists custom domains, subdomains, function names, secret NAMES (never values), CI bindings that will be revoked on accept, mailbox summary, billing implications. - `run402 transfer list [--incoming | --outgoing | --handoffs] [--limit N] [--offset N]` — `--incoming` (default) shows wallet transfers OFFERED TO you; `--outgoing` shows transfers you initiated; `--handoffs` shows incoming email->org handoffs. Each entry includes `preview_path`. - `run402 transfer accept ` — accept a WALLET transfer (recipient). Atomically flips ownership, revokes the previous owner's CI bindings on the project, and stamps a `secrets_rotation_advised` advisory. Secret VALUES are inherited; the response returns `secret_names_inherited[]`. - `run402 transfer claim [--into ]` — claim an incoming EMAIL handoff into an org you own (omit `--into` to claim into a brand-new org). The handoff analog of `accept`. - `run402 transfer cancel [--reason ] [--handoff]` — cancel (either party); `--handoff` cancels an email->org handoff. While a transfer is `pending` (72h TTL), owner-side mutations on the project return `409 PROJECT_HAS_PENDING_TRANSFER` with `details.transfer_id` and a `next_actions[]` cancel route. Data-plane (`/rest/v1/*`, function invocation, mailbox send/receive) keeps serving; payment-path routes (tier renew, billing) keep working; the `transfer cancel` route is intentionally unblocked so the owner can always recover. After accept, rotate every inherited secret with `run402 secrets set ` — the `secrets_rotation_advised` advisory on `tier status` clears once every previously-inherited name has been re-written. What does NOT transfer: tier lease (stays with the original owner's billing account; no Phase 1A proration), KMS contract wallets (wallet-scoped, not project-scoped), GitHub repo ownership (handle out of band), or on-chain balance on any wallet. Phase 1A supports only `--billing-policy migrate` — the project moves into the recipient's billing account; if they have no active billing account yet, accept returns `409 RECIPIENT_ACCOUNT_NOT_ACTIVE`. ### org / grants (v1.77+ org-owned control plane; first-class orgs v1.82) A wallet AUTHENTICATES; an org owns projects. Authorization is an org membership role (`owner > admin > developer > billing > viewer`) or a per-project grant — never `wallet == signer`. Member/invite changes require an active `owner`. Members and invites are grouped sub-resources (`org member …`, `org invite …`). Memberships carry `org_id` + `display_name`. Create/rename/member/invite mutations are step-up gated for control-plane sessions. - `run402 org create [--name