run402-96a4bc — independently scanned and version-tracked by SaferSkills.
SaferSkills independently audited run402-96a4bc (Agent Skill) and scored it 96/100 (green). The audit ran 55 deterministic rules across Security, Supply Chain, Maintenance, Transparency, and Community; it found 0 high-severity and 1 lower-severity findings. The full rule-by-rule trace and per-finding evidence are below. Free, methodology-open.
Findings & checks · 1 flagged
The text {match} tells the agent to skip the normal "ask the user first" gate. Used adversarially it removes the human-in-the-loop check before destructive or sensitive actions, turning a normally-gated agent into a fire-and-forget executor.
Every scanned point with the score it earned and what moved between them.
First recorded scan — no prior version to compare against.
The primary manifest — the file an agent reads to learn what this artifact does.
Run402 gives an agent a real Postgres database with REST API and user auth, content-addressed CDN storage, static site hosting, Node 22 serverless functions, email, image generation, and KMS-backed on-chain signing. One command provisions; payment happens automatically with x402 USDC on Base. Prototype tier is free on testnet — no real money, no human signup.
Every example below is a CLI command. The CLI prints JSON to stdout, JSON errors to stderr, and exits 0 on success / 1 on failure — designed for shells, scripts, and agent loops.
run402 init # one-shot: allowance + faucet + tier check
run402 tier set prototype # FREE on testnet (verifies x402 setup)
run402 projects provision --name my-app # → anon_key, service_key, project_id
run402 sites deploy-dir ./dist # incremental upload of a directory
run402 subdomains claim my-app # → https://my-app.run402.comThat's a real Postgres database + a deployed static site, paid for autonomously with testnet USDC.
| You want to… | Reach for… |
|---|---|
| Set up a wallet from scratch | run402 init |
| Make a database | run402 projects provision |
| Run SQL on it | run402 projects sql |
| Check an auth manifest before applying | run402 projects validate-expose |
| Make a table reachable from the browser | run402 projects apply-expose |
| Deploy a frontend from a directory | run402 sites deploy-dir <path> |
| Link GitHub Actions deploys | run402 ci link github |
| Stash a file with a paste-able CDN URL | run402 assets put <file> |
| Run code on the server | run402 functions deploy |
| Send email | run402 email send |
| Sign on-chain | run402 contracts call |
| One-call full-stack deploy | run402 deploy apply --manifest app.json |
The active project is sticky — run402 projects use <id> makes it the default for every subsequent <id>-taking command. Most commands work without an explicit <id> once a project is active.
Hold several wallets on one machine - per-client isolation, personal vs work, blast-radius containment. Keys never leave the machine (non-custodial). The default wallet stays at ~/.config/run402/; named wallets live under ~/.config/run402/profiles/<name>/.
run402 wallets new kychon # create a named wallet
run402 wallets list # name, label, address, rail, active
run402 wallets use kychon # set the global default wallet
run402 wallets bind kychon # write ./.run402.json - this repo uses kychon (safe to commit)
run402 wallets current # which wallet is active + how it was selected
run402 --wallet kychon projects list # one-off override for a single commandSelection precedence for every command: --wallet <name> flag > RUN402_WALLET env > nearest ./.run402.json directory binding > wallets use default > default. A RUN402_WALLET that disagrees with a .run402.json binding is a hard error - pass --wallet, unset RUN402_WALLET, or run402 wallets unbind. Non-default selections echo the wallet name on stderr so you always know which wallet you are on.
After provision, two keys land in ~/.config/run402/projects.json (per active wallet):
anon_key — for the browser. Read-only by default; safe to embed in HTML. RLS still applies.service_key — server-side admin. Never embed in browser code. CORS is intentionally open for x402 clients, so a leaked service_key is exploitable from any origin. Use only inside functions or when running CLI as the agent.Neither expires. Lease enforcement happens server-side.
run402 projects keys <id> # print the project's anon_key + service_key as JSON
run402 projects info <id> # tier, lease, schema slot, host, …Run402 JSON errors may include canonical fields. Branch on stable code, not English message or legacy error text.
Fields to use:
code: machine-readable reason, e.g. PROJECT_FROZEN, PAYMENT_REQUIRED, MIGRATION_FAILED, MIGRATE_GATE_ACTIVEretryable: the same request may succeed latersafe_to_retry: repeating the same request should not duplicate or corrupt a mutationmutation_state: one of none, not_started, committed, rolled_back, partial, unknowntrace_id: include when reporting an issuerequest_id: routed/function failure handle; use run402 functions logs <id> <name> --request-id <req_...> for diagnostics. Distinct from gateway trace_id.details: structured route-specific contextnext_actions: advisory actions e.g. authenticate, submit_payment, renew_tier, check_usage, retry, resume_deploy, edit_request, edit_migration, create_project, initialize_wallet, deploy; never treat them as blindly executable. CLI-resolvable entries carry a literal command. Cold start: from run402 deploy apply, follow the chain it hands back — no allowance -> run402 init, no tier -> run402 tier set prototype, no project -> run402 projects provision — then retry. tier set and projects provision accept --idempotency-key so retries never double-chargeRetry policy:
retryable: true and safe_to_retry: true; reuse the same idempotency key for mutating operations.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 specs by re-planning through the SDK. A handled retry appears as a deploy.retry stderr event; exhausted retries include attempts, max_retries, and last_retry_code. Static activation/config failures reported from activation_pending throw promptly with gateway metadata. Do not hand-roll this specific deploy race loop.safe_to_retry: false, or mutation_state: "committed", "partial", or "unknown", inspect/poll/reconcile state before retrying. For deploys, inspect events or resume the existing operation instead of starting a duplicate deploy.PROJECT_FROZEN/PROJECT_DORMANT/PROJECT_PAST_DUE -> check usage or renew tier; PAYMENT_REQUIRED/INSUFFICIENT_FUNDS -> submit/fund payment.Examples:
{ "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" }] }{ "message": "Payment required.", "code": "PAYMENT_REQUIRED", "category": "payment", "retryable": true, "safe_to_retry": true, "next_actions": [{ "action": "submit_payment" }] }{ "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" }] }deploy-dir — the modern pathdeploy-dir walks a local directory, hashes each file client-side, and only PUTs bytes the gateway doesn't already have. Re-deploying an unchanged tree returns immediately with bytes_uploaded: 0.
run402 sites deploy-dir ./dist > result.json 2> events.logSkips .git/, node_modules/, .DS_Store automatically. Symlinks throw (no cycles).
stderr streams progress events — one JSON object per line:
| phase | When fired | Extra fields |
|---|---|---|
plan | After the planning request | manifest_size (file count) |
upload | After each missing file finishes PUTing | file, sha256, done, total |
commit | Just before commit | — |
poll | Per server-side copy poll | status, elapsed_ms |
Pass --quiet to suppress events; the final result envelope still goes to stdout.
deploy — one-call full stackFor a database + migrations + manifest + secret dependencies + functions + site + subdomain, set secret values first, then deploy a value-free manifest:
run402 secrets set <project_id> OPENAI_API_KEY --file ./.secrets/openai-key
run402 deploy apply --manifest app.json --final-onlyUse --final-only (alias of --quiet) when an agent or CI job only wants the final stdout JSON envelope. Use repeatable --allow-warning <code> for reviewed warning codes; reserve broad --allow-warnings for reviewed exceptional cases.
After deploys, inspect release state without starting another mutation:
run402 deploy release active --project prj_...
run402 deploy release get rel_... --project prj_...
run402 deploy release diff --from empty --to active --project prj_...Inventories expose site paths, static_public_paths when returned, functions, secret keys only, subdomains, materialized routes, applied migrations, release_generation, static_manifest_sha256, nullable static_manifest_metadata, and 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. Release diffs use migrations.applied_between_releases, route added / removed / changed buckets, and static_assets counters for unchanged/changed/added/removed files, CAS byte reuse, eliminated deployment-copy bytes, and immutable/CAS warning counts.
#### Same-origin web routes
Use run402 deploy apply for site.public_paths clean static browser URLs and public browser routes to functions or exact method-aware static aliases. Release static asset paths and public browser paths are distinct: events.html can be a private release asset while /events is the public static URL.
{
"project_id": "prj_...",
"site": { "replace": {
"index.html": { "data": "<!doctype html><main id='app'></main><script>fetch('/api/hello')</script>" },
"events.html": { "data": "<!doctype html><h1>Events</h1>" }
}, "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" } }
]
}
}site.public_paths.mode: "explicit" means only the complete public_paths.replace table is directly reachable as static URLs. In the example, /events serves release asset events.html, while /events.html is not public unless separately declared. { "mode": "implicit" } restores filename-derived public reachability and can widen access; review gateway warnings before confirming that switch. Public-path-only site specs are meaningful deploy content.
Omit routes or pass routes: null to carry forward base routes. Use routes: { "replace": [] } to clear the route table. Do not use path-keyed maps. Function targets use { "type": "function", "name": "<materialized function name>" }. Prefer site.public_paths for ordinary clean static URLs e.g. /events -> events.html. Static route targets use exact patterns only, methods ["GET"] or ["GET","HEAD"], and { "pattern": "/events", "methods": ["GET", "HEAD"], "target": { "type": "static", "file": "events.html" } } for route-only aliases; file is a release static asset path, not a public path, URL, CAS hash, rewrite, or redirect. Direct /functions/v1/:name remains API-key protected; browser-routed paths are public same-origin ingress, so the function owns application auth, CSRF for cookie-authenticated unsafe methods, CORS/OPTIONS, cookies, redirects, and spoofed forwarding-header hygiene.
Matching is exact or final /* prefix only. /admin/* does not match /admin; use both /admin and /admin/* for a dynamic area 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; matched dynamic route failures fail closed.
Routed functions use the Node 22 Fetch Request -> Response contract: export default async function handler(req) { ... }. req.method is the browser method, and req.url is the full public URL on managed subdomains, deployment hosts, and verified custom domains. Derive OAuth callbacks from it, for example new URL("/admin/oauth/google/callback", new URL(req.url).origin). Append multiple cookies with headers.append("Set-Cookie", value); redirects, cookies, and query strings are preserved. The raw run402.routed_http.v1 envelope is internal; do not write route handlers against it.
Use run402 deploy diagnose --project prj_123 https://example.com/events --method GET before mutating deploy state when the question is "what would this public URL serve?" For lower-level parity use run402 deploy resolve --project prj_123 --url https://example.com/events?utm=x#hero --method GET or run402 deploy resolve --project prj_123 --host example.com --path /events --method GET; never combine --url with --host/--path. Output is JSON with status, would_serve, diagnostic_status, match, normalized request, warnings, full resolution, and structured next_steps. URL query strings/fragments are disclosed in request.ignored. 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. Stable-host diagnostics may also 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 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 CLI process status, so host misses can exit 0 with would_serve: false. Do not use diagnostics as a fetch, cache purge, or reason to hard-code cache_policy strings; branch on structured JSON e.g. allow and cas_object.
Known route warning recovery: PUBLIC_ROUTED_FUNCTION means review app auth, CSRF, CORS/OPTIONS, and cookies before retrying with --allow-warning PUBLIC_ROUTED_FUNCTION; broad --allow-warnings is last resort after every warning is reviewed. ROUTE_SHADOWS_STATIC_PATH and WILDCARD_ROUTE_SHADOWS_STATIC_PATHS mean inspect affected paths, active routes, static_public_paths, and resolve diagnostics before confirming. STATIC_ALIAS_SHADOWS_STATIC_PATH, STATIC_ALIAS_RELATIVE_ASSET_RISK, STATIC_ALIAS_DUPLICATE_CANONICAL_URL, STATIC_ALIAS_EXTENSIONLESS_NON_HTML, and STATIC_ALIAS_TABLE_NEAR_LIMIT are route-only static alias warnings; prefer site.public_paths for ordinary clean URLs, inspect the backing asset_path, fix relative assets/canonical URLs, and avoid table-exhausting page-by-page routes. ROUTE_TARGET_CARRIED_FORWARD means inspect carried-forward function targets. METHOD_SPECIFIC_ROUTE_ALLOWS_GET_STATIC_FALLBACK means confirm static fallback is intended. WILDCARD_ROUTE_EXCLUDES_MUTATION_METHODS means a wildcard API prefix 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. ROUTE_TABLE_NEAR_LIMIT means consolidate routes. ROUTES_NOT_ENABLED means deploy without routes or request enablement. 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 as a spec.i18n release slice and the gateway negotiates a locale per routed-function request, then surfaces it to user code through two request headers. Add i18n to the deploy manifest alongside functions and routes and run run402 deploy apply --manifest run402.deploy.json:
{
"project_id": "prj_...",
"functions": {
"replace": {
"api": {
"runtime": "node22",
"source": { "data": "export default async (req) => { const locale = req.headers.get('x-run402-locale'); const def = req.headers.get('x-run402-default-locale'); return Response.json({ locale, default: def }); }" }
}
}
},
"routes": {
"replace": [
{ "pattern": "/api/*", "target": { "type": "function", "name": "api" } }
]
},
"i18n": {
"defaultLocale": "en",
"locales": ["en", "es", "fr", "zh-Hant"],
"detect": ["cookie:wl_locale", "accept-language"]
}
}Carry-forward semantics: omit i18n to carry forward from base release; pass "i18n": null to clear the slice on the new release; pass { defaultLocale, locales, detect? } to replace. Simpler than routes — no { replace } envelope.
Locale-tag rules (strict, no canonicalization):
defaultLocale MUST be byte-identical to one entry in locales[]. The gateway does NOT silently canonicalize; the CLI/SDK validate this client-side before planning./^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/. Tags are opaque — no BCP-47 semantic validation.locales[] is non-empty, max 50 entries.locales[], NOT the request's casing.Detection (detect[], default ["accept-language"], max 10, [] allowed and means "always default"):
"accept-language" parses per RFC 9110, drops q=0 and *, sorts by q descending; applies RFC 4647 §3.4 lookup-style truncation (zh-Hant-TW → zh-Hant → zh); longest matching prefix wins. A generic request tag does NOT match a more-specific locales[] entry — Accept-Language: es does NOT match locales: ["es-MX"]."cookie:<name>" does a case-sensitive cookie-name lookup; the raw cookie value (no percent-decode) is matched case-insensitively against locales[]. Cookie names MUST match RFC 6265 grammar (/^[!#$%&'*+\-.^_|~0-9A-Za-z]+$/`).Read the negotiated locale inside a routed function (single-arg (req) signature, not (req, ctx)):
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' });
};x-run402-locale and x-run402-default-locale are OMITTED entirely when the active release has no i18n slice (additive-compat). The gateway injects them at request time, so already-deployed function bundles see new headers on the next deploy that adds i18n — no function redeploy required. The bundled @run402/functions runtime translates the routed envelope into a Web-standard Request before calling user code, so the routed-envelope context.locale is NOT visible to typical user functions — read the headers instead.
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.
#### 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:
function setLanguage(lang) {
localStorage.setItem('wl_locale', lang);
document.cookie =
`wl_locale=${encodeURIComponent(lang)}; path=/; max-age=31536000; samesite=lax`;
}{ "i18n": { "defaultLocale": "en", "locales": ["en", "es"], "detect": ["cookie:wl_locale", "accept-language"] } }The deploy manifest is a v2 ReleaseSpec; put the auth manifest under database.expose:
{
"project_id": "prj_…",
"database": {
"migrations": [{ "id": "001_init", "sql_path": "setup.sql" }],
"expose": {
"$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 }
]
}
},
"secrets": { "require": ["OPENAI_API_KEY"] },
"functions": {
"replace": {
"my-fn": {
"runtime": "node22",
"source": { "data": "export default async (req) => new Response('ok')" },
"config": { "timeoutSeconds": 30, "memoryMb": 256 }
}
}
},
"site": {
"replace": {
"index.html": { "data": "<!doctype html>…" },
"logo.png": { "data": "iVBORw0…", "encoding": "base64" }
}
},
"subdomains": { "set": ["my-app"] }
}The database.expose entry is auth-as-SDLC — your authorization travels with the release. The gateway validates it against the migration SQL and applies it atomically. If the manifest references a table the migration doesn't create, the deploy is rejected with HTTP 400 and a structured errors array listing every violation.
Provision first (run402 projects provision) so you have the anon_key to embed in your HTML before deploying.
Link once locally, then let GitHub Actions run the same deploy command agents already use:
run402 ci link github --project prj_... --manifest run402.deploy.json
# Optional route authority for CI route declarations:
run402 ci link github --project prj_... --manifest run402.deploy.json --route-scope /admin --route-scope /api/*
git add .github/workflows/run402-deploy.yml run402.deploy.json
git commit -m "Add run402 deploy workflow"The generated workflow uses a pinned run402@<current> CLI via npx, includes permissions: id-token: write and contents: read, and runs:
run402 deploy apply --manifest run402.deploy.json --project prj_...Useful follow-ups:
run402 ci list --project prj_...
run402 ci revoke cib_...V1 intentionally keeps the shape narrow: push and workflow_dispatch only, no PR deploy flags, no raw subject or wildcard flags, and no soft repository-id binding. Without --route-scope, CI cannot deploy routes; with repeatable route scopes, it may deploy only matching exact paths e.g. /admin or final-wildcard prefixes e.g. /api/*. If CI returns CI_ROUTE_SCOPE_DENIED, re-link with covering scopes or run the route-changing deploy locally. Revocation stops future CI gateway requests but does not undo already-deployed code, stop in-flight deploy operations, rotate exfiltrated keys, or remove deployed functions.
Tables you create are dark by default. Until your manifest declares a table with expose: true, it's invisible to anon and authenticated callers. This eliminates the "agent forgot RLS, data leaked" footgun. The manifest is the single source of truth for what's reachable via /rest/v1/*.
JSON Schema: <https://run402.com/schemas/manifest.v1.json>. Set $schema on your manifest file and your editor gets autocomplete for free.
manifest.json in your bundleAuthorization travels with your code. Put a file named manifest.json in the bundle's files[] and the gateway reads it, validates it against your 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.
{
"$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"] }
]
}If the manifest references a table the migration doesn't create, the deploy is rejected with HTTP 400 and a structured errors array listing every violation (not the first).
validate-exposeBefore applying, run:
run402 projects validate-expose [id] --file manifest.json --migration-file migrations.sqlThis 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; read hasErrors to decide what to do.
apply-exposeFor ad-hoc changes outside a deploy — same JSON shape, no bundle:
run402 projects apply-expose <id> --file manifest.json
run402 projects get-expose <id> # source: "applied" | "introspected"get-expose returns the live state. source: "applied" means it came from a prior apply-expose (or a bundled manifest.json); "introspected" means no manifest has ever been applied and the response was reconstructed from live DB state.
Convergent: applying the same manifest twice is a no-op. Items removed between applies have their policies, grants, triggers, and views dropped. Always include everything you want exposed.
| 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 anything user-scoped. |
public_read_authenticated_write | Anyone reads. Any authenticated user writes any row. For shared boards / collaborative content. |
public_read_write_UNRESTRICTED | Fully open. Requires i_understand_this_is_unrestricted: true on the table entry. Only for guestbooks / waitlists / feedback forms. |
custom | Escape hatch. Provide custom_sql with CREATE POLICY statements. |
Views always run with security_invoker=true — they inherit the underlying table's RLS, so they can't accidentally leak hidden columns. RPCs are not exposed unless listed in rpcs[] (a database event trigger revokes PUBLIC EXECUTE on every newly-created function).
run402 assets put returns an AssetRef. The URL is content-addressed (pr-<public_id>.run402.com/_blob/<key>-<8hex>.<ext>), served through CloudFront, and never needs cache invalidation:
run402 assets put ./logo.png --json
run402 assets put ./app.js --json
run402 assets put ./styles.css --json
run402 assets put ./asset --key assets/logo --content-type image/svg+xml --json
run402 assets put ./hero.jpg --meta uploaded_by=agent_abc --meta tags=hero,banner --exif-policy strip --jsonEach response includes:
| Field | Use |
|---|---|
cdn_url | The content-addressed URL — paste straight into src= / href= |
sri | sha256-<base64> for <script integrity="…"> if you build tags by hand |
etag | Strong "sha256-<hex>" ETag |
cache_kind | immutable / mutable / private |
metadata (v1.50) | Echo of your --meta bag, or null |
image_format (v1.50) | Decoded format (jpeg/png/webp/…) or null for non-images |
image_info (v1.50) | has_alpha, color_space, animated, frame_count, bit_depth, orientation |
image_exif (v1.50) | EXIF block (null when stripped or no EXIF) |
image_exif_policy (v1.50) | keep or strip, echoing the applied policy |
Immutable upload is the default since v1.45 — the SDK computes the SHA-256 client-side and pairs the URL with SRI. The browser refuses execution on byte mismatch. No invalidation choreography.
blob put infers MIME type from the destination key. Use --content-type <mime> for extensionless assets, unusual file types, or deliberate overrides.
v1.50 `--meta` coercion: pure digits become a number, true/false become a boolean, values containing , become a string[], everything else is a string. ≤4 KB serialized; bad shapes are rejected client-side with INVALID_ASSET_METADATA before any HTTP call.
run402 assets ls --prefix images/
run402 assets ls --sort createdAt:desc --filter is_image=true --filter min_width=320 --filter format=webp
run402 assets ls --filter uploaded_by=agent_abc --filter tag=hero
run402 assets get images/logo.png --output /tmp/logo.png
run402 assets rm images/old.png
run402 assets sign secrets/report.pdf --ttl 3600 # presigned URL for private blobs--sort (v1.50) takes key:asc (default), createdAt:asc, or createdAt:desc. Cursors are sort-pinned — cross-sort reuse returns INVALID_CURSOR_FOR_SORT. --filter k=v is repeatable; allowed keys: uploaded_by, tag, format, is_image, min_width/max_width/min_height/max_height. Unknown keys are rejected client-side with INVALID_FILTER_KEY.
run402 assets diagnose <url> # exit 0 if fresh, 1 if stale
run402 cdn wait-fresh <url> --sha <hex> --timeout 120 # poll until freshdiagnose is shell-loop friendly: until run402 assets diagnose <url>; do sleep 1; done blocks until the CDN catches up. Vantage is single-region (us-east-1) — other PoPs may differ. Don't call wait-fresh on immutable URLs — they're correct from the moment of upload.
run402 projects sql <id> "CREATE TABLE items (id serial PRIMARY KEY, title text NOT NULL, user_id uuid)"
run402 projects sql <id> --file migrations.sql
run402 projects sql <id> "SELECT * FROM items WHERE id = \$1" --params '[42]'
run402 projects rest <id> items "select=id,title&order=id.desc&limit=10"
run402 projects validate-expose <id> --file manifest.json --migration-file migrations.sql
run402 projects schema <id> # introspect tables, columns, RLS
run402 projects usage <id> # API calls, storage, lease expiry
run402 projects costs <id> --window 30d # operator-only finance; admin wallet requiredCREATE TABLE IF NOT EXISTS only handles "already exists" errors — it won't add new columns to an existing table. For evolving schemas, wrap ALTER TABLE in a DO block:
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.
The SQL endpoint blocks: CREATE EXTENSION, COPY ... PROGRAM, ALTER SYSTEM, SET search_path, CREATE/DROP SCHEMA, GRANT/REVOKE, CREATE/DROP ROLE. Table and sequence permissions are granted automatically — use the expose manifest for access control instead of GRANT.
Node 22 runtime. Handler: export default async (req: Request) => Response.
run402 functions deploy <id> my-fn --file fn.ts \
--timeout 30 --memory 256 \
--schedule "*/15 * * * *" \
--deps "stripe,zod@^3,[email protected]"
run402 functions invoke <id> my-fn --body '{"hello":"world"}'
run402 functions logs <id> my-fn --tail 100 --request-id req_abc123 --follow
run402 functions update <id> my-fn --schedule "0 */6 * * *"
run402 functions rebuild <id> my-fn # refresh ONE function onto the current platform runtime
run402 functions rebuild <id> --all # refresh every function in the project
run402 functions list <id>
run402 functions delete <id> my-fnrun402 functions rebuild is opt-in and never changes your source: it re-bundles the stored source against the platform's current runtime/entry-wrapper (deps pinned to the versions recorded at deploy), so the source code_hash is unchanged and no new release is created. This is how a gateway-side wrapper fix (e.g. an SSR auth.* fix) reaches an already-deployed function — a plain redeploy with unchanged source does not. Functions deployed before dependency locking return CANNOT_REBUILD_UNLOCKED_DEPS; redeploy those from source instead. run402 doctor flags functions running a stale runtime so you know when to rebuild.
--deps accepts npm specs: bare names (lodash) resolve to the latest version at deploy time, pinned ([email protected]) and ranges (date-fns@^3.0.0) are honored verbatim. Max 30 entries, 200 chars each. Native binary modules (sharp, canvas, native bcrypt, etc.) are rejected — pure JS only. Don't list @run402/functions (auto-bundled).
The deploy response surfaces:
runtime_version — the bundled @run402/functions versiondeps_resolved — { name: version } for every package the gateway pinned, including transitiveswarnings — non-fatal notes (e.g. when a bare spec resolved to a version that's later than what's typical)@run402/functionsThe one place to write code, not commands. Built-in helpers are auto-bundled:
import { db, adminDb, getUser, email, ai, assets } from "@run402/functions";
export default async (req: Request) => {
const user = await getUser(req);
if (!user) return new Response("unauthorized", { status: 401 });
// Caller-context — Authorization header is forwarded; RLS evaluates against the caller's role.
const mine = await db(req).from("items").select("*").eq("user_id", user.id);
// Bypass RLS — only when the function acts on behalf of the platform.
await adminDb().from("audit").insert({ event: "items_read", user_id: user.id });
if (mine.length === 0) {
await email.send({ to: user.email, subject: "Welcome", html: "<h1>Hi</h1>" });
}
return Response.json(mine);
};db(req) — caller-context. Forwards Authorization header. RLS applies. Default choice.adminDb() — bypass RLS. Use only for audit logs, cron cleanup, webhook handlers, platform-authored writes.adminDb().sql(query, params?) — raw parameterized SQL. Always bypass.ai.generateImage({ prompt, aspect? }) — live image generation from deployed functions, billed/rate-limited against the project organization through RUN402_SERVICE_KEY. Aspects: square, landscape, portrait; result: { image, content_type, aspect }. For public routed functions, authenticate/rate-limit app users before calling it.assets.put(key, source, opts?) — upload runtime bytes through the same CAS-backed apply substrate as deploy-time assets. source is a string, Uint8Array, or { content | bytes }; returns an SDK-compatible AssetRef.getUserId(req) / getRole(req) (v1.51+, @run402/functions 2.5+) — typed reads of the x-run402-user-id / x-run402-user-role headers the gateway injects when a FunctionSpec.requireAuth / requireRole gate passed. Both return string | null. Use these inside a gated function instead of re-decoding the JWT — the gate already verified the caller and resolved the application role. getRole(req) is non-null only when requireRole ran (the value is guaranteed to be in requireRole.allowed). The JWT role from getUser(req) is the system role (anon/authenticated/…), NOT the app role — don't conflate them. See "Function-level auth gates" below for the deploy-spec side.Fluent surface on both db(req).from(t) and adminDb().from(t):
.select(), .eq(), .neq(), .gt(), .lt(), .gte(), .lte(), .like(), .ilike(), .in(), .order(), .limit(), .offset().insert(obj | obj[]), .update(obj), .delete() — return arrays of affected rows.insert({…}).select("id, title")For TypeScript autocomplete in your editor: npm install @run402/functions in your project. Also works at build time for static-site generation if you set RUN402_SERVICE_KEY + RUN402_PROJECT_ID in .env.
Skip the hand-rolled "decode JWT → query members table → return 403" boilerplate. Declare the gate on your FunctionSpec and the gateway enforces it before invoking the function. Unauthorized callers get 401/403 without your code running, and the gateway injects the resolved identity into request headers your function can trust.
Two independent fields on FunctionSpec:
requireAuth: true — gateway rejects callers without a valid project user JWT with 401. No DB lookup.requireRole: { table, idColumn, roleColumn, allowed[], cacheTtl? } — gateway resolves the caller's role from the project-schema table (RLS-bypass — the gateway is the trusted intermediary, not the caller) and rejects callers whose role is not in allowed with 403. Implies authentication.Declare in your deploy manifest and run run402 deploy apply --manifest run402.deploy.json:
{
"project_id": "prj_...",
"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"]
}
}
}
}
}
}Reading the gate result inside the function:
import { getUserId, getRole } from "@run402/functions";
export default async (req: Request): Promise<Response> => {
const userId = getUserId(req); // string | null
const role = getRole(req); // string | null
// For a gated function reached through the gateway:
// getUserId is non-null whenever any gate ran;
// getRole is non-null whenever requireRole ran (one of `allowed`).
return Response.json({ actor: userId, role });
};Rules and footnotes:
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 INVALID_SPEC."public.members") are rejected. The project schema is resolved server-side.DEPLOY_INVALID_ROLE_GATE (422) before flipping the live release. run402 deploy apply surfaces the error envelope on stderr.cacheTtl: 0 (fresh lookup per request)./your/route) and direct (POST /functions/v1/:name with API key) invocation. Direct invocation still requires the API key at the edge; the gate runs after API-key auth, against the user JWT.For Astro apps, scaffold with run402 init astro (sets up astro.config.mjs with the @run402/astro 1.0+ preset, [slug].astro SSR template wired to the DB, layouts, .env.example). Run run402 dev to start astro dev with project credentials in scope.
Functions opt into the SSR class declaratively:
{
"functions": {
"ssr": {
"class": "ssr",
"code": { "data": "...", "encoding": "base64" }
}
}
}The gateway provisions SnapStart-enabled Lambda and reverse-validates the published version before activation. Failure ships the function anyway and surfaces DEPLOY_FUNCTION_SSR_SNAPSTART_VALIDATION_FAILED as a non-blocking warning.
Cache is bypass-by-default. SSR responses only get cached when Cache-Control explicitly allows it AND no Set-Cookie AND no auth-taint flag — getUser() / getUserId() / getRole() from @run402/functions 2.5+ automatically taint per-request caching so personalized renders never get stored.
Invalidate from the CLI:
run402 cache invalidate https://eagles.kychon.com/the-guys — single URLrun402 cache invalidate https://eagles.kychon.com/blog --prefix — path prefixrun402 cache invalidate https://eagles.kychon.com --all — all rows for the hostrun402 cache inspect https://eagles.kychon.com/the-guys — peek at cache stateHost ownership is server-validated; cross-project hosts throw R402_CACHE_INVALIDATION_HOST_FORBIDDEN. Writes are generation-guarded: in-flight MISS renders started before an invalidate cannot overwrite the freshly-cleared state.
Agent-DX shortcuts:
run402 doctor — 5 health checks (credentials, allowance, project, network, SDK build), --json for machine-readable output, exit 1 on fail.run402 dev — runs npx astro dev with .env.local + Run402 credentials in scope.run402 logs --request-id req_XYZ — fetch logs for a specific request id across every function in the project (parallel scan, timestamp-ascending merge).Reference: astro/README.md (top section), cli/llms-cli.txt (R402_* SSR Runtime Error Codes section).
Use portable archives when the user wants no vendor lock-in for the supported Run402 Core runtime slice. This is a portability trust claim: Cloud is the easiest place to start, not the only place the supported application can run. Keep it separate from allowance/spend-cap financial-risk claims.
Canonical CLI path:
run402 cloud archives create <project_id> --scope portable-runtime-v1 --auth stubs --consistency pause-writes --wait --output ./project.r402ar --json
run402 archives inspect ./project.r402ar --json
run402 archives verify ./project.r402ar --json
run402 core projects import ./project.r402ar --name imported-project --env-file ./required.env --jsonMCP tools mirror the same flow: export_project_archive, inspect_project_archive, verify_project_archive, and import_project_archive. SDK helpers live under r.archives; the Node entry adds local inspect, verify, and importToCore, plus standalone inspectArchive, verifyArchive, and importArchiveToCore.
Archive v1 exports active release/apply state, supported Postgres/RLS/REST data, storage/static bytes, functions, Astro SSR artifacts, disabled auth subject stubs, and value-free secret requirements. It does not export secret values, auth credentials, logs, billing/allowance/spend state, Cloud provider/fleet operations metadata, Cloud import, or existing-project merge import. verify is local/offline integrity and compatibility checking, not trust; Core import verifies again and creates a new local project only.
Agent-side calls should use run402 functions invoke; this direct fetch() shape is only for app/browser code that needs to call the deployed function from the user's session.
const res = await fetch("https://api.run402.com/functions/v1/my-fn", {
method: "POST",
headers: {
apikey: ANON_KEY,
Authorization: "Bearer " + session.access_token, // optional, for authenticated calls
"Content-Type": "application/json",
},
body: JSON.stringify({ hello: "world" }),
});Pass a 5-field cron expression. To remove a schedule: --schedule-remove. Tier limits:
| Tier | Max scheduled | Min interval |
|---|---|---|
| Prototype | 1 | 15 min |
| Hobby | 3 | 5 min |
| Team | 10 | 1 min |
Injected as process.env.<KEY> inside every function. Values are write-only — list returns keys and timestamps only, never values or value-derived hashes. Deploy manifests use secrets.require[] to assert keys exist; they never carry secret values.
run402 secrets set <id> STRIPE_KEY --file ./.secrets/stripe-key
printf %s "$STRIPE_KEY" | run402 secrets set <id> STRIPE_KEY --stdin
run402 secrets set <id> JWT_PRIVATE --file ./private.pem
run402 secrets list <id>
run402 secrets delete <id> STALE_KEYPlatform-managed jobs. This is not arbitrary Docker execution: submit the gateway-shaped request with job_type, input["input.json"], and max_cost_usd_micros, then inspect status/logs, cancel one run, or purge all project runs.
run402 jobs submit --file job.json --project <id>
run402 jobs get <job_id> --project <id>
run402 jobs logs <job_id> --project <id> --tail 100
run402 jobs cancel <job_id> --project <id>
run402 jobs purge --project <id>
run402 jobs artifacts get <job_id> result.json --output ./result.json --project <id>When a job completes, jobs get returns an artifacts map keyed by filename, each value an object { url, content_type, sha256, size_bytes } (the old run402:// ref strings were retired; sha256/size_bytes are omitted for pre-change jobs). Download the bytes with jobs artifacts get; discover the recorded filenames from the job's artifacts map.
Up to 5 mailboxes per project at <slug>@mail.run402.com. Optionally bring your own domain. Configure explicit defaults before relying on omitted --mailbox sends.
run402 email create my-app # not idempotent
run402 email mailboxes # candidates + mailbox_settings + next_actions
run402 email defaults --outbound my-app --auth-sender my-app
run402 email update my-app --footer-policy none # hobby/team only; prototype is locked
run402 email send --to [email protected] \
--template notification \
--var project_name="My App" --var message="Hello"
run402 email send --to [email protected] \
--subject "Welcome" --html '<h1>Hi</h1>' --from-name "My App"
run402 email list
run402 email get <message_id>
run402 email get-raw <message_id> --output msg.eml # raw RFC-822 for DKIM / zk-emailOmitting --mailbox on email send uses default_outbound_mailbox_id; missing/invalid defaults return typed errors such as DEFAULT_MAILBOX_REQUIRED / DEFAULT_MAILBOX_INVALID with next_actions. Successful sends echo the actual mailbox_id and from_address when the gateway returns them.
Mailbox reads (email mailboxes / email info) include footer_policy, effective_footer_policy, and footer_policy_locked_reason when the gateway returns them. Use run402 email update <slug|mbx_id> --footer-policy run402_transparency|none to set the policy. none is allowed on hobby/team projects; prototype projects stay locked to run402_transparency and attempts to set none return FOOTER_POLICY_TIER_REQUIRED.
Templates: project_invite (project_name, invite_url), magic_link (project_name, link_url, expires_in), notification (project_name, message ≤ 500 chars).
Tier rate limits: prototype 10/day, hobby 50/day, team 500/day. Unique recipients per lease: 25 / 200 / 1000.
run402 email webhooks register --url https://… --events delivery,bounced,reply_received
run402 email webhooks list
run402 email webhooks update <id> --events delivery,bounced,complained,reply_received
run402 email webhooks delete <id>
# Durable delivery is at-least-once (bounded retries + exponential backoff);
# failures land in failed_permanent — the dead-letter queue. The delivered body
# is the canonical envelope { id, type, created_at, schema_version,
# idempotency_key, payload } — dedupe on idempotency_key.
run402 email webhooks deliveries --status failed_permanent # inspect the DLQ
run402 email webhooks redrive <delivery_id> # replay a dead-lettered delivery
# Reconciliation backstop if a reply_received webhook is ever lost:
run402 email list --direction inboundrun402 sender-domain register example.com # → DKIM CNAMEs to add to DNS
run402 sender-domain status # poll until verified
run402 sender-domain inbound-enable example.com # → MX record (opt-in)Auth supports passwords, magic links, Google OAuth, and WebAuthn passkeys. Google is on for all projects with zero config; passkeys require an exact allowed app_origin (claimed subdomain, project public-id host, active custom domain, or localhost when allowed).
run402 auth magic-link --email [email protected] --redirect https://my-app.run402.com/cb
run402 auth verify --token <token> # → access_token + refresh_token
run402 auth invite-user --email [email protected] --redirect https://my-app.run402.com/cb --admin true
run402 auth set-password --token <bearer> --new <pwd> # change | reset | set
run402 auth passkey-login-options --app-origin https://my-app.run402.com
run402 auth passkey-login-verify --challenge <id> --response '<json>'
run402 auth providersMagic-link tokens are single-use, expire in 15 min, and are rate-limited. The access_token works as apikey for user-scoped REST calls subject to RLS. Use run402 auth settings --preferred passkey --require-admin-passkey true to require eligible passkey auth for project_admin sessions.
For browser-side flows (PKCE, Google OAuth, refresh-token rotation), see <https://docs.run402.com/llms-cli.txt>.
run402 subdomains claim my-app # → https://my-app.run402.com
run402 subdomains list
run402 subdomains delete my-app --confirm
run402 domains add example.com my-app # → DNS records to set
run402 domains status example.com # poll until active
run402 domains list
run402 domains delete example.com --confirmSubdomain auto-reassignment: claim once. Every subsequent run402 sites deploy-dir to the same project automatically points the subdomain at the new deployment. The deploy response includes subdomain_urls showing what got reassigned. No re-claim needed.
For agents that need to sign Ethereum transactions. Private keys never leave AWS KMS — there is no export, ever. $0.04/day rental + $0.000005/call. Signer creation requires $1.20 in cash credit (30 days prepaid). Non-custodial — see <https://run402.com/humans/terms.html#non-custodial-kms-wallets>.
run402 contracts provision-signer --chain base-mainnet [--recovery-address 0x…]
run402 contracts list-signers
run402 contracts get-signer <signer_id> # metadata + live balance + USD value
run402 contracts call <project_id> <signer_id> --to 0x… \
--abi @abi.json --fn transfer --args '["0x…", "1000000"]' \
[--value-wei 0] [--idempotency-key <k>]
run402 contracts read --chain base-mainnet \
--to 0x… --abi @abi.json --fn balanceOf --args '["0x…"]'
run402 contracts status <call_id>
run402 contracts drain <signer_id> --to 0x… --confirm # safety valve (works on suspended)
run402 contracts delete <signer_id> --confirm # 7-day KMS deletion windowTier is per organization, not per project. run402 tier set is organization-wide — one subscribe / renew / upgrade applies to every project in the organization. api_calls and storage_bytes are pooled across every project in the organization, and across every wallet linked to it via run402 billing link-wallet. Quota-denial error envelopes include details.scope: "organization" | "project" — "organization" for the pooled path, "project" for the orphan fallback (project whose organization row was purged but cascade has not yet run).
run402 tier set prototype # FREE on testnet (verifies x402 setup)
run402 tier set hobby # $5 / 30 days
run402 tier set team # $20 / 30 days
run402 tier status # pool_usage sums across the whole organization
# Pay with Stripe instead of x402
run402 billing create-email [email protected]
run402 billing link-wallet <org_id> <wallet> # response includes pool_implications (over_limit warning)
run402 billing checkout <org_id> --product tier --tier hobby
run402 billing checkout <org_id> --product email-pack # $5 / 10k emails (never expire)
run402 billing checkout <org_id> --product balance-topup --amount 5000000
run402 billing auto-recharge <org_id> on --threshold 2000
run402 billing balance <org-id | wallet | email> # wallet/email resolved to the organization; SIWX must be linked to it (email lookups admin-only)
run402 billing history <org-id | wallet | email>run402 tier set refetches /tiers/v1/status after the call and includes the refreshed organization-pool snapshot as status_after in the JSON output, so the new pooled api_calls / storage_bytes totals come back in one step.
After subscribing you can create unlimited projects, deploy unlimited sites, fork apps — all free with your active tier, subject to the organization-pooled api_calls and storage_bytes caps. Only image generation ($0.03/image) is per-call.
The server auto-detects the action: no tier or expired → subscribe; same tier active → renew; higher tier → upgrade (prorated refund); lower tier → downgrade (prorated refund if usage fits).
| Prototype | Hobby | Team | |
|---|---|---|---|
| Lease | 7 days | 30 days | 30 days |
| Storage | 250 MB | 1 GB | 10 GB |
| API calls | 500K | 5M | 50M |
| Functions | 5 | 25 | 100 |
| Function timeout | 10s | 30s | 60s |
| Function memory | 128 MB | 256 MB | 512 MB |
| Secrets | 10 | 50 | 200 |
| Scheduled fns | 1 / 15min | 3 / 5min | 10 / 1min |
run402 deploy apply preflights literal unified-deploy timeout, memory, cron interval, and scheduled-count values before plan/upload when caps are known; failures are structured BAD_FIELD errors. run402 tier status shows live function caps and current scheduled usage when returned.
Project-level rate limit: 100 req/sec. Exceeding returns 429 with retry_after. Each project has its own Postgres schema; cross-schema access is blocked.
Gateway v1.57 moved the lifecycle state machine from internal.projects to internal.organizations. The grace clock now ticks per organization, not per project — every project on the same organization inherits the same organization_lifecycle_state. The live data plane keeps serving the whole time; only the owner's control plane gets gated:
| State | When | What happens |
|---|---|---|
active | — | Full read/write |
past_due | day 0 | Site, REST, email keep serving. Owner gets first email. |
frozen | +14d | Control plane (deploys, secrets, subdomain claims, function upload) returns 402 with lifecycle_state / entered_state_at / next_transition_at. Site still serves. Subdomain reserved so the brand can't be claimed by another wallet. |
dormant | +44d | Scheduled (cron) functions pause. |
purged | +104d | Cascade: schemas dropped, Lambdas deleted, mailboxes tombstoned. Subdomains become claimable 14 days later. |
run402 tier set … at any point during grace reactivates the organization inline and clears every project's timers in one transaction. Each project entry also exposes:
effective_status — derived state for serving (active / past_due / frozen / dormant / archived / deleted). Use this for UX. When a single project is moderate-archived (archived_at set) or user-deleted (deleted_at set) it overrides the organization lifecycle in this field.organization_lifecycle_state — the raw per-organization state. Identical across every project on the same organization.lease_perpetual — operator escape hatch flag. When true, the organization never advances past active. Replaces the v1.56 per-project pinned. Toggle via run402 admin lease-perpetual <org_id> --enable | --disable (platform-admin only).Operator moderation actions (independent of lifecycle): run402 admin archive <project_id> [--reason "..."] sets archived_at; run402 admin reactivate <project_id> flips it back. Both are scoped to a single project — siblings on the same organization keep serving.
Hand off or move a project without redeploying — one noun, three recipient shapes. --to routes by value: a wallet is a two-party SIWX transfer (recipient completes with accept); an email is an email→org transfer (recipient completes with claim, claiming into an org they own). --to-org moves the project into another org you already own; same-actor owned-org moves complete immediately in the first gateway release. Owner-side mutations on pending wallet/email transfers freeze for the 72-hour window so the recipient reviews exactly what they will own.
# Initiate (you must currently own/admin the project). --to routes by recipient kind:
run402 transfer init --to 0xRECIPIENT --project <project_id> [--message "..."] # wallet
run402 transfer init --to [email protected] --project <project_id> [--retain-collaborator developer] # email
run402 transfer init --to-org <org_id> --project <project_id> [--message "..."] # owned org; completes immediately
# Either party can inspect the safe preview document (kind-agnostic)
run402 transfer preview <transfer_id>
# Cancel a pending transfer of any kind (any authorized party)
run402 transfer cancel <transfer_id> [--reason "..."]
# WALLET recipient completes (your wallet must equal the transfer's to_wallet)
run402 transfer accept <transfer_id>
# EMAIL recipient completes — claim into an org (omit --into to create a new org)
run402 transfer claim <transfer_id> [--into <org_id>] [--accept-retained-collaborator]
# OWNED-ORG moves have no follow-up accept/claim step in the same-actor release
# Inbox / outbox (pending rows are tagged recipient_kind)
run402 transfer list # incoming (default)
run402 transfer list --outgoingFreeze invariant. While pending, owner-side mutations against the project (deploy, secrets, custom domains, function CRUD, scheduled-function changes, mailbox config, CI bindings, project rename) return `409 PROJECT_HAS_PENDING_TRANSFER` with details.transfer_id and a next_actions[] cancel route. Data-plane traffic keeps serving. Payment-path routes (tier renew, billing) keep working. The transfer cancel route is intentionally unblocked.
What does NOT transfer: tier lease (stays with the original owner's organization; no Phase 1A proration), KMS signers (run402 contracts ... — wallet-scoped, not project-scoped), GitHub repo ownership (handle out of band), or on-chain balance on any wallet.
Billing policy. Wallet transfers support only --billing-policy migrate (default): the project moves into the recipient's organization. If the recipient has no active organization yet, the accept returns 409 RECIPIENT_ORGANIZATION_NOT_ACTIVE. Email and owned-org transfers always migrate ownership; don't send --billing-policy there.
Secrets rotation prompt. Secret VALUES are inherited at accept. The accept response returns secret_names_inherited[] (names only). The project carries a persistent secrets_rotation_advised advisory; run402 tier status surfaces it on projects[].secrets_rotation_advised. Use run402 secrets set <project> <name> <value> to rotate every inherited name; the advisory clears once every one has been re-written.
run402 tier status also surfaces incoming_transfers[] at the top level (each entry carries preview_path) so a single status call shows pending offers without a separate fetch.
run402 image generate "a serif logo for a coffee shop" --aspect square --output logo.png$0.03 per image. Aspects: square, landscape, portrait. Without --output, returns base64 in JSON.
run402 ai translate <id> "Hello world" --to es --context "marketing tagline"
run402 ai moderate <id> "<text>"
run402 ai usage <id>Translation requires the AI Translation add-on on the project. Moderation is free.
run402 apps browse [--tag <tag>]
run402 apps inspect <version_id>
run402 apps fork <version_id> my-clone --bootstrap '{"admin_email":"[email protected]"}'
run402 apps publish <id> --description "…" --tags todo,demo --visibility public --fork-allowed
run402 apps versions <id>
run402 apps update <id> <version_id> --description "…" --tags a,b
run402 apps delete <id> <version_id>Forking clones schema + site + functions into a new project. If the source app has a bootstrap function, it runs automatically with the variables you pass via --bootstrap. The fork response includes bootstrap_result (the function's return value) or bootstrap_error. Use apps inspect to see what bootstrap_variables an app expects.
These work on a fresh install before init — useful for evaluating Run402 or distinguishing platform problems from your own:
run402 service status # 24h/7d/30d uptime per capability, operator, deployment topology
run402 service health # per-dependency liveness (postgres, postgrest, s3, cloudfront)Don't confuse with run402 status (your organization's allowance, balance, tier, projects).
run402 message send "deploy-dir was magical, but the cdn wait-fresh timeout default felt too aggressive"Free with active tier. The team reads every message.
# 1. Set up (once per machine)
run402 init
run402 tier set prototype
# 2. Provision the project — copy the anon_key from the response into your HTML before deploying.
run402 projects provision --name my-app
PROJECT=$(run402 status | jq -r '.active_project')
# 3. Schema
cat > setup.sql <<'EOF'
CREATE TABLE IF NOT EXISTS items (
id serial PRIMARY KEY,
title text NOT NULL,
user_id uuid,
created_at timestamptz DEFAULT now()
);
EOF
run402 projects sql $PROJECT --file setup.sql
# 4. Authorization manifest
cat > manifest.json <<'EOF'
{ "version": "1",
"tables": [{ "name": "items", "expose": true, "policy": "user_owns_rows",
"owner_column": "user_id", "force_owner_on_insert": true }],
"views": [], "rpcs": [] }
EOF
run402 projects validate-expose $PROJECT --file manifest.json --migration-file setup.sql
run402 projects apply-expose $PROJECT --file manifest.json
# 5. Deploy site + claim subdomain
run402 sites deploy-dir ./dist
run402 subdomains claim my-app
# 6. Optional: a server function
run402 functions deploy $PROJECT my-fn --file fn.tsTwo payment rails work with the same wallet key:
~30 seconds. Free. No account. Every finding cites a rule and a line of evidence.