# @run402/sdk — comprehensive reference > Package: `@run402/sdk` (npm) > Wayfinder: https://run402.com/llms.txt > Sibling references: CLI at https://docs.run402.com/llms-cli.txt · MCP at https://docs.run402.com/llms-mcp.txt · HTTP at https://run402.com/llms-full.txt > Source: sdk/llms-sdk.txt in https://github.com/kychee-com/run402 The canonical agent-facing reference for the typed TypeScript SDK. Every Run402 capability is a method on a resource namespace; the CLI and MCP server are thin shims over this kernel. **The attention model, in one sentence:** everything operationally significant is a *fact*; you read facts with a *cursor* (store and echo, never parse — a stale cursor resets, it never errors); you can request *attention* at a declared guarantee (feed-visible → opt-in rules → mandatory page that climbs); and *closure* is visible on the fact itself (acks are first-writer-wins, a replay reports the ORIGINAL, and a timed-out wait RETURNS the unsettled state — silence is an answer to look at, never consent). Learn it once on any surface (rooms, events, escalations) and you have learned them all. The SDK is the recommended surface when you're authoring code. Fewer process boundaries than the CLI, typed error envelopes, identical behavior. If you're already in TypeScript, prefer this. Run402 treats people and agents as first-class principals. An agent uses its own authenticator rather than borrowing a human login; identity records who acted, while organization roles, grants, delegates, freshness, and spend policy determine authority. Founder-agent ownership and human co-ownership are both legitimate states. ## Install ```bash npm install @run402/sdk ``` Two entry points: | Import | Use when | Bundles | |---|---|---| | `@run402/sdk/node` | Running in Node 22 with local profile state, project-key cache, and allowance | Auto-loads the configured API base, active project state, local project-key cache, and signs x402 payments from the selected allowance or opaque signer. Includes `r.actions.run(...)`, `r.up(...)`, `r.sites.deployDir(dir)`, `fileSetFromDir(dir)`, `loadDeployManifest(path)`, `normalizeDeployManifest(input)`, and `resolveRun402TargetProfile()`. | | `@run402/sdk/config` | Authoring typed deploy configs that normalize to `ReleaseSpec` | Browser-safe helper descriptors and types: `defineConfig`, `dir`, `file`, `sqlFile`, `nodeFunction`, `Run402ExecutionMode`. No filesystem, env, credential, or network side effects. | | `@run402/sdk/node/config` | Loading explicit executable deploy configs in Node | Re-exports config helpers plus `loadDeployManifest`, `loadExecutableDeployConfig`, and `normalizeDeployManifest`. | | `@run402/sdk` | Isomorphic — Node, Deno, Bun, V8 isolates. No filesystem. | Bring your own `CredentialsProvider`. | Node SDK requests include bounded client metadata through `Run402-Client`, such as `surface="sdk", version="3.7.14", sdk="3.7.14"`. CLI-created SDK instances use `surface="cli"`. The header is semantic version/surface metadata only: no cwd, executable path, package manager, wallet/org/project ids, secrets, or install confidence. The isomorphic entry omits the header by default to avoid browser/CORS surprises; only callers that explicitly pass `clientMetadata` opt in. ## Quick start (Node) ```ts import { run402 } from "@run402/sdk/node"; const r = run402(); const project = await r.projects.provision({ tier: "prototype" }); await (await r.project(project.project_id)).assets.put("hello.txt", { content: "hi" }); ``` That's it — credentials are read, x402 payments are signed, results are typed. ## Public Buzz/Nostr identity links (`r.identityLinks`) `identityLinks` represents public Nostr attribution for human and agent principals through one common shape discriminated by `proof_protocol`. One principal may have multiple active subjects, while an active subject is linked to only one principal. It never accepts a Nostr secret and never affects authentication, authorization, organization ownership, grants, delegates, payment, or transfers. ```ts import { readFile } from "node:fs/promises"; const challenge = await r.identityLinks.nostr.begin({ nostrPubkey: "npub1...", // canonical npub or 64-char lowercase hex visibility: "public", // deliberately explicit idempotencyKey: crypto.randomUUID(), }); // Give challenge.proof_content to Buzz as one standalone kind-1 message. // Buzz owns Nostr signing; the SDK never sees its private key. const rawEvent = await readFile("buzz-event.json", "utf8"); const proof = await r.identityLinks.nostr.complete({ rawEvent }); const links = await r.identityLinks.list(); const publicProof = await r.identityLinks.getProof(proof.identity_link_id); await r.identityLinks.revoke(proof.identity_link_id); ``` `begin`/`complete` is the agent protocol. It requires a credential provider with `signPersonalMessage(message)`, checks that the returned EOA matches the gateway payload, verifies the exact kind-1 Nostr event locally, and never sees a Nostr secret. Human creation is browser-canonical at : direct human session, fresh passkey, explicit public-correlation disclosure, and released Buzz approval, with no terminal/raw-event/resource-id/passkey choreography. `list()` preserves every active and revoked record and its proof protocol. Public proof reads expose common subject/principal/lifecycle fields, the immutable Nostr event, and either agent EOA evidence or the Run402-attested human-session verification statement. Human link and organization membership revocation are independent. Current `whoami`, project, deploy-operation/release, and transfer response types include linked-identity and immutable action-time actor snapshots. Render unknown future principal/authenticator/authority kinds as data. A snapshot is historical attribution, not a live authorization decision. ## Buzz community control plane (`r.buzz`) `await r.buzz.status()` capability-detects `run402.buzz-control-plane.v1` plus `capabilities.human_adoption_offers` and returns independent skill/offer/adoption/community/enrollment state without manufacturing state on an older gateway. The canonical ownership alias is `r.buzz.offerAdoption`; typed `humanAdoptionOffers.create/get/cancel/createAttempt` separates durable inert offers from short human/session-bound attempts. A completed poll exposes the terminal consent receipt, public human identity link, and ordinary owner membership as distinct typed effects. The membership is the only organization-authority source; identity-link and membership revocation are independent and neither rewrites the completed receipt. `r.buzz.adopt` and `humanAdoptions` remain direct advanced compatibility. Other goal aliases are `r.buzz.install` and `r.buzz.enroll`. The SDK generates mutation idempotency keys when omitted and rejects nested secret-shaped fields before network access. It never signs a Buzz event. Human adoption requires a directly authenticated human completion; community activation accepts an ordinary Buzz kind-1 owner/admin approval and lets Run402 verify released NIP-11/NIP-43 relay evidence. Run402 owns descriptor discovery, policy/default revisions, and revocation, so Buzz itself remains unchanged. Agent enrollment can create only finite grants on named existing projects. It never creates agent org membership, future-project, owner, delegate, or payment authority. Installation revocation leaves existing grants unchanged; enrollment revocation affects only its linked grants; drift is advisory. Buzz failures preserve the gateway's stable code, exact repair `field`, complete `nextActions`, and `safeToRetry`; there is no client-synthesized generic edit fallback, and an unchanged call is retried only when the gateway marks it safe. Before creating an x402 payment payload, the Node entry confirms USDC with bounded retry/backoff and independent RPC failover on Base and Base Sepolia. RPC exhaustion is never treated as a zero balance. Branch on the exported `X402BalanceError.code`: `X402_RPC_TIMEOUT`, `X402_RPC_RATE_LIMITED`, and `X402_RPC_UNAVAILABLE` are pre-payment failures with `safeToRetry === true` and `mutationState === "not_started"`; `X402_INSUFFICIENT_FUNDS` means the relevant balance reads succeeded and the confirmed funds do not cover any accepted requirement. After a retryable preflight failure, the next request refreshes only mutable RPC balance state while retaining the originally selected signer and payer provenance. Error details contain provider indexes and failure classes, never RPC credentials, wallet keys, or signed proofs. ### Payment signer selection (Node) Authentication and payment are separate authorities. A custom `credentials` provider controls API authentication; the x402 payer is resolved exactly once in this order: 1. `paymentSigner` — an explicit async EVM signer provider (KMS/HSM friendly). 2. `allowancePath` — an explicit local allowance file. 3. `credentials.readAllowance()` — when a supplied provider implements it. 4. The Node default provider's active-profile allowance — only when the caller did not supply a custom credentials provider. Once a source is selected, the SDK never falls back to the ambient/global wallet. `paymentSigner` and `allowancePath` together throw `PAYMENT_SOURCE_CONFLICT`. Passing both `credentials` and `allowancePath` is valid: auth uses `credentials`, while payment intentionally uses that file. `fetch` still takes precedence over built-in paid fetch, and `disablePaidFetch: true` disables automatic payment entirely. An opaque signer returns only its public payer address and signing operation; raw keys and replayable payment authorizations do not cross the provider boundary: ```ts import { run402, type CredentialsProvider, type EvmPaymentSigner, type EvmPaymentSignerProvider, type PaymentPublicClient, type X402PaymentNetwork, } from "@run402/sdk/node"; declare const sessionCredentials: CredentialsProvider; declare function kmsSignerFor( network: X402PaymentNetwork, publicClient: PaymentPublicClient, ): Promise; const paymentSigner: EvmPaymentSignerProvider = { async getSigner({ network, publicClient }) { return kmsSignerFor(network, publicClient); // address + signTypedData }, }; const r = run402({ credentials: sessionCredentials, paymentSigner }); const payer = await r.paymentPayer(); // { source: "payment_signer", rail: "x402", payers: [{ address, network }, ...] } ``` The provider may return `null` for an unsupported Base network. Paid-fetch initialization is lazy and retries after missing/recoverable local state, so a long-lived client can start paying after its selected allowance/provider becomes available without being reconstructed. `r.paymentPayer()` initializes the selected source if necessary and returns only its source, rail, public address(es), and network(s); it never returns a key, signed authorization, or replayable proof. It returns `null` when automatic paid fetch is disabled, a custom `fetch` owns payment, or the selected source is not currently available. ### Buy arbitrary x402 URLs (Node) `r.pay.fetch(url, init?, options?)` is the canonical buyer surface for an arbitrary x402-priced HTTP endpoint. It passes unpriced endpoints through, defaults `maxUsdMicros` to `100_000` ($0.10), forwards an optional `Idempotency-Key`, and returns the response together with a faithful receipt: ```ts import { run402 } from "@run402/sdk/node"; const r = run402(); const result = await r.pay.fetch( "https://seller.example/translate", { method: "POST", body: JSON.stringify({ text: "hello" }) }, { maxUsdMicros: 50_000, idempotencyKey: "translation:1", requireReceipt: true, }, ); console.log(result.outcome, result.payment, await result.response.json()); ``` `payment` is `null` when no payment was required or when an already-used proof confirms that a prior ambiguous request settled but the target cannot return a transaction reference. Otherwise it includes settlement, movement/replay, delivery, offer, merchant-receipt, signer-relationship, policy, and raw-evidence fields. Set `requireReceipt: true` to require a verified wallet-rooted offer before payment and a matching receipt afterward. The buyer verifies the exact URL, scheme, network, asset, atomic amount, recipient, validity, settlement, payer, transaction, and signer relationship. Branch on `PaymentBuyerError.code`: `PAYMENT_EXCEEDS_MAX`, `PAYMENT_WALLET_UNFUNDED`, `PAYMENT_NETWORK_UNSUPPORTED`, exact Run402 pending/drain/destination/fence/ lifetime/key-reuse codes, or `PAYMENT_SETTLEMENT_FAILED`. The error preserves `fundsMoved`, `paymentId`, intent/delivery facts, and canonical `nextActions`. Successful results preserve `paymentId`, `deduplicated`, `fundsMoved`, `delivery`, `settledAt`, and `intentState` when supplied. Required policy fails before signing with `MERCHANT_RECEIPT_REQUIRED` when no eligible offer remains. A post-settlement evidence failure throws `PaymentPolicyError` with `MERCHANT_RECEIPT_UNAVAILABLE`, the upstream `Response`, complete commerce result, true funds-moved/mutation state, and one canonical `retry` or `reconcile_payment` action. Never authorize a second payment to recover a receipt. `payFetchResultToJson` renders the complete snake_case `x402-commerce-result.v1` envelope. For an ambiguous transport failure, retry the identical request on the same SDK instance with the same idempotency key. This buyer keeps the signed proof only in memory and re-presents that exact proof; it never mints a second authorization. An upstream used-proof response becomes `outcome: "already_settled"` and `replay: true`. Across a fresh process, a Run402 managed/deployment host can recover a caller-keyed intent by repeating the same request with the same payer and key. Trusted pending requires status 409, the exact code and reserved header, the same payment-bearing origin, redirects disabled, HTTPS, and an exact Run402 DNS-label match. Custom, arbitrary, lookalike, and redirected hosts remain ambiguous. `PAYMENT_CALLER_IDENTITY_NOT_ACTIVE` is a rollout fail-closed response. Keep the same key and retry after caller identity is activated; removing the key to force a proof-only attempt changes the contract and is never a recovery step. Raw HTTP interoperability follows the same protocol: 1. Send the intended request with a stable `Idempotency-Key`. 2. On 402, base64url-decode `PAYMENT-REQUIRED`, verify its exact scheme, network, asset, atomic amount, recipient, and your local spend ceiling. 3. Sign one x402 payload and retry the same request with that base64url JSON in `PAYMENT-SIGNATURE` (`X-PAYMENT` for v1). Do not follow redirects with a payment proof. 4. On success, base64url-decode `PAYMENT-RESPONSE` and require `success: true`, a non-empty `transaction`, and the expected network before reporting funds moved. 5. If the signed request loses its response, retain and re-present the same proof for the same intent. Never create a fresh proof until the first settlement is reconciled. A used-proof 402 can establish `already_settled`, but without a settlement header it is not a transaction receipt. ### Automatic x402 attempt recovery (Node) The Node entry tracks each automatic x402 payment across the provider-dispatch boundary. If setup, challenge handling, or signing fails before a payment-bearing request is sent, it throws `PaymentAttemptError` with `mutationState: "not_started"` and `safeToRetry: true`. Check `retryable` separately: persistent local-journal corruption is safe from duplicate payment but requires repair instead of an automatic retry. If the signed request may have reached the target but no reliable result returns, it reports `mutationState: "ambiguous"`, `safeToRetry: false`, and `reconcile_payment` / `poll` next actions. Generic automatic requests must not be blindly retried; `r.pay.fetch` is the deliberate exception because the same live SDK instance retains and re-presents the original proof. Every challenged payment gets a stable `paymentAttemptId`. A redacted intent is written atomically under the active profile's mode-0700 `payment-attempts/` directory before provider dispatch; individual records are mode 0600. Inspect them with `readPaymentAttempt(id)` or `listPaymentAttempts({ limit })`. Trusted pending records use `state: "intent_pending"` and may include `payment_id`, retry timing, and a SHA-256 caller-key digest. Records never contain a raw caller key, URL path/query, request body, header, wallet key, signature, signed authorization, provider proof, or raw cause. The SDK sends `X-Run402-Payment-Attempt-Id` only on the payment-bearing request so a compatible target can correlate its logs. Redirects are disabled for that signed request, preventing both the correlation id and signed payment authorization from reaching a redirect target. A caller may supply a canonical `pat_` id only when it is new; the SDK reserves it atomically across processes, and an id already present in the journal fails closed with `X402_ATTEMPT_ID_ALREADY_EXISTS` before any network request. Generic automatic payment retries require reconciliation and a fresh authorized attempt; only `r.pay.fetch` may re-present its in-memory proof for an identical request. Malformed reserved-header values fail locally with `INVALID_PAYMENT_ATTEMPT_ID`; they are never replaced with an id that could authorize a new payment. Repo-level deploy through the same SDK action runner used by `run402 up`: ```ts import { Run402Action, run402 } from "@run402/sdk/node"; const r = run402(); await r.up({ name: "my-app" }, { approval: "yes" }); const provision = await r.actions.run({ type: Run402Action.ProjectsProvision, name: "my-app", }); ``` Typed deploy config loop: ```ts await r.up({ manifest: "run402.deploy.ts" }, { mode: "check" }); const reviewed = await r.up({ manifest: "run402.deploy.ts" }, { mode: "plan" }); await r.up( { manifest: "run402.deploy.ts" }, { mode: { kind: "applyReviewed", planId: reviewed.result?.plan?.plan_id ?? "", planFingerprint: reviewed.result?.plan?.plan_fingerprint ?? undefined, }, }, ); ``` For a self-hosted Run402 Core Gateway, run `run402 init --api-base=http://my-core:4020` once. The Node SDK then targets that API base by default; explicit `run402({ apiBase })` still wins. App build scripts should use `resolveRun402TargetProfile()` instead of parsing `target.json` or local project-key cache files: ```ts import { resolveRun402TargetProfile } from "@run402/sdk/node"; const target = resolveRun402TargetProfile({ requiredTarget: "core", requireProject: true, requireAnonKey: true, }); console.log(target.apiBase, target.projectId, target.anonKey); ``` For app-specific legacy env names, pass aliases: ```ts import { resolveRun402TargetProfile } from "@run402/sdk/node"; resolveRun402TargetProfile({ envAliases: { projectId: ["MY_APP_PROJECT_ID"], anonKey: ["MY_APP_ANON_KEY"], }, }); ``` ## Quick start (isomorphic / sandbox) ```ts import { Run402, type CredentialsProvider } from "@run402/sdk"; const credentials: CredentialsProvider = { async getAuth() { return { Authorization: `Bearer ${session.token}` }; }, async getProject(id) { return session.projects[id] ?? null; }, }; const r = new Run402({ apiBase: "https://api.run402.com", credentials, }); ``` The `CredentialsProvider` interface has two required methods (`getAuth`, `getProject`) plus optional ones for hosts that want full sticky-default behavior (`saveProject`, `updateProject`, `removeProject`, `setActiveProject`, `getActiveProject`, `readAllowance`, `saveAllowance`, `createAllowance`, `getAllowancePath`). ## Mental model The SDK is the canonical kernel. A single typed `Run402` class with one namespace per resource group (`r.projects`, `r.assets`, …). The hero apply primitive is `r.project(id).apply(spec)`; there is no public `r.deploy` surface. Every method: - Takes typed parameters (TS interfaces in `*.types.ts`) - Returns a typed `Promise` - Throws a typed subclass of `Run402Error` on failure - Never calls `process.exit` The MCP server's tools and the CLI's subcommands are argv-/schema-parsing wrappers around these methods. They share the configured API target, active project state, allowance, and local project-key cache so target selection and credentials carry across surfaces without treating cached keys as project inventory. ### Action runner (`@run402/sdk/node`) The Node entry owns recursive agent actions. The CLI command `run402 up` is only a flag parser around this surface. ```ts export const Run402Action = { ProjectsProvision: "projects.provision", TierSet: "tier.set", Up: "up", } as const; export type Run402ActionType = typeof Run402Action[keyof typeof Run402Action]; type Run402ActionInput = | { type: typeof Run402Action.ProjectsProvision; name?: string; tier?: "prototype" | "hobby" | "team"; orgId?: string; idempotencyKey?: string } | { type: typeof Run402Action.TierSet; tier: "prototype" | "hobby" | "team"; idempotencyKey?: string } | { type: typeof Run402Action.Up; source?: string; dir?: string; manifest?: string; projectId?: string; name?: string; tier?: "prototype" | "hobby" | "team"; orgId?: string; idempotencyKey?: string; verifyOnly?: boolean; propagationBudgetSeconds?: number; propagationWait?: boolean; }; type Run402ExecutionMode = | "apply" | "check" | "printSpec" | "plan" | { kind: "applyReviewed"; planId: string; planFingerprint?: string }; ``` `r.actions.run(input, opts)` returns `{ action, mode, dry_run, target, steps, result }`. `r.up(input, opts)` is equivalent to `actions.run({ type: Run402Action.Up, ...input }, opts)`. `Run402Action.Up` behavior: - Discover `run402.deploy.json`, then `app.json` under `dir` / cwd; explicit `manifest` wins. - Validate the deploy manifest and referenced local files before allowance, tier, project, link, upload, or deploy mutations. - Resolve project as explicit `projectId`, then `.run402/project.json`, then manifest `project_id`, then approved project creation from `name`, then approved active-project fallback. - For app manifests with `verify.http[]`, fetch verification URLs after apply and write per-check details to `result.app_result.verification.http[]`. Fresh edge sentinel misses (`x-run402-edge` or JSON codes such as `SUBDOMAIN_NOT_CONFIGURED`) and non-settled deploy-resolve diagnostics become `propagation_pending` instead of permanent failures while the binding is fresh. - Set `propagationBudgetSeconds` to control the wait for edge convergence (default 120). Set `propagationWait: false` to return `status: "propagation_pending"` immediately with `verify.status`, `propagation_wait_ms`, warnings, `next_action`, and diagnostic `edge_propagation` / `resolve` payloads. - Set `verifyOnly: true` to rerun app HTTP verification without upload, deploy, resource mutation, or project creation. This is the SDK equivalent of `run402 up verify`. - `name` is only project creation/link metadata. It is not a manifest field and never renames an existing project. - Write `.run402/project.json` atomically when `up` needs to remember an explicit/created/active project. Schema: `{ schema_version: "run402.workspace-project.v1", project_id, name?, target?, created_at, updated_at? }`. - On Run402 Cloud, recursively ensure allowance and tier (default bootstrap tier `prototype`) only when missing; existing active tiers are not downgraded or renewed just because `up` ran. - On Run402 Core, skip Cloud allowance/tier prerequisites and fail closed if no Core project is selected. - Delegate the final deployment to `r.project(id).apply(spec, opts)`. Action options: - `mode: "check"` validates local manifest/config and file references only. No gateway calls, uploads, prerequisite mutations, or local writes. - `mode: "printSpec"` returns the normalized `ReleaseSpec` in `result.spec`; CLI prints only that JSON. - `mode: "plan"` creates a gateway-reviewed non-deploying plan. It returns `result.plan.plan_id`, `plan_fingerprint`, `plan_expires_at`, warnings, diff, and same-surface `next_actions[]`. - `mode: { kind: "applyReviewed", planId, planFingerprint? }` applies only when the reviewed plan still matches. The SDK verifies before upload and commit. - `approval: "never" | "yes" | { mode: "interactive"; approve(request) }` gates recursive prerequisites and local link writes. SDK default is `"never"`; CLI maps `-y/--yes` to `"yes"` and TTY prompts to interactive approval. If allowance/tier/project/link are already configured, `r.up()` can run the requested deploy without approval. - `autoPrerequisites` defaults to `true` for `up` and `false` for direct actions. - `idempotencyKey` supplies a root key; recursive gateway mutations derive child keys from it. Legacy `dryRun: true` remains an action-graph compatibility mode. For typed deploy config, use `mode: "check"` for local validation and `mode: "plan"` for gateway review. ### Typed deploy config (`@run402/sdk/config`) Typed configs compile to the same SDK-native `ReleaseSpec` as JSON manifests. Raw `ReleaseSpec` slices remain valid for fields without helpers. ```ts import { defineConfig, dir, file, nodeFunction, sqlFile } from "@run402/sdk/config"; export default defineConfig(({ env }) => ({ project: env.required("RUN402_PROJECT_ID"), database: { migrations: [sqlFile("db/001_init.sql")] }, site: { replace: dir("dist"), public_paths: { mode: "implicit" }, }, functions: { replace: { api: nodeFunction("dist/functions/api.js", { deps: ["zod@^3"], requireAuth: true, }), }, }, assets: { put: [{ key: "logo.svg", source: file("assets/logo.svg", { contentType: "image/svg+xml" }) }], }, secrets: { require: ["OPENAI_API_KEY"] }, })); ``` Helper semantics: - `defineConfig(config)` preserves type inference. The export may be an object or `(context) => object`; context has `manifestPath`, `rootDir`, and `env`. Use `env.get("NAME")`, `env.required("NAME")`, or `env.RUN402_*` property reads; executable manifest loads report `config.env_accessed` metadata for those reads. - `dir(path, { prefix?, ignore?, includeSensitive? })` resolves from the config directory, walks deterministically by normalized `/` path, skips sensitive defaults unless opted in, rejects symlinks, infers content type, and produces local directory descriptors consumed by the Node normalizer. - `file(path, { contentType? })` produces a local file source; the Node normalizer reads bytes later and keeps secrets out of config examples. - `sqlFile(path, { id?, name?, checksum?, transaction? })` derives `id` from the filename when omitted and keeps checksum/transaction metadata stable. Pass `{ name: "seed" }` for generated/idempotent SQL; the SDK compiles `_` from the post-build SQL bytes, changed content applies once under a new id, and unchanged re-deploys noop. SQL declared with `name` MUST be idempotent because it re-runs whenever content changes against a database where prior versions may already exist. - `nodeFunction(path, opts)` creates a Node 22 `FunctionSpec` from built JavaScript. TypeScript function sources (`.ts`, `.tsx`, `.mts`, `.cts`) currently fail locally with `TYPESCRIPT_FUNCTION_REQUIRES_BUNDLE`; build them first and point at `.js`. Executable trust policy: - `loadDeployManifest("run402.deploy.ts")` can load `.ts/.mts/.cts/.js/.mjs/.cjs` configs only when the path is explicit. - Auto-discovery for `up` checks only data manifests: `run402.deploy.json`, then `app.json`. - If a repo only contains `run402.deploy.ts`, `up` fails with `EXECUTABLE_CONFIG_REQUIRES_EXPLICIT_MANIFEST` and a next action to rerun with `--manifest run402.deploy.ts --check`. - `--check` / `mode: "check"` and `--print-spec` / `mode: "printSpec"` are local-only; use `--plan` / `mode: "plan"` for gateway policy, quota, cost, secret existence, missing-content, and base-release facts. The runner never executes arbitrary gateway-authored `next_actions[].command`; it uses its own fixed action graph (`allowance`, `tier`, `projects.provision`, workspace link, deploy apply). ### Casing in returned shapes Two casings coexist by design — classify a field by the shape it belongs to: - Raw API result shapes preserve the gateway's snake_case fields. Examples: `ProvisionResult.project_id`, `ProvisionResult.anon_key`, `ProvisionResult.service_key`, `ProvisionResult.schema_slot`, `ProjectInfo.project_id`, `ProjectSummary.lease_expires_at`, `UsageReport.api_calls`, `SchemaReport.schema`. These mirror the HTTP response bodies one-to-one. - SDK-specific helper shapes use camelCase. Examples: `AssetRef.cdnUrl` / `AssetRef.cacheKind` / `AssetRef.contentSha256`, `Run402DeployError.safeToRetry` / `operationId` / `mutationState`, every `DeployEvent` variant's discriminator (`type`, plus per-variant fields like `releaseId`, `urls`). The split is stable across the `3.x` line. CI fails any TypeScript-fenced example that accesses a field that does not exist on the actual type. Reference tables below use plain code fences (no `ts`) — they document the type surface for visual scanning, are not runnable, and are exempt from type-checking. ### Timestamp Convention Public API and SDK response timestamps are ISO-8601 strings, never JavaScript `Date` objects or numeric epochs. Absolute instants use fields such as `created_at`, `updated_at`, `expires_at`, `lease_expires_at`, `timestamp`, and `ingestion_time`; nullable means the gateway state is genuinely absent. Numeric time values are reserved for relative durations or local measurements and carry units in the name (`expires_in`, `duration_ms`, `elapsedMs`, `ttl_seconds`). ## Project credentials After `r.projects.provision(...)`, the result has `project_id`, `anon_key`, `service_key`, `schema_slot`. The Node entry's credentials provider auto-saves keys to the active profile's local project-key cache (`credentials/project-keys.v1.json`). Legacy `projects.json` files are one-way migration input only. - `anon_key` — read-only by default; safe in browser HTML. RLS policies apply. - `service_key` — server-side admin. Never embed in browser code. Neither key expires. Lease enforcement happens server-side. Server project reads such as `r.projects.list()`, `r.projects.get(id)`, and `r.projects.use(id)` authorize with the current principal and do not require local cache membership. ### Rotatable project credentials — the replacement for the derived pair The `anon_key`/`service_key` above are DERIVED from the platform signing key: they never expire, cannot be revoked individually, and the signing key behind them is being retired. A **project credential** (`r402_…`) is a ROW instead — named, listable, expiring, individually revocable — and several may be live per kind at once, which is exactly how you rotate with no downtime. `r.credentials` carries both surfaces, and they do not overlap: `r.credentials.` is the gateway's rows, `r.credentials.projectKeys.` is the local cache on this machine. ```ts // Am I still on the retiring key? Only project.read is needed, so an agent can // check its own posture. retirement.deadline is ALWAYS null on purpose — // retirement is condition-gated, never a date. Read retirement.gated_on. const posture = await r.credentials.status(projectId); // { state: "legacy" | "rotatable", ... } // Mint one. The secret is returned EXACTLY ONCE; there is no read that // returns it. Persist it before doing anything else. const cred = await r.credentials.issue(projectId, { kind: "service", name: "ci-deploy" }); cred.secret; // r402_… — once, and never again await r.credentials.list(projectId, { includeRevoked: true }); // metadata only await r.credentials.rotate(projectId, cred.credential_id); // replace in one tx; new secret once await r.credentials.revoke(projectId, cred.credential_id, { reason: "leaked in a log" }); ``` Zero-downtime rotation is `issue` a second live credential → deploy it → `revoke` the first. `rotate()` collapses that into one transaction (same name, records `replacement_of`) and is the right call when the old secret is already compromised. `issue`/`rotate`/`revoke` require owner membership on the project's owning org PLUS a fresh step-up, and a delegate can NEVER satisfy them — a scoped agent credential must not be able to escalate itself into a permanent root. ```ts // The one exception, and the cold-restart recovery path: an agent that lost // local state but still holds a delegate mints a SHORT-LIVED token with no // human present. No step-up, because there is nobody to prompt; it expires, // so it cannot become a durable root. const token = await r.credentials.mintToken(projectId); // { secret, expires_in, … } ``` Never write an `issue` / `rotate` / `mintToken` response to a result cache, tmp file, or expansion handle — they are secret-bearing, like `provision` and project keys. ```ts await r.projects.use(projectId); // make this the active project const keys = await r.projects.keys(projectId); const info = await r.projects.info(projectId); ``` ## Rehearsals, snapshots, and branches For database-bearing deploys, rehearse before commit. Create a reviewed plan with `r.project(id).apply.plan(spec, { mode: "reviewedPlan" })`, upload missing bytes, then call `r.project(id).apply.rehearse(plan.plan.plan_id, { teardown: "on_pass" })`. The rehearsal creates a contained branch, applies migrations and checks there, and returns `report.status`, migration/check results, branch URL, snapshot id, and `next_actions`. A passing report does not mutate the source project until you commit the original plan. Manual restore points are exposed as `r.snapshots` and the scoped `r.project(id).snapshots`. `restorePlan()` is the no-mutation loss-statement step; `restore()` requires the confirm token from the plan and performs the atomic offline-materialize-then-flip restore. Auth users/passkeys are restored only with `{ includeAuth: true }`; sessions and tokens are never restored. Contained branch projects are exposed as `r.branches` and `r.project(id).branches`. Branch creation can capture a fresh snapshot or start from an existing snapshot, saves returned branch keys to the Node credential cache when available, defaults email to sandboxed, keeps cron off unless requested, and expires by TTL. ## Portable project archives Portable archives are the SDK path for proving Cloud is the easiest place to start, not the only place the supported application can run. They are a vendor-lock-in trust artifact and are separate from allowance/spend-cap financial-risk controls. Archive v1 exports the supported Run402 Core runtime slice of a Cloud project, not an entire Cloud project. Node happy path: ```ts import { writeFile } from "node:fs/promises"; import { importArchiveToCore, inspectArchive, run402, verifyArchive, } from "@run402/sdk/node"; const r = run402({ surface: "cli" }); const exported = await r.archives.export("prj_...", { scope: "portable-runtime-v1", auth: "stubs", consistency: "pause-writes", onProgress: (event) => console.log(JSON.stringify(event)), }); await writeFile("./project.r402ar", exported.bytes); const inspected = await inspectArchive("./project.r402ar"); const verified = await verifyArchive("./project.r402ar"); if (!verified.ok) { console.error(JSON.stringify(verified.diagnostics)); } const imported = await importArchiveToCore({ archivePath: "./project.r402ar", name: "imported-project", envFile: "./required.env", requireRunnable: true, }); console.log({ inspected, imported }); ``` The isomorphic SDK exposes `r.archives.create(projectId, opts)`, `get(projectId, archiveId)`, `wait(projectId, archiveId, opts)`, `download(projectId, archiveId)`, and `export(projectId, opts)`. The Node entry upgrades `r.archives` to add local `inspect(archivePath)`, `verify(archivePath)`, and `importToCore(opts)`, and also exports standalone `inspectArchive`, `verifyArchive`, `importArchiveToCore`, and `readEnvFile`. Archive progress events and diagnostics use stable agent fields: `event`, `stage`, `resource_type`, `resource_id`, `project_id`, `status`, `completed_units`, `total_units`, `code`, `message`, `next_action`, `retryable`, and safe `context`. `verify` is offline and checks integrity and compatibility only; archives remain untrusted input. Core import verifies before mutation, creates a new Core project only, and accepts required secret values via `envFile` or `secretValues`. Secret values, auth credentials, logs, billing/allowance state, and managed Cloud operations are never exported in v1. ## Errors All failures throw subclasses of `Run402Error`. Every subclass carries a stable `kind` string discriminator and an `isRun402Error` brand. Branch with the exported type guards (or by comparing `e.kind`) — NOT with `instanceof X`: identity-based checks fail silently when the consumer's runtime holds a different copy of the SDK (duplicate npm installs, bundler chunk splits, ESM/CJS interop, V8-isolate realms). `instanceof` continues to work for single-copy single-realm callers as a back-compat path. | Class | `kind` | When | Notable fields | |---|---|---|---| | `PaymentRequired` | `"payment_required"` | HTTP 402 | x402 payment requirements in `body` | | `ProjectNotFound` | `"project_not_found"` | Project ID not in the credential provider | `projectId` | | `Unauthorized` | `"unauthorized"` | HTTP 401 / 403 — authentication missing or invalid | — | | `NotAuthorizedError` | `"not_authorized"` | HTTP 403 with `code: "NOT_AUTHORIZED"` — org-owned control-plane denial (gateway v1.77+): authenticated, but the principal lacks the required org membership/role or per-project grant | `requiredRole`, `requiredCapability`, `reason`, `action` | | `ApiError` | `"api_error"` | Other non-2xx responses | `status`, `body` | | `NetworkError` | `"network_error"` | Fetch rejected with no HTTP response | `cause` | | `PaymentAttemptError` | `"payment_attempt_error"` | Automatic x402 setup/signing/submission failed | `code`, `phase`, `paymentAttemptId`, `providerStarted`, `safeToRetry`, `mutationState`, `nextActions` | | `PaymentBuyerError` | `"payment_buyer_error"` | Bounded arbitrary-URL x402 buying failed | `code`, `fundsMoved`, `details`, `safeToRetry`, `nextActions` | | `LocalError` | `"local_error"` | Local-host issues (filesystem, signing) | `cause` | | `X402BalanceError` (Node entry) | `"local_error"` | x402 USDC balance preflight could not be confirmed, or confirmed funds are insufficient | `code`, `safeToRetry`, `mutationState="not_started"`, `details`, `nextActions` | | `Run402DeployError` | `"deploy_error"` | Structured envelope from the deploy state machine (v1.34+) | `code`, `phase`, `operationId`, `safeToRetry`, `mutationState`, `nextActions` | | `TransferFreezeError` | `"transfer_freeze"` | HTTP 409 with `code: "PROJECT_HAS_PENDING_TRANSFER"` from the v1.59 transfer-freeze middleware blocking owner-side mutations during a pending transfer | `transferId`, `projectId`, `cancelPath`, `previewPath` | The exported `Run402ErrorKind` union type (`"payment_required" | "payment_buyer_error" | "project_not_found" | "unauthorized" | "not_authorized" | "api_error" | "network_error" | "payment_attempt_error" | "local_error" | "deploy_error" | "transfer_freeze" | "step_up_required" | "operator_approval_required"`) supports exhaustive `switch` statements with TypeScript exhaustiveness checking. ```ts import { run402, withRetry, isPaymentRequired, isDeployError, type ReleaseSpec, } from "@run402/sdk/node"; declare const spec: ReleaseSpec; const r = run402(); try { const release = await withRetry( async () => (await r.project(spec.project)).apply(spec, { idempotencyKey: "deploy-2026-05-01" }), { attempts: 3, onRetry: (_e, attempt, delayMs) => process.stderr.write(`retry ${attempt} in ${delayMs}ms\n`), }, ); console.log(release.urls); } catch (e) { if (isPaymentRequired(e)) { // narrowed to PaymentRequired — read e.body for the x402 quote } else if (isDeployError(e)) { // narrowed to Run402DeployError — log the structured envelope for triage process.stderr.write(JSON.stringify(e) + "\n"); } else throw e; } ``` `Run402DeployError.code` is one of `MIGRATION_FAILED`, `MIGRATION_CHECKSUM_MISMATCH`, `BASE_RELEASE_CONFLICT`, `PAYMENT_REQUIRED`, `SCHEMA_SETTLE_TIMEOUT`, `ACTIVATION_FAILED`, `STORAGE_UNAVAILABLE`, `SITE_STAGE_FAILED`, `FUNCTION_BUILD_FAILED`, `CONTENT_UPLOAD_FAILED`, `INVALID_SPEC`, `MANIFEST_EMPTY`, `OPERATION_NOT_FOUND`, `MIGRATE_GATE_ACTIVE`, `INTERNAL_ERROR`, `NETWORK_ERROR`, `PROJECT_NOT_FOUND` (or any other string the gateway emits — consumers SHALL treat unknown codes as opaque). Pair it with the structured `nextActions` advisory array carried in the error body. ### Type guards and the canonical retry policy The SDK exports identity-free guards plus a single canonical "should I retry this?" function: - `isRun402Error(e)` — true for any `Run402Error` subclass instance, regardless of which SDK copy created it. - `isPaymentRequired(e)`, `isPaymentAttemptError(e)`, `isProjectNotFound(e)`, `isUnauthorized(e)`, `isNotAuthorized(e)`, `isApiError(e)`, `isNetworkError(e)`, `isLocalError(e)`, `isDeployError(e)`, `isTransferFreezeError(e)` — narrow `unknown` to the named subclass. `isPaymentAttemptError` is the automatic x402 failure guard; branch on `safeToRetry` before doing anything. `isUnauthorized` (authentication missing/invalid) and `isNotAuthorized` (org control-plane denial — authenticated but under-privileged) are distinct: the first calls for re-auth, the second for obtaining an org membership/role or grant. - `isRetryableRun402Error(e)` — encapsulates the retry policy: `e.retryable || kind === "network_error" || status in {408, 425, 429} || status >= 500`, unless the gateway explicitly sets `retryable: false`. `safeToRetry` alone is not a retry signal; it means a repeated mutation should not duplicate/corrupt state, not that lifecycle/payment/auth gates will become allowed without an action. Returns `false` for non-Run402 inputs so it's safe to call from any `catch` block. - `getQuotaScope(e)` — returns `"organization"` for pooled organization quota denials, `"project"` for the orphan-project fallback when a organization row has been purged but cascade has not yet run, and `undefined` for non-quota errors or pre-v1.46 gateways. Safe to call with any `unknown`; reads `Run402Error.quotaScope`, which is lifted from `details.scope` on the gateway envelope. - `isCiBindingRevoked(e)` — true for the CI token-exchange `binding_revoked` denial (HTTP 403): a subject-matching binding existed but was revoked (most often the project was transferred/handed off). Distinct from `access_denied` (no binding ever matched), which shares the same canonical `code: "FORBIDDEN"` — the guard reads the OAuth-style `error` field for you. The error stays an `Unauthorized` (no regression). Fix: re-run `run402 ci link github`; do NOT `set-asset-scopes` (409 on a revoked binding). Safe to call with any `unknown`. `Run402Error.toJSON()` returns a canonical envelope (`name`, `kind`, `message`, `status`, `code`, `category`, `retryable`, `safeToRetry`, `mutationState`, `traceId`, `context`, `details`, `nextActions`, `quotaScope`, `body`). `Run402DeployError.toJSON()` extends it with `phase`, `resource`, `operationId`, `planId`, `fix`, `logs`, `rolledBack`, and, when automatic deploy retries are exhausted, `attempts`, `maxRetries`, `lastRetryCode`. `JSON.stringify(error)` produces a populated structured object — never the empty `"{}"` plain `Error` produces. **`correlated_platform_incident` — the error might not be yours.** While a platform incident is OPEN and its subsystem correlates with the error's `code`, the gateway envelope (available raw on `error.body`) carries `correlated_platform_incident: { id: "inc_…", subsystem, status: "ongoing" | "resolved" }`, and a `poll` action is appended to `nextActions`. This is a CORRELATION, not an exoneration — the platform states that it was degraded when your call failed and lets you judge (an app can still cause its own throttling). Treat it as a strong signal to poll the events feed (`r.events.list`) before debugging your own code; when the incident resolves, the matching `platform_incident` feed event carries your project's real failed-invocation count. The field is absent on any error with no correlated open incident — never a false confession. ### `withRetry(fn, opts?)` `withRetry` runs an async function with exponential backoff. Defaults: 3 attempts (1 + 2 retries), 250 ms base delay, 5 s cap. Uses `isRetryableRun402Error` as the default retry decision. Pair with the SDK method's own `idempotencyKey` so retried mutations dedup server-side — the closure carries the same key on every attempt. Do not wrap lifecycle-gated writes, auth token exchanges, or passkey verification in blind retry loops because an error says `safeToRetry: true`. Use a custom `retryIf` when a caller-specific recovery action makes a retry meaningful. For `r.project(id).apply()`, do not hand-roll the `BASE_RELEASE_CONFLICT` loop: the deploy namespace already re-plans and retries omitted/current-base specs when the gateway returns `safe_to_retry: true`. Default deploy budget is 2 retries after the initial attempt, `maxRetries: 0` opts out, each retry emits `deploy.retry`, and exhausted retries surface `attempts` / `maxRetries` / `lastRetryCode` on `Run402DeployError`. `RetryOptions`: `attempts?: number`, `baseDelayMs?: number`, `maxDelayMs?: number`, `retryIf?: (error, attempt) => boolean`, `onRetry?: (error, attempt, delayMs) => void`. After exhausting attempts, `withRetry` throws the LAST observed error — your catch handler sees the original structured envelope, not a wrapper. A buggy `onRetry` that throws is swallowed; the retry chain is unaffected. ## The patterns ### Paste-and-go assets — content-addressed URLs with SRI `r.assets.put` returns an `AssetRef`: ```ts const logo = await (await r.project(projectId)).assets.put("logo.png", { bytes }); logo.cdnUrl // → "https://pr-.run402.com/_blob/logo-3a7fc02e.png" logo.sri // → "sha256-…" for ", "home.html": "

Welcome

Open the app", "app.js": "/* SPA bootstrap */", } }, routes: { replace: [ { pattern: "/", target: { type: "static", file: "home.html" } }, ] }, }; await (await r.project(spaWithStaticHome.project)).apply(spaWithStaticHome); ``` - Route matching runs before all static resolution — including the implicit `/` -> `index.html` root mapping — and SPA-fallback derivation is independent of the route table. So `GET /` serves `home.html` (match `route_static_alias`), unmatched app routes e.g. `/dashboard` still serve the `index.html` shell (match `spa_fallback`), and named static pages keep serving unchanged (match `static_exact`). - Root placement of `home.html` keeps its relative asset URLs resolving identically to the direct file and avoids the `STATIC_ALIAS_RELATIVE_ASSET_RISK` warning. - Expected non-blocking plan lints: `STATIC_ALIAS_SHADOWS_STATIC_PATH` (warn — the alias overrides what `/` would otherwise serve; for this recipe that is accurate and expected, and the commit proceeds) and `STATIC_ALIAS_DUPLICATE_CANONICAL_URL` (info — `/home.html` stays directly reachable in implicit public-path mode; add `` to `home.html` if duplicate-content SEO matters). - Omitting `routes` on later deploys carries the alias forward (informational `ROUTE_TARGET_CARRIED_FORWARD`); a pipeline that sends `routes.replace` must include the alias every time because replace is total. - Verify with `p.apply.resolve({ url: "https:///", method: "GET" })` (below) and confirm `match: "route_static_alias"` with `target_file: "home.html"`. URL-first public diagnostics: ```ts import { buildDeployResolveSummary, normalizeDeployResolveRequest, run402, type DeployResolveAuthorizationResult, type DeployResolveCasObject, type DeployResolveResponse, type DeployResolveResponseVariant, } from "@run402/sdk/node"; const r = run402(); const request = normalizeDeployResolveRequest({ project: projectId, url: "https://example.com/events?utm=x#hero", method: "GET", }); const p = await r.project(projectId); const resolution: DeployResolveResponse = await p.apply.resolve(request); const summary = buildDeployResolveSummary(resolution, request); const auth: DeployResolveAuthorizationResult | undefined = resolution.authorization_result ?? undefined; const cas: DeployResolveCasObject | undefined = resolution.cas_object ?? undefined; const variant: DeployResolveResponseVariant | undefined = resolution.response_variant ?? undefined; console.log(summary.would_serve, summary.diagnostic_status, summary.match, request.ignored); void auth; void cas; void variant; ``` `r.project(id).apply.resolve({ url, method })` also accepts lower-level `{ host, path?, method? }`. URL query strings/fragments are ignored for lookup and surfaced 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`, route/static fields e.g. `allow`, `route_pattern`, `target_type`, `target_name`, and `target_file`, plus `edge_propagation` (`status`, `claimed_at`, `kvs_synced_at`, `expected_visible_by`, `hint`). Current known `edge_propagation.status` literals are `settled`, `propagating`, and `sync_pending`; non-settled statuses add `edge_propagating` / `edge_sync_pending` warnings and next steps such as `retry_after_edge_propagation` or `retry_after_edge_sync`. Current 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 SDK HTTP transport status, so host misses can be successful calls with `would_serve: false`. Do not use resolve as a fetch, cache purge, or cache-policy oracle; branch on structured fields e.g. `cache_class`, `allow`, `cas_object`, and `edge_propagation`, and preserve unknown cache classes. For post-deploy convergence checks, `DeployResult.edge` carries the gateway's edge block when returned by apply/commit polling. Call `p.apply.edgeCoherence(operationId)` to fetch the canonical report (`coherent`, pointer updates, probed paths, stale-release evidence, and `next_actions`), or `p.apply.waitEdgeCoherent(operationId, { timeoutMs, intervalMs, onPoll })` to poll until coherent or the timeout elapses. A non-coherent report is not a transport error; branch on `report.coherent` / `result.coherent` and inspect `report.paths[]`, `pending_count`, and `pointer_updates`. Known route warning codes and recovery: | Code | Meaning | Recovery | |---|---|---| | `PUBLIC_ROUTED_FUNCTION` | A route makes the target function public same-origin browser ingress. | Review app auth, CSRF, CORS/`OPTIONS`, and cookies; direct `/functions/v1/:name` remains API-key protected. Prefer `allowWarningCodes: ["PUBLIC_ROUTED_FUNCTION"]` after review; broad `allowWarnings` only after every warning was reviewed. | | `ROUTE_TARGET_CARRIED_FORWARD` | A carried-forward route still points at a base-release function target. | Inspect active routes with release observability and deploy `routes.replace` if the target should change. | | `ROUTE_SHADOWS_STATIC_PATH` | A dynamic route shadows one static path. | Inspect warning details and active release routes; confirm only when intentional. | | `WILDCARD_ROUTE_SHADOWS_STATIC_PATHS` | A prefix route shadows static paths. | Review affected paths, split exact routes if needed, and confirm only when intentional. | | `METHOD_SPECIFIC_ROUTE_ALLOWS_GET_STATIC_FALLBACK` | Unmatched methods can fall back to static content. | Confirm static fallback is intended or add method coverage. | | `WILDCARD_ROUTE_EXCLUDES_MUTATION_METHODS` | A 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. `allowWarningCodes` is a reviewed escape hatch; broad `allowWarnings` is last resort. | | `ROUTE_TABLE_NEAR_LIMIT` | The route table is near the gateway/project limit. | Consolidate or remove routes before adding more. | | `ROUTES_NOT_ENABLED` | Routes are not enabled for this 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). #### Node deploy convenience - `r.sites.deployDir(...)` — Node-only thin wrapper that uses `fileSetFromDir(dir)`, delegates to `apply`, and emits unified `DeployEvent` shapes. ### GitHub Actions OIDC — CI credentials + the same deploy primitive CI/OIDC federation is deliberately credential-driven. Link a GitHub repository or environment once, then keep using `r.project(id).apply(...)`; the deploy namespace detects SDK-marked CI credentials internally. Do not invent an `r.ci.deployApply(...)` path and do not pass a public `ci` deploy flag. The setup side is `r.ci` plus the Node-only signing helper. Use the SDK builders exactly; the gateway validates the SIWX Statement and Resource URI against golden vectors. ```ts import { CI_GITHUB_ACTIONS_PROVIDER, V1_CI_ALLOWED_ACTIONS, V1_CI_ALLOWED_EVENTS_DEFAULT, run402, signCiDelegation, } from "@run402/sdk/node"; const values = { project_id: projectId, subject_match: "repo:owner/name:ref:refs/heads/main", allowed_actions: V1_CI_ALLOWED_ACTIONS, allowed_events: V1_CI_ALLOWED_EVENTS_DEFAULT, // Optional: omit or [] for no CI route authority. // Use exact paths and/or final wildcard prefixes for route declarations. route_scopes: ["/admin", "/api/*"], github_repository_id: "123456789", expires_at: null, nonce: "0123456789abcdef0123456789abcdef", }; const r = run402({ disablePaidFetch: true }); await r.ci.createBinding({ ...values, provider: CI_GITHUB_ACTIONS_PROVIDER, signed_delegation: signCiDelegation(values), }); ``` Inside GitHub Actions, prefer `githubActionsCredentials({ projectId })`. It: - Requires `permissions: id-token: write` - Requests a GitHub OIDC token for `CI_AUDIENCE` (`https://api.run402.com`) unless overridden - Calls `/ci/v1/token-exchange` without local auth - Caches the Run402 session token until `expires_in - refreshBeforeSeconds` (default refresh cushion: 60 seconds) - Marks the credential provider so deploy uses CI Bearer auth and never local `apikey` headers ```ts import { githubActionsCredentials, run402, type ReleaseSpec } from "@run402/sdk/node"; const r = run402({ credentials: githubActionsCredentials({ projectId }), disablePaidFetch: true, }); const ciSpec: ReleaseSpec = { project: projectId, base: { release: "current" }, site: { patch: { put: { "index.html": "

ship

" } } }, }; await (await r.project(ciSpec.project)).apply(ciSpec); ``` CI deploy restrictions are part of the client contract: allowed top-level fields are only `project`, `database`, `functions`, `site`, absent/current `base`, and `routes` authorized by the binding's `route_scopes`. Omitted or empty `route_scopes` preserves the original no-routes CI posture. `spec.secrets`, `spec.subdomains`, `spec.checks`, unknown future fields, non-current `base`, and oversized specs that would require `manifest_ref` are rejected before any upload or plan call. Non-CI deploy behavior is unchanged. Gateway planning enforces route diffs and returns `CI_ROUTE_SCOPE_DENIED` when a route declaration falls outside the delegated exact paths or final wildcard prefixes. ### Dark-by-default tables + the expose manifest Tables you create are unreachable via `/rest/v1/*` until your manifest declares them with `expose: true`. The manifest is convergent — applying it twice is a no-op; items removed between applies have their policies, grants, triggers, and views dropped. The manifest itself is a JSON object: ```json { "$schema": "https://run402.com/schemas/manifest.v1.json", "version": "1", "tables": [ { "name": "items", "expose": true, "policy": "user_owns_rows", "owner_column": "user_id", "force_owner_on_insert": true }, { "name": "audit", "expose": false } ], "views": [ { "name": "leaderboard", "base": "items", "select": ["user_id", "score"], "expose": true } ], "rpcs": [ { "name": "compute_streak", "signature": "(user_id uuid)", "grant_to": ["authenticated"] } ] } ``` Built-in policies: `user_owns_rows` (rows where `owner_column = auth.uid()`), `public_read_authenticated_write` (anyone reads, any auth user writes), `public_read_write_UNRESTRICTED` (fully open; requires `i_understand_this_is_unrestricted: true`), `custom` (provide `custom_sql`). For `user_owns_rows`, `force_owner_on_insert: true` creates an idempotent per-table trigger named `_set_owner` backed by `
_set_owner_fn`. The generated shape is: ```sql CREATE OR REPLACE FUNCTION "
_set_owner_fn"() RETURNS trigger LANGUAGE plpgsql AS $body$ BEGIN IF NEW."" IS NULL THEN NEW."" := auth.uid(); END IF; RETURN NEW; END; $body$; CREATE TRIGGER "
_set_owner" BEFORE INSERT ON "
" FOR EACH ROW EXECUTE FUNCTION "
_set_owner_fn"(); ``` The trigger only fills omitted or explicit `null` owner values; it does not overwrite a non-null owner. Ordinary authenticated inserts still pass through the `WITH CHECK (owner_column = auth.uid())` policy, so an explicit different owner is rejected. `service_key` / service-role writes bypass RLS, but the trigger still runs; if the request has no JWT subject, `auth.uid()` is null, so admin writes should set `owner_column` explicitly when the row needs an owner. **Preferred path: put the manifest object under `database.expose` in a v2 `ReleaseSpec`.** The gateway validates it against migration SQL and applies it atomically with the rest of the release. **Non-mutating validation:** `r.projects.validateExpose(manifestOrJsonString, { project?, project_id?, migrationSql? })` validates the auth/expose manifest used by `database.expose` and `apply_expose`. With `project` / `project_id`, validation uses the live project schema through a server-authoritative endpoint; without it, validation is projectless. Invalid JSON strings return `{ hasErrors: true, errors, warnings }` instead of throwing. `migrationSql` is reference context only and is not executed as a PostgreSQL dry run. This is not deploy-manifest validation. **Imperative path:** `r.projects.applyExpose(projectId, manifest)` POSTs the same JSON shape to `/projects/v1/admin/:id/expose`; `r.projects.getExpose(projectId)` reads the currently-applied manifest. Prefer `r.project(id).apply` for production deploys so schema migrations and expose policy land together, but the direct methods are useful for round-tripping an existing manifest. ### In-function helpers — `@run402/functions` A separate package. Imported _inside_ a deployed serverless function, not by the SDK. Auto-bundled at deploy time (don't list `@run402/functions` in `--deps`). See for full details. ```ts import { db, adminDb, auth, email, ai, assets, getRoutedPaymentContext } from "@run402/functions"; export default async (req: Request) => { const user = await auth.requireUser(); // No .eq("user_id", user.id) — RLS already binds the visitor's rows via run402.current_user_id(); // the redundant filter is a deploy-fail (R402_AUTH_REDUNDANT_USER_FILTER). const mine = await db().from("items").select("*"); await adminDb().from("audit").insert({ event: "items_read", user_id: user.id }); return Response.json(mine); }; ``` - `db(req)` — caller-context. Forwards Authorization header. RLS applies. - `adminDb()` — bypass RLS. Routes to `/admin/v1/rest/*`. - `adminDb().sql(query, params?)` — raw parameterized SQL. - `auth.user()` / `auth.requireUser()` — read the verified actor from the SSR runtime context. `auth.user()` returns `Actor | null`; `auth.requireUser()` returns `Actor` and throws (303 redirect for HTML / 401 envelope for JSON, decided by the gateway from the `Accept` header). `Actor` has `id`, `projectId`, `sessionId`, `email`, `emailVerified`, `authTime`, `amr`, `amrTimes`. Calling either taints the SSR ISR cache (the response now depends on per-request actor state). Do NOT catch the throw from `auth.requireUser()` — the platform decides response shape. Bare `getUser` / `getUserId` / `getRole` / `getSession` / `currentUser` / `getCurrentUser` / `getServerSession` exports were retired in `@run402/functions` v3.0 — they throw `R402_AUTH_UNKNOWN_EXPORT` at runtime AND fail `run402 doctor` source scan at deploy. - For per-user gating in functions OUTSIDE the cookie-session flow (a `requireAuth` / `requireRole` deploy-spec gate, not the SSR auth namespace), read the gateway-injected headers directly: `req.headers.get("x-run402-user-id")` / `req.headers.get("x-run402-user-role")`. The gateway strips inbound `x-run402-*` headers before injection, so the values are trustworthy. Returns `null` when no corresponding gate ran (function has no gate, only `requireAuth` declared without `requireRole`, or local-invoke outside the gateway). - `getRoutedPaymentContext(req)` (`@run402/functions` 3.7+) — confirmed x402 payment context for priced routed function requests. Returns `{ scheme, paymentId, amountUsdMicros, payer, network, asset, payTo, transaction, settledAt }` or `null` for unpriced/direct/malformed calls. Use `payment.paymentId` for app-side idempotency. - `ai.generateImage({ prompt, aspect? })` — project-billed runtime image generation for deployed functions. `aspect` is `"square" | "landscape" | "portrait"`; result is `{ image, content_type, aspect }` with base64 image bytes. Uses `RUN402_SERVICE_KEY` against `/ai/v1/generate-image`, not the wallet/x402 `/generate-image/v1` endpoint. Gateway rate limits and spend caps are project-owned; public routed functions should add app auth or their own rate limiting before calling it. - `assets.put(key, source, opts?)` — runtime asset upload through `/apply/v1/service-asset-put`. Uses `RUN402_SERVICE_KEY`, shares the deploy-time CAS/activation substrate, and returns an SDK-compatible `AssetRef`. The helper makes raw `fetch()` calls to the project's own gateway endpoints using ambient request context (`RUN402_PROJECT_ID` / `RUN402_SERVICE_KEY` baked at deploy time). It does NOT use `@run402/sdk`. ## Namespaces — full surface The `Run402` class exposes focused namespaces. Click into the SDK source for full method signatures. > Reference tables below use plain fences, not `ts` fences. They document the > type surface in compact form — they are not runnable programs. Runnable example > snippets in this document still use ```` ```ts ```` and are type-checked by CI > against the published `@run402/sdk` and `@run402/sdk/node` types. ### `r.pay` ``` r.pay.fetch(url, init?, { maxUsdMicros?, idempotencyKey?, requireReceipt? }): Promise ``` Node automatically supplies the configured allowance/signer. Isomorphic hosts may inject `payExecutor` in `Run402Options`; without one, unpriced URLs pass through and a 402 fails locally with `PAYMENT_WALLET_UNFUNDED`. ### `r.actions` / `r.up` (`@run402/sdk/node` only) ``` r.actions.run(input: Run402ActionInput, opts?: Run402ActionRunOptions): Promise r.up(input?: Omit, opts?: Run402ActionRunOptions): Promise> Run402Action.ProjectsProvision === "projects.provision" Run402Action.TierSet === "tier.set" Run402Action.Up === "up" ``` Direct `projects.provision` and `tier.set` actions call the same SDK primitives as their namespaces. `up` is the recursive app deploy action described above; it returns `steps[]` and delegates final release work to `r.project(id).apply`. ### `r.projects` ``` provision(opts?: { tier?, name?, orgId?, idempotencyKey? }): Promise // idempotencyKey → Idempotency-Key header; retry-safe re-runs delete(id: string): Promise list(wallet?: string): Promise getUsage(id: string): Promise getSchema(id: string): Promise sql(id: string, sql: string, params?: unknown[]): Promise rest(id: string, table: string, queryOrOptions?: string | ProjectRestOptions): Promise restResponse(id: string, table: string, queryOrOptions?: string | ProjectRestOptions): Promise> validateExpose(manifest: ExposeManifest | string, opts?: { project?: string; project_id?: string; migrationSql?: string }): Promise applyExpose(id: string, manifest: ExposeManifest): Promise getExpose(id: string): Promise getQuote(): Promise info(id: string): Promise keys(id: string): Promise use(id: string): Promise // sets the active project (sticky default) active(): Promise // CLI-style aliases: usage(id): Promise // alias of getUsage schema(id): Promise // alias of getSchema quote(): Promise // alias of getQuote promoteUser(id, email): Promise // project-admin role helper demoteUser(id, email): Promise ``` **Tier and lifecycle are per-organization, not per project.** The state machine lives on `internal.organizations` (v1.46 / v1.57). Read it from `r.tier.status()`: - `organization_lifecycle_state: "active" | "past_due" | "frozen" | "dormant" | "purged" | null` — the organization's lifecycle state; `null` only for orphan wallets with no organization row. - `lease_perpetual: boolean | null` — operator escape hatch flag. When `true`, the organization never advances past `active` regardless of lease expiry. - `tier: "prototype" | "hobby" | "team" | null` — the organization's active tier. - `advisories?: [{ type, summary, next_actions[] }]` — org-level advisories (recovery-event-reachability); present only when at least one applies. `type: "operator_unreachable"` means the owning organization resolves to zero verified notification recipients — mandatory recovery/security notifications (e.g. a mailbox suspension) currently reach nobody. The remedy rides `next_actions[]`: register and verify an operator contact via `POST /agent/v1/contact` (`r.admin.setAgentContact`). Reachability is also machine-checkable on `r.admin.getOperatorStatus().operator_reachability` (`{ reachable, verified_recipient_count, sources[], skipped_last_90d }`). `r.projects.list(opts?)` reads the named, domain-aware inventory (`GET /projects/v1`, project-findability). Each `ProjectSummary` carries `id`, `name`, `tier`, `site_url` (first claimed run402.com subdomain → else first custom domain → else null), `custom_domains[]`, `status` / `effective_status`, `organization_lifecycle_state`, `lease_perpetual`, `organization_id` (the owning org), `created_by` (provisioning principal), and `created_at`. The response is `{ projects, has_more?, next_cursor?, scope? }`. Membership-scoped by default — org-owned control plane (v1.77+): a wallet *authenticates* (SIWX signed from the provider; mandatory server-side) but does not *own* — this lists projects owned by orgs the wallet's resolved principal is an active member of, ∪ projects with an active per-project grant. Options: `{ org }` filters to one org (`?org_id`; authorize-before-reveal — non-member/guessed id → 403, non-UUID → 400), `{ limit, cursor }` paginate (`?limit` default 50 max 200, `?after`), and `{ all: true }` reads the operator email-union inventory (`GET /agent/v1/operator/projects`) across every wallet controlling the operator's verified email — pass `{ all: true, token }` (operator-session token) for the cross-wallet union, else `all` falls back to the SIWX wallet's own slice and echoes `scope`. `all` + `org` together throws `LocalError` (mutually exclusive). The legacy thin wallet-scoped shape (`api_calls` / `storage_bytes` via `GET /wallets/v1/:wallet/projects`) is retired; those two fields remain optional on `ProjectSummary` for back-compat but the named inventory does not populate them — read `r.projects.getUsage(id)` for live usage. The v1.56 `projects.pin(id)` SDK method was removed — use `r.admin.org(org_id).pinLease()`. `r.projects.get(id)` is the authoritative single-project read (`GET /projects/v1/:id`, gateway `project.read`) — a `ProjectDetail` superset of a list row: `project_id`, `public_id`, `name`, `org_id`, `tier`, `effective_status`, `organization_lifecycle_state`, `site_url` (`| null`), `custom_domains[]`, `last_deploy` (`{ release_id, activated_at } | null`), `mailbox[]` (active addresses), `usage` (`{ api_calls, storage_bytes, api_calls_limit, storage_bytes_limit }`), and `created_at`. Caller-authed (SIWX/control-plane, no project keys) and works without the project in the local project-key cache. It returns NO secrets — authorize-before-reveal means an unauthorized/guessed id throws `Unauthorized` (403, or `NotAuthorizedError` for an org-membership denial), never a not-found oracle. Use `r.credentials.projectKeys.status(...)` / `export(...)` for explicit local cache inspection or secret export. Scoped form: `(await r.project(id)).projects.get()`. `r.projects.rename(projectId, name)` renames a project (`PATCH /projects/v1/:id`, project-findability) and returns `{ project_id, name }`. Caller-authed (SIWX/control-plane, not a project service key), so it works without the project in the local project-key cache. Authorization is org `admin`+ (or a `project:write` grant) on the owning org and authorize-before-reveal — an unauthorized/guessed id throws `Unauthorized` (403), never a not-found oracle; an invalid name throws `ApiError` (400). Scoped form: `r.project(id).rename(name)`. `r.projects.getUsage(id)` still surfaces `effective_status` and `organization_lifecycle_state` because that endpoint scopes to a single project and the derivation collapses per-project `archived_at` / `deleted_at` together with the organization's lifecycle. ### `r.project(id).apply` The unified apply primitive. **There is no public `r.deploy` surface** — the scoped client (`r.project(id)`) is the only path. Mutations live on the callable hero `r.project(id).apply(spec)` with `.plan/.start/.resume` sub-methods. Observability reads (release inventory, diff, resolve, event replay) live on the same `r.project(id).apply` object. The internal engine is `r._applyEngine` and is not part of the public surface. ``` // MUTATIONS — r.project(id).apply (callable hero): r.project(id).apply(spec, opts?): Promise r.project(id).apply.plan(spec, opts?: { idempotencyKey?, mode?: "reviewedPlan" | "legacyDryRun", dryRun?, requiredPlan? }): Promise<{ plan, byteReaders }> r.project(id).apply.start(spec, opts?: { idempotencyKey?, requiredPlan?, allowWarnings?, allowWarningCodes? }): Promise r.project(id).apply.resume(operationId, opts?): Promise // OBSERVABILITY — r.project(id).apply (read/event surface): r.project(id).apply.status(operationId, opts?): Promise r.project(id).apply.list(opts?: { limit?, cursor? }): Promise r.project(id).apply.events(operationId, opts?): Promise r.project(id).apply.edgeCoherence(operationId, opts?): Promise r.project(id).apply.waitEdgeCoherent(operationId, opts?: { timeoutMs?, intervalMs?, onPoll? }): Promise r.project(id).apply.resolve(opts: ScopedDeployResolveOptions): Promise // ScopedDeployResolveOptions is { url, method? } OR { host, path?, method? }; // the bare-r form r._applyEngine.resolve takes a top-level project plus DeployResolveOptions. r.project(id).apply.getRelease(releaseId, opts?: { siteLimit? }): Promise r.project(id).apply.getActiveRelease(opts?: { siteLimit? }): Promise r.project(id).apply.diff(opts: { from, to, limit? }): Promise // Low-level upload/commit (CLI debugging — most agents call apply()): r.project(id).apply.upload(plan, opts: { byteReaders, onEvent? }): Promise r.project(id).apply.commit(planId, opts?: { idempotencyKey?, onEvent? }): Promise r.project(id).apply.rehearse(planId, opts?: { teardown?: "keep" | "on_pass" | "always" }): Promise ``` Top-level deploy summary helper: ``` summarizeDeployResult(result: DeployResult): DeploySummary ``` Example: ```ts import { run402, summarizeDeployResult, type ReleaseSpec } from "@run402/sdk/node"; const r = run402(); const spec: ReleaseSpec = { project: "prj_...", site: { patch: { put: { "index.html": "

Hello

" } } }, }; const result = await (await r.project(spec.project)).apply(spec); const summary = summarizeDeployResult(result); console.log(summary.headline, summary.site?.cas?.reused_bytes); ``` For live event streaming during an in-flight apply, use `(await r.project(spec.project)).apply.start(spec)` and iterate `op.events()` (an `AsyncIterable`). The `r.project(id).apply.events(operationId)` method returns the events the gateway has recorded so far for an operation — useful for inspecting an apply after the fact, not for live streaming. ### `r.snapshots` Project snapshots are internal restore points, not portable archives. ``` create(projectId): Promise list(projectId, opts?: { kind?, limit?, after? }): Promise get(projectId, snapshotId): Promise delete(projectId, snapshotId): Promise restorePlan(projectId, snapshotId, opts?: { includeAuth? }): Promise restore(projectId, snapshotId, confirm, opts?: { includeAuth? }): Promise ``` `ProjectSnapshotDto` preserves gateway snake_case: `snapshot_id`, `operation_id`, `project_id`, `kind` (`manual` / `pre_migration` / `pre_restore` / `scheduled`), `profile`, `status`, `manifest_sha256`, `size_bytes`, `live_release_id`, `captured_at`, `expires_at`, `error`, `created_at`, `updated_at`, and `next_actions`. `restorePlan()` returns `{ restore_plan }` with `data_loss_statement`, auth counts/mode, capture-time/current releases, target slot behavior, `confirm.token`, `confirm.expires_at`, and next actions. `restore()` requires that token and returns `operation_id`, `pre_restore_snapshot_id`, old/new schema slots, restored migration registry row count, status, and next actions. Scoped form: `(await r.project(id)).snapshots.*`. ### `r.branches` Project branches are contained, expiring data copies for rehearsal and inspection. ``` create(projectId, opts?: { fromSnapshotId?: string, name?: string, emailMode?: "sandbox" | "off", enableCron?: boolean, ttlDays?: number, }): Promise list(projectId): Promise renew(projectId, branchProjectId, opts?: { ttlDays?: number }): Promise delete(projectId, branchProjectId): Promise ``` `ProjectBranchDto` includes `branch_project_id`, `parent_project_id`, `name`, `branch_url`, `subdomain`, `status`, `email_mode`, `enable_cron`, `data_from`, `release`, `expires_at`, `created_at`, and `next_actions`. `ProjectBranchCreateResult` additionally returns `operation_id`, `materialization_id`, `anon_key`, and `service_key`; the SDK saves those branch keys when the credential provider supports `saveProject`. Scoped form: `(await r.project(id)).branches.*`. ### `r.ci` GitHub Actions OIDC federation over `/ci/v1/*`. V1 supports deploy-scoped bindings only. ``` createBinding(input: { project_id: string, provider: "github-actions", subject_match: string, allowed_actions: readonly ["deploy"], allowed_events: readonly string[], route_scopes?: readonly string[], github_repository_id?: string | null, expires_at?: string | null, nonce: string, signed_delegation: string, }): Promise listBindings(input: { project: string }): Promise<{ bindings: CiBindingRow[] }> getBinding(bindingId: string): Promise revokeBinding(bindingId: string): Promise exchangeToken(input: { project_id: string, subject_token: string }): Promise<{ access_token: string, token_type: "Bearer" | string, expires_in: number, scope: string, }> ``` `exchangeToken` fills the RFC 8693 grant constants internally: `grant_type = urn:ietf:params:oauth:grant-type:token-exchange` and `subject_token_type = urn:ietf:params:oauth:token-type:jwt`. It sends `withAuth: false`; credential-provider auth headers are intentionally omitted. On failure `exchangeToken` throws the usual `Unauthorized`/`ApiError`. Use `isCiBindingRevoked(err)` to detect the `binding_revoked` denial (HTTP 403): a subject-matching binding existed but was revoked — typically because the project was transferred/handed off, which suspends the prior org's CI bindings. The fix is to re-create it with `run402 ci link github`, NOT to widen asset scopes (`run402 ci set-asset-scopes` 409s on a revoked binding). The gateway gives both `binding_revoked` and `access_denied` the generic canonical `code: "FORBIDDEN"`, so the only discriminator is the OAuth-style `error` field on the 403 body — `isCiBindingRevoked` reads it for you. The error stays an `Unauthorized` (`isUnauthorized` remains true), so existing generic-403 handling is unaffected. `CI_BINDING_REVOKED_ERROR` is the exported `"binding_revoked"` constant. `CiBindingRow` preserves gateway snake_case fields: ``` { id, project_id, issuer, subject_match, allowed_actions, allowed_events, route_scopes, github_repository_id, created_by, nonce, created_sig, created_at, expires_at, revoked_at, last_used_at, use_count } ``` Canonical helper exports: ``` CI_GITHUB_ACTIONS_PROVIDER = "github-actions" CI_GITHUB_ACTIONS_ISSUER = "https://token.actions.githubusercontent.com" CI_AUDIENCE = "https://api.run402.com" DEFAULT_CI_DELEGATION_CHAIN_ID = "eip155:84532" V1_CI_ALLOWED_ACTIONS = ["deploy"] V1_CI_ALLOWED_EVENTS_DEFAULT = ["push", "workflow_dispatch"] normalizeCiDelegationValues(values): NormalizedCiDelegationValues buildCiDelegationStatement(values): string buildCiDelegationResourceUri(values): string validateCiSubjectMatch(subject): string validateCiNonce(nonce): string normalizeCiRouteScopes(values): string[] validateCiRouteScope(value): string assertCiDeployableSpec(specOrPlanBody): void ``` Node-only CI exports from `@run402/sdk/node`: ``` signCiDelegation(values, opts?: { apiBase?, allowancePath?, chainId?, issuedAt?, expirationTime?, nonce? }): string createCiSessionCredentials({ projectId, accessToken?, getAccessToken? }): CiMarkedCredentialsProvider githubActionsCredentials({ projectId, apiBase?, audience?, refreshBeforeSeconds?, fetch? }): CiMarkedCredentialsProvider isCiSessionCredentials(credentials): boolean ``` CI error-code unions include binding errors (`invalid_route_scopes`, `nonce_replay`, `delegation_statement_mismatch`, `signer_mismatch`, `duplicate`), token-exchange errors (`invalid_token`, `access_denied`, `binding_revoked`, `event_not_allowed`, `repository_id_mismatch`, `ambiguous_binding`), and CI deploy errors (`payment_required`, `insufficient_scope`, `forbidden_spec_field`, `forbidden_plan`, `CI_ROUTE_SCOPE_DENIED`). Preserve unknown future strings as opaque gateway codes. ### `r.operator` The **human / email principal** — the *operator session* — distinct from the agent's per-wallet SIWX identity (and from the platform-`admin` "operator" endpoints, which are a different thing). A wallet signature can only ever return one wallet's slice; the operator session proves control of the *email* and returns the union across every wallet that verified it. Authentication is browser-delegated via an OAuth 2.0 device-authorization grant (RFC 8628, the `aws sso login` model): the SDK never performs WebAuthn — the browser does, via the existing magic-link or passkey web flow — and the SDK brokers the resulting operator-session token (a read-only `operator.read` bearer, ~30-min TTL, ~12h absolute cap, revocable). ``` operator.deviceStart({ clientName? }): Promise // POST /agent/v1/operator/session/device (unauthenticated). Returns // { device_code, user_code, verification_uri, verification_uri_complete?, expires_in, interval }. operator.devicePoll(deviceCode): Promise // POST /agent/v1/operator/session/device/token. RFC 8628 states are returned as // DATA, not thrown: { kind: "approved", session } | { kind: "authorization_pending" } // | { kind: "slow_down" } | { kind: "access_denied" } | { kind: "expired_token" }. // `session` is the OperatorSessionToken { operator_session_token, token_type, // expires_in, absolute_expires_at, email, wallets[] }. operator.overview({ token? }): Promise // GET /agent/v1/operator/overview. With `token` (the operator-session bearer) → // the email-union (scope.kind "email"): rollup, organizations[], wallets[] // (each with projects + email_binding), advisories[]. Without `token` it falls // back to the provider's default auth (SIWX) → that one wallet's slice. operator.revoke({ token }): Promise // POST /agent/v1/operator/session/revoke (operator-session bearer). Idempotent, // 204. Server-side revoke is instant (no positive-validity cache). ``` **Control-plane session (v1.78 — `passkey-principals-onboarding`).** The human's write-capable session (the gateway's 5th principal, `control_plane_session`). Distinct from the read-only operator session above. It authorizes most control-plane ops, but since v1.85/v1.87 it is **not** sufficient on its own for `provision` / `deploy` / secret-writes — those additionally require a passkey-fresh **operator approval** (see below). High-stakes control ops (invite, membership, handoff, delete) require a **fresh passkey** — a magic-link/OAuth session raises `StepUpRequiredError` until it runs a step-up ceremony. The CLI mints it headlessly via loopback-PKCE (RFC 8252, the `aws sso login` localhost-redirect model). The SDK exposes the two isomorphic seams: ``` operator.buildCliAuthorizeUrl({ redirectUri, codeChallenge, state, nonce }): string // Pure (no network). GET /agent/v1/control-plane/cli/authorize — the URL the CLI // opens in the browser; the console runs the passkey ceremony + approves. operator.exchangeCliToken({ code, codeVerifier, redirectUri, state }): Promise // POST /agent/v1/control-plane/cli/token (unauth — code + verifier ARE the credential). // ControlPlaneSession { control_plane_session_token, token_type, expires_in, // provenance:"loopback_pkce", principal_id, amr[] }. ``` **Operator approval (write-auth, v1.85/v1.87).** A wallet-less human's control-plane session is read-capable on the high-stakes routes; `provision` / `deploy` / secret-writes also need a passkey-fresh approval scoped to one `(action, target)`, carried as an `X-Run402-Write-Auth` token. The isomorphic seams (`r.operator.approval`) mirror the login seams; the Node CLI (`run402 operator approve`) runs the loopback + PKCE around them: ``` operator.approval.requestChallenge({ action, orgId?, projectId?, cliRedirectUri, codeChallenge, state, token? }): Promise // POST /agent/v1/control-plane/write-auth/challenges. action ∈ org.project.create | project.deploy // | project.secret.write (org.project.create needs orgId; the others projectId). Carries the cp bearer. operator.approval.exchangeClaimCode({ code, codeVerifier, state }): Promise // POST /agent/v1/control-plane/write-auth/cli/token (unauth; no redirect_uri — bound at challenge). // ApprovalTokenResult { write_auth_token, token_type:"write_auth", header:"X-Run402-Write-Auth", session }. ``` Credential resolution is **surface-aware and never ambient**: `run402({ surface })` — `cli` resolves `auto` (wallet, else the control-plane session + an approval *only* when a cached one exactly matches the request's `(capability, target)`); `mcp` / `sdk` stay wallet-only, so an agent tool call never spends the human's approval. A gated write with no matching approval throws `OperatorApprovalRequiredError` (`isOperatorApprovalRequired()` guard) carrying `capability`, `target`, and a resolved `approveCommand` (e.g. `run402 operator approve --action project.deploy --project prj_x`) — the agent relays that; an interactive CLI auto-runs it. (`WRITE_AUTH_BINDING_MISMATCH` / `WRITE_AUTH_SESSION_INVALID` map to the same typed error.) The **hosted/browser** session surface — the front door the console (and any browser app) drives — is `r.operator.session.*`. Public *mint* methods send no auth; *session-bound* methods take `{ token }` (the `control_plane_session` bearer) and fall back to the credential provider when omitted (mirrors `overview`). WebAuthn option/assertion payloads are opaque passthroughs — the browser runs the ceremony. ``` // mint (public — no auth) operator.session.email({ email }): Promise // non-enumerating magic-link send operator.session.verifyEmail({ token }): Promise // verifies email, AUTO-CLAIMS invites, mints (amr ["email"]) operator.session.passkeyOptions({ email }) / passkeyVerify({ email, response }) // WebAuthn login → session operator.session.oauthUrl("google" | "github"): string // pure; GET …/oauth/:provider/start (browser 302) operator.session.consumeRecoveryCode({ code }): Promise // session + must_enroll_passkey // session-bound ({ token } → bearer; omit → provider auth) operator.session.whoami({ token? }): Promise // { principal, memberships, amr, amr_times } operator.session.refresh({ token? }) / revoke({ token? }) operator.session.enrollPasskeyOptions({ token? }) / enrollPasskeyVerify({ token?, response, label? }) operator.session.stepUpOptions({ token?, opClass? }) / stepUpVerify({ token?, response, opClass?, objectKind?, objectId? }) // satisfy a StepUpRequiredError (amr passkey), then retry the gated write operator.session.issueRecoveryCodes({ token? }) // one-time codes (shown once) operator.session.listAuthenticators({ token? }) / revokeAuthenticator({ token?, id }) ``` Carry a minted session as the whole SDK's credential with `controlPlaneSessionCredentials({ token | getToken })` — `r.orgs.*` / `r.org(id).*` / `r.admin.transfers.*` then act as that principal (it carries no project keys, so DB/project-key ops still need the wallet/keystore): ``` import { run402, controlPlaneSessionCredentials } from "@run402/sdk/node"; const r = run402({ credentials: controlPlaneSessionCredentials({ token }) }); await r.orgs.whoami(); // resolves the principal + memberships ``` **Invite → claim at first login.** An owner invites by email (`r.org(id).invites.create`); the invitee's pending memberships are claimed *automatically* when they log in via that verified email (email / OAuth / loopback) and surface as active rows in `session.whoami().memberships` (and in `run402 operator login --loopback` output). Owner/admin invites only claim once the invitee has enrolled a passkey; lower roles claim on any login. There is no invitee-side "list my invites" call — the claim is the surfacing. The session caches are Node-only and live in `core`: the read session at `{base}/operator-session.json` and the write-capable control-plane session at `{base}/control-plane-session.json` (both mode 0600, base config dir — email-scoped, shared across local named wallets). The CLI (`run402 operator login[/--loopback]/logout/overview/whoami`) brokers them; read `whoami` is a pure local-cache read. No MCP tool by design — MCP authenticates as the agent, not the human; the hosted login is browser-interactive and console-side. ### `r.sites` `deployDir` is exposed only on the Node entry (`@run402/sdk/node`); the isomorphic entry's `r.sites` namespace is empty. ``` // Node-only — @run402/sdk/node: deployDir(opts: { project, dir, target?, onEvent? }): Promise ``` ### `r.assets` The unified asset namespace (renamed from `r.blobs` in v2.0). Isomorphic single-asset methods on every runtime; the Node entry point (`@run402/sdk/node`) upgrades `r.assets` to `NodeAssets`, adding the bulk directory helpers. ``` // Isomorphic — single asset: put(projectId, key, source, opts?: BlobPutOptions): Promise get(projectId, key): Promise ls(projectId, opts?: { prefix?, limit?, cursor? }): Promise rm(projectId, key): Promise sign(projectId, key, opts?: { ttl_seconds? }): Promise diagnoseUrl(projectId, url): Promise waitFresh(projectId, opts: { url, sha256, timeoutMs? }): Promise // Node-only — @run402/sdk/node — bulk directory + batch: uploadDir(path, opts: { project, prefix?, ignore?, includeSensitive?, onEvent? }): Promise syncDir(path, opts: { project, prefix?, prune?, confirm?, ignore?, includeSensitive?, onEvent? }): Promise prepareDir(path, opts: { project, prefix?, ignore?, includeSensitive? }): Promise<{ manifest: AssetManifest, applySlice: AssetSpec }> putMany(items: PutManyItem[], opts: { project, onEvent? }): Promise // Node-only — input helper (synchronous; walk happens at apply submission): dir(path, opts?: { prefix?, ignore?, includeSensitive? }): LocalDirRef ``` `source` is one of: a bare `string` (text encoded as UTF-8, ≤ 1 MB), a bare `Uint8Array`, `{ content: string }`, or `{ bytes: Uint8Array }`. Known binary keys/MIME types reject string sources with `BINARY_CONTENT_REQUIRES_BYTES`; pass their original bytes. `AssetRef` (return type of single-asset `put`; legacy alias `BlobPutResult` still exported) extends snake_case fields (`key`, `size_bytes`, `sha256`, `visibility`, `url`, `immutable_url`) with the v1.45+ camelCase helpers used by paste-and-go HTML emitters: `cdnUrl`, `cdnMutableUrl`, `immutableUrl`, `etag`, `sri`, `contentDigest`, `cacheKind` (`"immutable" | "mutable" | "private"`), `contentSha256`, `contentType`, plus `scriptTag()`, `linkTag()`, `imgTag()` methods. See `sdk/src/namespaces/assets.types.ts` for the full shape. **v2.1.0 substrate change.** `r.assets.put` now routes through the unified-apply hero (`r.project(id).apply(spec)` with `spec.assets.put`). Bytes upload via `/content/v1/plans` to direct-to-S3 presigned URLs; per-key visibility flips inside the activation transaction that flips `live_release_id`. The legacy `/storage/v1/uploads*` substrate was removed in gateway v1.48; the `initUploadSession` / `getUploadSession` / `completeUploadSession` SDK methods now throw `LocalError` directing callers to `r.assets.put` (single key) or `r.assets.uploadDir(path)` (Node-only, batches a directory under one apply). #### Bulk directory helpers (Node-only) `uploadDir` is **additive**: walks the directory, hashes every file with streaming SHA-256, and submits one apply transaction (`r.project(id).apply ({ assets: { put: [...] } })`). Existing keys not present in the directory are left untouched. `syncDir` is **declarative**. Without `prune: true` it behaves identically to `uploadDir`. With `prune: true` it deletes keys under the supplied prefix that aren't in the new directory; the first call runs a plan and throws `PruneConfirmationRequired` (a `LocalError` subclass) carrying `base_revision`, `delete_set_digest`, `expected_delete_count`, and `sample_keys`. Echo those back as `confirm: {...}` to commit. `prune: true` **requires an explicit `prefix`** — no implicit project-root prune. The gateway's `ASSET_SYNC_DRIFT` activation check catches the narrower race where inventory mutates between commit and activation. `prepareDir` runs plan-only and returns `{ manifest, applySlice }`. Use it when you need resolved CDN URLs before commit (e.g. inject content-hashed asset URLs into HTML, then commit both the HTML and the assets in one apply call by passing `applySlice` to a follow-up `r.project(id).apply .start(...)`). `putMany` is the in-memory batch shape: each item carries a `key` plus an in-memory `ContentSource` (string, Uint8Array, ArrayBuffer, Blob). Useful in V8 isolates and tests where no filesystem is available. #### The three-schema fidelity contract The SDK accepts three input shapes for the assets slice but the gateway sees only one wire shape: 1. `LocalDirRef` — returned by `dir(path)`. Synchronous, lazy: the filesystem walk happens at apply submission, not at construction. The discriminator `__source: "local-dir"` is stable for type-narrowing. **The gateway never sees a `LocalDirRef`** — submitting one in a JSON body is rejected with HTTP 400 `INVALID_WIRE_SCHEMA`. The SDK normalizes via `entriesFromLocalDir(ref)` before any plan request. 2. `AssetPutEntry[]` — wire-shaped (`{ key, sha256, size_bytes, content_type, visibility, immutable }`). What the gateway sees. 3. In-memory `ContentSource` — accepted by `putMany`; hashed locally and converted to `AssetPutEntry` before submission. This is enforced by the wire-schema validator and verified by `three-schema fidelity` tests under `sdk/src/`. #### `AssetManifest` — batch result envelope ``` interface AssetManifest { list: AssetManifestEntry[] byKey: Record // null-prototype manifest: Record // null-prototype, plain-data copy totals: { files, bytes_uploaded, bytes_reused, duration_ms } pruned?: string[] // present when syncDir prune ran } interface AssetManifestEntry { key, sha256, size_bytes, content_type, visibility, url, immutable_url, cdn_url, cdn_immutable_url, sri, etag, content_digest } ``` `byKey` and `manifest` are constructed with `Object.create(null)` so attacker-controlled keys like `__proto__` can't collide with `Object.prototype` — a hard invariant covered by the prototype-pollution safety tests in `sdk/src/`. ### `r.archives` Cloud export helpers are available in both SDK entry points: ``` create(projectId, opts?: { scope?: "portable-runtime-v1", auth?: "stubs" | "none", consistency?: "pause-writes" | "cloud_write_pause_v1", idempotencyKey?: string, }): Promise get(projectId, archiveId): Promise wait(projectId, archiveId, opts?: { pollIntervalMs?: number, timeoutMs?: number, onProgress?: (event: ProjectArchiveProgressEvent) => void | Promise, }): Promise download(projectId, archiveId): Promise export(projectId, opts?: ProjectArchiveExportOptions): Promise ``` `ProjectArchiveDto` carries `archive_id`, `operation_id`, `status`, `format_version`, `scope`, `auth_export`, `consistency_mode`, `active_release_id`, `portability_report`, `export_report`, `byte_count`, `sha256`, `expires_at`, and `next_action`. `download` returns `{ archive, bytes, contentType, filename }`. Node-only helpers: ``` r.archives.inspect(archivePath): Promise r.archives.verify(archivePath): Promise r.archives.importToCore(opts: { archivePath: string, name?: string, coreUrl?: string, envFile?: string, secretValues?: Record, dryRun?: boolean, requireRunnable?: boolean, }): Promise ``` `ArchiveVerifyResult` includes `ok`, `archive_version`, `archive_digest`, `transport`, `file_count`, `total_bytes`, `descriptor_count`, `required_capabilities`, `required_secrets`, `auth_subject_stub_count`, `export_report`, `portability_report`, and `diagnostics`. Branch on diagnostic `code`, not prose. Common codes include `ARCHIVE_DIGEST_MISMATCH`, `ARCHIVE_UNSUPPORTED_REQUIRED_CAPABILITY`, `ARCHIVE_PATH_UNSAFE`, `SECRET_VALUES_REQUIRED`, `AUTH_CREDENTIALS_NOT_EXPORTED`, `AUTH_SUBJECT_STUBS_IMPORTED`, `CLOUD_ONLY_FEATURE_EXCLUDED`, `PROJECT_ALREADY_EXISTS`, `IMPORT_VERIFY_FAILED`, and `IMPORT_CONFORMANCE_FAILED`. ### `r.functions` ``` deploy(projectId, opts: { name: string, code: string, config?: { timeout?, memory? }, deps?: string[], schedule?: string | null, }): Promise // routes through unified apply (functions.patch.set). result: { name, url, status, runtime, schedule, warnings }; runtime_version & deps_resolved are null via this path - read r.functions.list() for resolved versions invoke(projectId, name, opts?: { method?, body?, headers?, idempotencyKey?, wait? }): Promise // paid calls require a stable idempotencyKey; wait polls a 202 run handle and replays the same key for the retained result logs(projectId, name, opts?: { tail?, since?, requestId? }): Promise update(projectId, name, opts: { schedule?, timeout?, memory? }): Promise list(projectId): Promise // FunctionSummary includes runtime_version?, runtime_current_version?, runtime_minimum_version?, and runtime_stale? delete(projectId, name): Promise rebuild(projectId, name): Promise // { name, rebuilt, old_fingerprint, new_fingerprint, runtime_version_before, runtime_version_after, code_hash } rebuildAll(projectId): Promise // { rebuilt_count, total, results: (FunctionRebuildResult | { name, rebuilt: false, code?, error })[] } r.functions.runs.create(projectId, name, { eventType: string, payload?: Record, idempotencyKey: string, delay?: string | number, // "10m", "1h", "3d" or seconds; mutually exclusive with runAt delaySeconds?: number, runAt?: string | Date, expiresAt?: string | Date, expiresAfter?: string | number, retry?: { preset?: "standard", maxAttempts?: number, minDelaySeconds?: number, maxDelaySeconds?: number }, }): Promise r.functions.runs.list(projectId, name, opts?: { status?, eventType?, since?, until?, limit?, cursor? }): Promise<{ runs, next_cursor? }> r.functions.runs.get(projectId, runId): Promise r.functions.runs.logs(projectId, runId, opts?: { tail?, since? }): Promise r.functions.runs.cancel(projectId, runId): Promise r.functions.runs.redrive(projectId, runId, opts?: { retry? }): Promise r.functions.runs.wait(projectId, runId, opts?: { intervalMs?, timeoutMs?, throwOnFailure? }): Promise r.idempotency.fromParts(...parts: Array): string ``` Durable function runs are service-key authed function requests that survive process crashes and support delay/run_at scheduling, expiry, retry, cancellation, logs, and redrive. `idempotencyKey` is required on create; use `r.idempotency.fromParts("reminder", messageId)` or your own stable key so retries do not duplicate logical work. The scoped client exposes the same surface at `(await r.project(id)).functions.runs.*` without repeating `projectId`. `FunctionLogEntry` includes `timestamp` and `message`, plus optional `event_id`, `log_stream_name`, `ingestion_time`, and `request_id` metadata when the gateway can provide it. Use `requestId` to follow a routed browser failure exposed as `X-Run402-Request-Id` / JSON `request_id`, or to filter by durable run/attempt ids (`fnrun_...`, `fnatt_...`); SDK calls reject invalid `since` timestamps, invalid request ids, and `tail` values outside 1..1000 locally instead of forwarding them. `rebuild` / `rebuildAll` (capability `function-runtime-rebuild`, gateway v1.69+) refresh a deployed function onto the platform's CURRENT entry wrapper + bundled runtime WITHOUT changing source: they re-bundle from the stored source with dependencies pinned to the recorded exact versions, so the source `code_hash` is unchanged and no new release is created — only the platform wrapper/runtime changes. 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 pick it up. Strictly opt-in. Both are **wallet-authed** (project ownership; no service key) and allowed during billing grace (`past_due` / `frozen` / `dormant`). Functions deployed before dependency locking are refused with `CANNOT_REBUILD_UNLOCKED_DEPS` (single: HTTP 409 `ApiError`; `rebuildAll`: a `{ rebuilt: false, code: "CANNOT_REBUILD_UNLOCKED_DEPS", error }` entry that never aborts the batch) — redeploy those from source. Runtime compatibility is surfaced per function as recorded `runtime_version?`, gateway `runtime_current_version?`, guaranteed `runtime_minimum_version?`, and `runtime_stale?`; the current `3.7.0` minimum includes `getRoutedPaymentContext()` for priced routes. Operator status also carries `{ stale_function_count, stale_functions: [{ project_id, name }] }`. The scoped client exposes `r.project(id).functions.rebuild(name)` / `.rebuildAll()`. `deps` accepts npm specs: bare names → latest at deploy time, pinned (`lodash@4.17.21`) and ranges (`date-fns@^3.0.0`) honored verbatim. Max 30 entries / 200 chars each; empty or whitespace-only entries are rejected. **Native binary modules are rejected.** Don't list `@run402/functions` (auto-bundled). ### `r.jobs` Platform-managed jobs over `/jobs/v1/*`. This is not arbitrary Docker execution: callers choose a run402-configured `jobType`, provide JSON input, and set a hard cost cap. The SDK loads the project's `service_key`, supplies the required `Idempotency-Key` header internally, and serializes the request to the gateway's snake_case body. ``` submit(projectId, { jobType: "example.managed_job.v1", input: { inputJson: Record }, maxCostUsdMicros: number, callbackUrl?: string, }): Promise get(projectId, jobId): Promise logs(projectId, jobId, opts?: { tail?, since? }): Promise<{ logs: ManagedJobLogEntry[] }> cancel(projectId, jobId): Promise purge(projectId): Promise<{ deleted_jobs, cancelled_active_jobs, terminated_instances }> ``` The scoped client pre-binds the project id: `const p = await r.project(id); await p.jobs.get(jobId)`. `ManagedJobResponse` mirrors the gateway snake_case shape: `job_id`, `job_type`, `status` (`queued` / `running` / `completed` / `failed` / `cancelled`), `created_at`, optional `started_at`, `completed_at`, `artifacts`, `metadata`, and `error`. `jobs.logs(..., { since })` prefers an ISO-8601 timestamp; legacy epoch milliseconds are still accepted for older callers. ### `r.secrets` ``` set(projectId, key, value): Promise list(projectId): Promise // { secrets: [{ key, created_at?, updated_at? }] } delete(projectId, key): Promise ``` Secret values and value-derived hashes are never returned. For deploys, use `secrets.require[]` only as a dependency gate; it is not an injection allowlist. ### `r.subdomains` ``` claim(name, deploymentId, opts?: { projectId? }): Promise delete(name, opts?: { projectId? }): Promise list(projectId): Promise ``` Most agents do not call `claim` directly — declare subdomains in `r.project(id).apply({ subdomains: { set: ["my-app"] } })` and the deploy primitive claims them as part of the release. Subdomain auto-reassignment: claim once. Every subsequent deploy to the same project automatically points the subdomain at the new deployment. ### `r.domains` The ProjectDomain lifecycle — the ONE surface for custom domains (web + email). ``` ensure(projectId, domain, { desired }): Promise // connect / update desired state get(projectId, domain): Promise list(projectId): Promise<{ domains: ProjectDomain[] }> check(projectId, domain): Promise // refresh observations apply(projectId, domain): Promise // apply records Run402 has authority over repair(projectId, domain): Promise wait(projectId, domain, { until?, timeoutMs?, intervalMs? }): Promise testReceive(projectId, domain, to): Promise activate(projectId, domain): Promise disconnect(projectId, domain): Promise<{ status, domain }> ``` `desired` carries `web`, `email`, and an optional `authority`: ```ts // Root domain — Run402 hosts the DNS zone; the owner makes ONE nameserver change. const d = await r.domains.ensure(projectId, "example.com", { desired: { authority: "hosted_dns_zone", web: { enabled: true } }, }); // hosted_zone is present only for a hosted-zone domain, hence the ?. const nameservers = d.hosted_zone?.ns_assigned ?? []; // hand these two to the domain owner await r.domains.wait(projectId, "example.com", { until: "active" }); // Subdomain / you keep your DNS host — add the records the response lists. await r.domains.ensure(projectId, "app.example.com", { desired: { web: { enabled: true } } }); ``` `authority: "hosted_dns_zone"` is the only workable path for a ROOT domain at most registrars (a root CNAME is illegal without flattening/ALIAS support) and collapses setup to one registrar step: Run402 applies every in-zone record, verifies ownership, and issues TLS once delegation is observed. Existing MX/TXT are imported into the hosted zone before the nameserver change is recommended, so mail keeps working. `hosted_zone` reports `{ dns_hosting, status, ns_assigned, imported_records }`; disconnecting tears the zone down (DNS stops resolving until nameservers are re-pointed). Every response carries `next_actions[]` (ordered; `[0]` is the recommended step). ### `r.events` The cursored events feed — "what happened since I last looked". Also project-scoped as `r.project(id).events.list(opts)`. An **organization** owns each fact and `project_id` says what it is *about*. So `listForOrg` is a **superset** of the project feeds rather than a union of them — it also carries organization-level facts, which belong to no project and arrive with `project_id: null` — and a fact **outlives** the project it describes: deleting a project no longer erases its history, so `project_id` may name a project that is gone. ``` list(projectId, { cursor?, limit?, source?, eventType? }): Promise listForOrg(orgId, { cursor?, limit?, source?, eventType? }): Promise // ProjectEventFeedPage = { events: ProjectEvent[], cursor, has_more, reset, earliest_cursor?, // platform_incidents?, platform_status? } // ProjectEvent = { id, project_id, event_type, class, source, occurred_at, payload, next_actions[] } // project_id: string | null ← null for an organization-level fact ``` **An id is not a cursor.** Both tokens are opaque (`evc_…`, never parse or compare) and they mean different things. An event's `id` names a **fact**: the same event carries the same `id` from `list` and from `listForOrg`, which is how you dedup across both. The page `cursor` names a **position**, and a position only means something inside the row set it came from — so it is bound to that projection (which feed, plus any `source` / `eventType` filters). Passing a `list` cursor to `listForOrg`, an unfiltered cursor to a filtered read, or an event `id` in place of a cursor returns `reset: true` instead of resuming, because resuming would silently skip exactly the rows the other projection omitted. Key any cursor you persist by the read shape it came from. Store the page's `cursor` and pass it back as `{ cursor }`. An unusable cursor never throws; the page returns `reset: true` + `earliest_cursor` to restart from. Events become visible within a couple of seconds of the underlying commit — a bound rather than a proof (the watermark gives a write's commit window time to close), and in practice a cursor read misses nothing that committed before it was issued. `list` accepts the project's own service_key, a wallet/control-plane principal with `project.read`, or a scoped delegate; `listForOrg` is principal-only (active org membership). Never lifecycle-gated — a frozen project's feed stays readable. Retention is **age and class only**: 90d, 365d for mandatory classes. Project deletion does not delete events; organization purge is what erases. **App events vs platform events.** The feed also carries app-emitted business facts (a deployed function's own `events.emit(...)` calls, `@run402/functions`) alongside the platform events above; every row is `source`-discriminated (`"app"` vs `"platform"` — every non-app source, e.g. the platform's internal `gateway` / `email-lambda` producers, collapses under `"platform"`). `source?: "app" | "platform"` restricts to one lane; `eventType?: string | string[]` restricts to one or more event types (an array serializes as the comma-joined wire param `event_type=a,b`; a plain string is passed through as-is). Both filters compose with `cursor`/`limit` unchanged and are additive — omit either to keep reading the unfiltered feed. Consumers should key on the pair `(source, event_type)` together: app-chosen `event_type` names are free-form per app, so only the pair disambiguates them from the platform's own vocabulary. **Platform incidents — my bug or yours?** When a platform incident (a debounced CloudWatch-alarm window or a human-declared incident) is attributed to your project, its feed gains one `platform_incident` event (class `platform_incident`, mandatory retention 365d) with a compact-fact payload `{ incident_id, subsystem, severity, scope, status, started_at, resolved_at, summary, impact: { count } }` — `impact.count` is the real number of your invocations the platform, not your code, caused to fail (may be `null` for a manually-declared impact). Its `next_actions[]` carry a `poll` on this feed plus a `check_usage` drill-down into `r.errors` so you can confirm those failures were platform-excluded from your fingerprints. The page also carries two additive fields during an OPEN incident: `platform_incidents[]` — a sidecar overlay of open GLOBAL (unattributed) incidents, each with a stable `id` for dedup, never interleaved into `events[]` so the cursor stays monotonic — and `platform_status: "degraded"` (omitted when clear), the same health rider surfaced on `r.admin.getOperatorStatus()` and `r.tiers.status()`. Both are absent when nothing applies; existing consumers ignore them. ### `r.rooms` Org-scoped agent coordination rooms — session presence ("who's here, doing what"), durable room-visible messages, and advisory work claims for the agents working on the same project. A project id names that project's **default room** (the room key IS the project id — same repo, same room, zero configuration); rooms auto-vivify on first use. ``` registerPresence(orgId, roomKey, { requestedName?, task?, program?, model? }) // → your presence: { presence_id, name, requested_name, renamed, … } // requestedName honored when free, suffixed on collision (Opus → Opus-2) — never an error listPresences(orgId, roomKey, { includeExpired?, name? }) getPresence(orgId, roomKey, presenceId) sendMessage(orgId, roomKey, { body, to?, cc?, threadId?, importance?, ackRequired?, idempotencyKey?, presenceId?, requestedName?, task? }) // body: markdown, ≤32 KiB. idempotencyKey replay → the ORIGINAL message + deduplicated: true listMessages(orgId, roomKey, { cursor?, order?, before?, threadId?, addressedTo?, unread?, presenceId?, limit? }) // ascending catch-up from { cursor }; { order: "desc", before } pages OLDER history; // { addressedTo: "me", unread: true, presenceId } is the unread-inbox read getMessage(orgId, roomKey, messageId) // FULL body (lists carry snippets) + ack state ackMessage(orgId, roomKey, messageId, { presenceId? }) createClaim(orgId, roomKey, { resource, mode, ttlSeconds?, note?, presenceId? }) // ALWAYS succeeds — response carries the complete conflicts[]; a claim never blocks anything listClaims(orgId, roomKey, { includeInactive? }) releaseClaim(orgId, roomKey, claimId) // holder only; idempotent scoped(orgId, roomKey): ScopedRoom // sync — same methods with the room pre-bound forProject(projectId): Promise // resolves the project's org via its overview; // the default room's key IS the project id ``` **Presence is a session, not a credential.** Two sessions of the same agent are two presences. A presence expires after ~1h of silence; names are unique per room FOREVER, so a re-registration after expiry gets a fresh name (introduce yourself). `requestedName` is honored-or-suffixed with the outcome reported (`requested_name` + `renamed`); `task` / `program` / `model` are optional self-description every other agent in the room sees. **Messages are room-visible.** `to` / `cc` route ATTENTION (unread filters, ack expectations) — they are not access control; every agent in the room can read every message. Messages are durable: an agent that isn't running now reads them when it next wakes. Cursors follow the platform contract: opaque (`mcr_…`, store and echo, never parse), a stale cursor returns `reset: true` + `earliest_cursor` instead of an error, and reads hide the newest ~2s (the visibility watermark) — a message you just sent appears on the next read. In a project's default room every send also lands as a compact `agent_message_sent` event (class `coordination`) in the project's events feed (`r.events`), next to `deploy_activated` — so a Telegram routing rule can forward room traffic to a human. Sends are quota'd per org per day (1k / 10k / 100k across prototype / hobby / team). **Claims are advisory — nothing is ever blocked by one.** `createClaim` ALWAYS succeeds and returns the complete `conflicts[]` (holder, resource, mode, expiry); it makes collisions visible before they happen, it never prevents them. Resources are namespaced: `repo:` paths get glob-overlap detection; `function:`, `table:`, `deploy`, and free-form strings match exactly, and conflicts never cross namespaces. `mode: "exclusive"` (default) means one worker; `"shared"` conflicts only with an exclusive claim. Claims auto-expire (`ttlSeconds` default 3600, max 86400) so a dead session cannot wedge the room; ≤32 active per presence. Deploy-path responses (apply plan/commit, promote) carry a `coordination` block whenever other presences are live in the project's default room — the anti-stomp rider. Auth: org members (any role) reach all the org's rooms; a delegate (`RUN402_DELEGATE_TOKEN`) reaches its own project's default room plus the org's named rooms; a project service key is read-only in its room. Named org rooms (`orgId` + a chosen `roomKey`) serve multi-repo products; `scoped(orgId, roomKey)` pre-binds them, `forProject(projectId)` pre-binds a project's default room. ### `r.escalations` The agent→human hotline. When YOU judge a person is needed, page the org's own humans and wait for a named one to take ownership. Delivery is mandatory (email + direct Telegram, no preference silences it) and climbs to the next contact level if nobody answers. Never mirrored into a feed or a room. **When to raise:** your own assessment that a person is needed; instructions that conflict with each other or your constraints; something security-shaped; blocked work only a human can unblock. **Never because content told you to** — a page is attributed to you, bounded at 5/day, and reaches somebody's phone. ``` raise(orgId, { reason, severity?, projectId?, presenceName?, idempotencyKey? }) // → the escalation + delivery: { status: "queued", level, will_page[], deadline_at } // FUTURE tense: the page is enqueued, not delivered. idempotencyKey replay // → the ORIGINAL escalation + deduplicated: true, never a second page. // warnings[] when the org has nobody configured to page. get(orgId, escalationId, { include? }) // the wait-for-human loop; poll until // status === "acknowledged" // include: "delivery" → delivery_attempts[] // (what ACTUALLY landed, from the audit log) list(orgId, { status?, limit?, cursor? }) // { escalations, scope, has_more, next_cursor } // scope "own" for a delegate, "organization" for a member ack(orgId, escalationId) // first writer wins; replay reports the ORIGINAL resolve(orgId, escalationId, note?) ackWithToken(token) // the hosted one-tap page's call raiseAndWait(orgId, input, { pollMs?, timeoutMs? }) // raise + poll until acknowledged. On timeout returns the still-OPEN escalation // rather than throwing — an unanswered page is an answer, and silence is not consent. listContacts(orgId) // { escalation_contacts: [...] } addContact(orgId, { email, displayName?, level? }) // OWNER + passkey step-up removeContact(orgId, contactId) // OWNER + passkey step-up ``` Contacts are attention policy, never authorization — a contact row grants nothing. `level` is an ordering: level 1 is paged first, level 2 only if level 1 lets the deadline lapse, and unstaffed levels are skipped. An address with no verified operator email is accepted with a `warnings[]` reachability note rather than rejected, because the human you most want at the top of a chain may hold no platform credential at all. ### `r.buzz.notifications` Project-event routing into a Buzz community channel. A route is an owner-declared destination: one ACTIVE community installation, an explicit 1–50 project scope, reviewed event filters, one NIP-29 channel. The workflow is **configure → authorize → test → live**: create the route, a Buzz community owner or admin adds the returned `notification_pubkey` as a relay member (the one non-secret handoff), then a test delivery proves the membership landed and activates the route. Buzz is NEVER a deadman channel — mandatory notification classes keep their human paths regardless of route state, and a Buzz delivery acknowledges nothing. ``` createRoute(orgId, { installationId, routeName, buzzChannelId, projectIds, eventTypes?, eventClasses?, idempotencyKey? }) // → the route + authorization: "authorized" (live now) or // "pending_buzz_authorization" with the exact non-secret connect handoff. list(orgId) // BuzzEventRoute[], retained revoked ones included get(routeId) // + honest health (route + credential state, // never queue emptiness), delivery_counts, // consumer_cursor update(routeId, patch, expectedRevision) // stale revision → 409 BUZZ_ROUTE_REVISION_STALE // without mutating; re-read, re-send pause(routeId, idempotencyKey?) // stop matching NEW events; nothing retroactive resume(routeId, idempotencyKey?) // re-arm + reset the hard-failure counter; // needs a live signing credential NOW rotate(routeId, idempotencyKey?) // STAGES the next signing generation; the swap // activates only after the next pubkey's own // Buzz-side membership verifies revoke(routeId, idempotencyKey?) // cancel queued deliveries; sanitized history // stays readable; notification_credential_destroyed // only on the installation's LAST live route test(routeId, idempotencyKey?) // 202 queued-not-delivered; doubles as the // authorization poll on a pending route deliveries(routeId, { limit?, cursor?, deliveryId? }) // keyset newest-first, dead letters included, the signed envelope never testAndWait(routeId, { pollMs?, timeoutMs?, onPoll? }) // test + poll until terminal. On timeout returns the still-queued delivery // rather than throwing — the tick publishes ~every 60s, so silence is // cadence, not failure (the shared waitFor contract). ``` Only three reviewed event types are routable (`deploy_activated`, `error_fingerprints_observed`, `platform_incident`); the classes `security` / `billing_critical` / `destructive_lifecycle` / `verification` / `recovery` may never be routed. Filters: omitted/`null` = everything registered; an explicit `[]` is a 422, never a wildcard. Routes deliver NEW events only (`start_after_event_id` floor); delivery is at-least-once with byte-identical republish, backing off 1m/5m/30m/2h/12h to 8 attempts or 48h, then `dead_letter` — visible in `deliveries()`. Ten consecutive hard failures auto-pause the route (`pause_reason: "delivery_failures"`) and fire the mandatory `buzz_route_auto_paused` operator notification. No response ever contains the signing secret — `notification_pubkey` + `signing_generation` are the only credential material on the wire. Every mutation carries an `Idempotency-Key` (auto-generated when omitted) and requires fresh `buzz.event_route` step-up server-side (a SIWX wallet is inherently fresh). ### `r.gitvault` The host-blind encrypted Git remote (`r402s/v0`). All protocol behaviour — crypto core, keystore, creation journal, snapshot + capture, publication state machines, ref transactions, verification budget, token exchange, repair — is implemented ONCE here. `run402 gitvault …`, `git-remote-run402`, and the MCP tools are adapters over this namespace: argument parsing, TTY output, exit codes, and local file I/O only. Anything the CLI can do is reachable programmatically with identical semantics. **What Run402 claims about it.** These are the entire approved claims vocabulary: 1. **Run402 cannot decrypt your gitvault or repository history. Deployment artifacts remain a disclosed plaintext custody boundary.** Cryptographic, against Run402 itself: source payload and repository-history content are ciphertext-only; the substrate retains only enumerated plaintext metadata and holds zero vault keys. 2. **Activation requires vault admission by default; an explicit, audited override can bypass it.** An operational platform invariant, not a cryptographic one. 3. **Retention is an operational promise of the platform, not a cryptographic guarantee against it** (the host controls timestamps and bytes). **Isomorphic / Node split.** Vault reads need nothing but the HTTP client and run anywhere. The verbs that touch a git working tree or the on-disk keystore are Node-only and are reached through DYNAMIC imports, so importing `@run402/sdk` in a browser or worker never pulls `node:fs` into the graph. Calling a Node-only verb outside Node throws a `LocalError` with code `GITVAULT_NODE_ONLY` rather than a module-resolution crash. Read side (isomorphic — `@run402/sdk` or `@run402/sdk/node`): ``` get(repoId): Promise // the vault record: policy, allocation generation, storage + maintenance state forProject(projectId): Promise // cold-restart lookup — resolve repo_id with no local state heads(repoId, { after_generation, limit, cursor? }): Promise allHeads(repoId, { after_generation, limit? }): Promise<{ heads, pages, total }> setPolicy(repoId, { gitvault_policy, reason? }): Promise<{ gitvault_policy, gitvault_policy_version, changed, warnings }> completeOverride(repoId, { operation_id, capture_receipt }): Promise<{ operation_id, advisory_cleared, generation, head_sha256 }> acquireMaintenanceLease(request): Promise ``` Write side (Node only — `@run402/sdk/node`; every one of these takes `{ repo_dir?, repo_id?, project_id? }`): ``` init({ repo_dir, project_id, ... }): Promise // allocate + genesis; prints the one-shot recovery receipt push({ snapshot?: { message?, ... }, checkpoint?, ... }): Promise status(opts?): Promise // pass { refs: true } to also materialize the ref map + HEAD target compact(opts?): Promise prune(opts?): Promise // plan; pass { submit } with both verifier receipts to submit verify(opts?): Promise deploy(opts): Promise // the push-gated deploy (raw; takes an injected lane — see applyWithGitvault below) restore({ target_dir, ... }): Promise<{ refs, generation }> // the clone-back path git-remote-run402 fetch drives; index-packs objects and leaves ref creation to the caller scaffoldRemote({ repo_dir, org_id, project_id, remote_name?, remote_url? }): Promise<{ name, url, created_repository, already_present, existing_url }> open(opts?): Promise // the raw protocol object, for ref transactions or repair drainOverrides(opts?): Promise ``` ```ts import { run402 } from "@run402/sdk/node"; const r = run402(); // Read side — runs anywhere, including a browser or a worker. const vault = await r.gitvault.forProject("prj_123"); // cold restart: no local state needed const page = await r.gitvault.heads(vault.repo_id, { after_generation: "0000000000000001", limit: "100" }); // Write side — Node only (keystore + git working tree). const pushed = await r.gitvault.push({ project_id: "prj_123", snapshot: { message: "wip: refactor the parser" } }); const state = await r.gitvault.verify({ project_id: "prj_123" }); ``` `heads` paging (D186): `after_generation` is the REQUIRED verification anchor — a semantic input, never a paging knob — and must stay CONSTANT across a page sequence. `limit` is required. `cursor` is omitted on the first request and is then the prior page's `next_cursor` echoed UNCHANGED. `allHeads` is the convenience wrapper that walks the sequence for you. #### Deploying a vaulted project — `applyWithGitvault` `r.gitvault.deploy(...)` is the raw push-gated machine and takes an injected lane. `applyWithGitvault` (`@run402/sdk/node`) is the supplied one: it reads the project's `gitvault_policy`, and only a `required` project captures at all. ```ts import { applyWithGitvault, run402 } from "@run402/sdk/node"; import type { ReleaseSpec } from "@run402/sdk"; const r = run402(); const spec: ReleaseSpec = { project: "prj_123", site: { replace: { "index.html": "

hi

" } } }; const { mode, deploy, gitvault } = await applyWithGitvault({ sdk: r, spec, // the same ReleaseSpec `r.project(id).apply` takes apply: { idempotencyKey: "deploy-42" }, // the same options, passed through untouched repo_dir: process.cwd(), onCommitLine: (line) => process.stderr.write(`${line}\n`), // `gitvault_commit ` }); if (gitvault?.outcome === "DEPLOYED_AND_VAULTED") { console.log(mode.kind, deploy?.operation_id); // `deploy` is the usual DeployResult } ``` `mode` says what happened about the vault: `{ kind: "vaulted" }`, `{ kind: "grandfathered" }`, or `{ kind: "none" }`. For anything but `vaulted` this is `apply()` and nothing else — no capture, no token, no added refusal, and the only added cost is the single policy read that determined the project is not `required`. `gitvault` is `null` on those paths and carries the five-outcome envelope on the vaulted one. **A vaulted apply never auto-retries.** Each attempt plans a new operation, and an activation token is minted for exactly one; retrying under a fresh capture would paper over a refusal (revoked, expired, bound elsewhere) that is the platform telling you something true. `maxRetries` is forced to 0 on this path. **Snapshot correspondence, and its exact scope.** The client digests the captured file set at capture and re-derives it after artifacts are collected, before the plan commits. A difference refuses the deploy with **`SNAPSHOT_MOVED_DURING_DEPLOY`** — a `LocalError` whose `details` name the `modified` / `added` / `removed` paths plus the `gitvault_commit`, `capture_id`, and both digests. It is **client-local**: it is detected before anything is committed, never crosses the wire, and therefore is not in the protocol's error registry. The client refuses and stops; it does not re-capture and continue, because a second capture would publish a snapshot whose relationship to the already-collected artifacts is exactly the thing in doubt. The captured set is tracked plus untracked-but-not-ignored — so a build that rewrites gitignored output between capture and commit proceeds, by design. **What the vault records is the source a release corresponds to; the artifacts are not proven to be derived from it.** The guarantee is that the captured source did not change while the artifacts were produced, not that the artifacts are a reproducible function of that source. `captureSnapshot()` carries the same set on every snapshot: `snapshot.captured` (`{path, mode, oid}[]`) and `snapshot.captured_digest`. `deriveCapturedSet({ top_level, global_excludes_path })`, `capturedSetDigest(files)`, and `diffCapturedSets(before, after)` are exported for callers building their own lane. **A `required` project needs the vault keystore on the deploying machine** — the capture is encrypted client-side and the platform holds no key that could produce it. Without it the deploy refuses with the protocol's own `KEYSTORE_MISSING` / `GITVAULT_REPO_STATE_MISSING`, with next actions leading on restoring the keystore and on `setPolicy(repoId, { gitvault_policy: "grandfathered", reason })` (owner + step-up, audited, doctor-persistent advisory). **Nothing here is memoised.** Two of these responses are secret-bearing — the maintenance lease's `holder_token` (returned exactly once) and anything derived from the keystore — and a secret-bearing response is never cached, never persisted into an agent-surface result store, and never logged. `gitvaultRemoteUrl(orgId, projectId)` and `parseGitvaultRemoteUrl(url)` are exported helpers for the `run402::/` remote URL form that `git-remote-run402` serves. **`status()` never mutates and never mints.** It READS the keystore rather than calling `ensureIdentity()`, so observing a vault cannot create the key material it is reporting on. Its `keystore.root` / `keystore.paths` name the directory to back up (see terminal loss below), `remote` reports the local `run402` git remote and whether it points at THIS vault, and `refs` / `head_target` are `null` unless `{ refs: true }` was passed — reading the ref map means materializing the chain, which is a verification and advances the local materialized pin. **`resolveGitInvocationRepo(env?, cwd?)`** (Node) resolves — and proves — the repository git invoked a remote helper for, from `GIT_DIR` rather than `process.cwd()`, and throws `GIT_INVOCATION_REPO_UNRESOLVED` rather than guessing. Any consumer that writes git objects on git's behalf should route through it: during `git clone`, cwd is the directory clone was run FROM, which is routinely an unrelated repository. **Terminal loss (protocol §0).** In V0-A, **whole-machine or whole-keystore loss is terminal for vault history until human envelopes ship**. `status()` carries the statement verbatim in `terminal_loss_statement` / `terminal_loss_detail`. The vault protects source history from host-side loss while a principal keystore survives. Back up the keystore directory `status()` reports as `keystore.root` — `~/.config/run402/gitvault` for the default wallet, `~/.config/run402/profiles//gitvault` for a named one. The recovery receipt is an integrity anchor, not a decryption key. `r402s-verify` is the deliberate exception to "all protocol logic lives in the SDK": an independent second lineage that must NOT share implementation code with this namespace, because differential verification is its entire purpose. ### `r.errors` Grouped error fingerprints + a release-baselined promote-vs-revert verdict — "did my new release make things worse?". Also project-scoped as `r.project(id).errors.{list,get,watch}(…)`. ``` list(projectId, ListErrorsOptions): Promise get(projectId, fingerprintId): Promise watch(projectId, WatchErrorsOptions): Promise // ListErrorsOptions = { since?, until?, function?, kind?, fingerprint?, newIn?, limit?, cursor? } // newIn (a release id or "active") → wire param new_in; drives verdict.new_fingerprints + baseline. // ErrorsPage = { verdict, errors: ErrorFingerprint[], has_more, next_cursor? } // verdict = { window{since,until}, compared_release_id, baseline_release_id, // new_fingerprints, recurring_fingerprints, invocations_in_window, // coverage{full_fidelity_functions, coarse_functions}, row_cap{limit, at_cap} } // ErrorFingerprint = { fingerprint_id, function, kind, fingerprint_quality, error_name, // message_template, stable_frames[], count, first_seen, last_seen, // first_seen_release_id, last_seen_release_id, samples{first, recent[]}, next_actions[] } // WatchErrorsOptions = { newIn (required), durationMs?=600000, intervalMs?=15000, signal?, onPoll?, failFast?=true } // WatchErrorsResult = { clean, verdict, new_errors: ErrorFingerprint[], polls, elapsed_ms, aborted? } ``` **The verdict math is the GATEWAY'S — the SDK never recomputes it.** No client-side fingerprinting, re-baselining, or re-counting of `new_fingerprints`; `list` / `get` pass the envelope through untouched and `watch` reads `verdict.new_fingerprints` as the truth (`clean === (verdict.new_fingerprints === 0)`, the gateway's number). The **baseline** is the previously ACTIVE release by activation history (not lineage) — rollback-safe: after A → B → rollback to A → C, C's baseline is A. Cursors (`next_cursor`) are opaque keyset tokens — store and echo as `{ cursor }`, never parse. `watch` is the promote-gate poll loop: run it right after an apply/promote activates a release. It polls immediately, then every `intervalMs`, plus one final poll when `durationMs` elapses; with `failFast` (the default) it stops the moment a poll reports a new identity. **An outage can never masquerade as a clean verdict:** a 4xx other than 408/429 rethrows immediately (auth/validation won't heal), while network errors, 5xx, 408, and 429 are tolerated — but three CONSECUTIVE failed polls rethrow the last error (a success resets the counter). `signal` aborts cleanly: with ≥1 successful poll it returns the result-so-far with `aborted: true`, otherwise it throws. Auth: the addressed project's OWN key (apikey-authed read). A key for a different project gets `403`, never a `404` that would confirm existence. Read-only; never lifecycle-gated. ### `r.email` ``` createMailbox(projectId, slug): Promise // NOT idempotent listMailboxes(projectId): Promise setMailboxDefaults(projectId, { default_outbound_mailbox_id?: string | null, auth_sender_mailbox_id?: string | null }): Promise updateMailbox(projectId, { mailbox?: string, footer_policy: "run402_transparency" | "none" }): Promise getMailbox(projectId, mailbox?): Promise deleteMailbox(projectId, mailboxId?): Promise send(projectId, opts: SendEmailOptions): Promise // If opts.mailbox is omitted, the SDK uses the configured // default_outbound_mailbox_id when mailbox_settings are present. Missing or // invalid defaults throw typed ApiError envelopes such as // DEFAULT_MAILBOX_REQUIRED / DEFAULT_MAILBOX_INVALID with details.candidates // and next_actions. Successful sends echo mailbox_id/from_address when the // gateway returns them. // opts.attachments?: { filename, content_base64, content_type }[] — RAW MODE // ONLY (subject + html, not template). Max 5; ≤ 7 MB total decoded. Sent as a // multipart/mixed MIME. Sent messages echo attachments_meta (names/types/sizes). list(projectId, opts?: { limit?, after?, direction? }): Promise // direction?: "inbound" | "outbound" — omit for BOTH. direction:"inbound" lists // received replies (each EmailSummary carries `direction`) and is the // reconciliation backstop if a reply_received webhook is ever lost. get(projectId, messageId): Promise getRaw(projectId, messageId): Promise // bytes + content_type // Webhooks (sub-namespace): webhooks.register(projectId, opts: { url, events }): Promise webhooks.list(projectId): Promise webhooks.get(projectId, webhookId): Promise webhooks.update(projectId, webhookId, opts: { url?, events? }): Promise webhooks.delete(projectId, webhookId): Promise webhooks.listDeliveries(projectId, opts?: { status?, limit?, after? }): Promise // Durable delivery is AT-LEAST-ONCE with bounded retries + exponential backoff. // Failures that exhaust the budget (or fail permanently) become status // "failed_permanent" — the dead-letter queue. status?: pending | in_flight | // delivered | failed_permanent. The delivered body is the canonical envelope // { id, type, created_at, schema_version, idempotency_key, payload }; consumers // MUST dedupe on idempotency_key (also the Run402-Webhook-Id header). Mailbox // webhooks are unsigned (verifyWebhook is for operator notifications only). webhooks.redriveDelivery(projectId, deliveryId): Promise // Re-queue a dead-lettered delivery for another attempt (after fixing the consumer). // CLI-style aliases: create(projectId, slug): Promise status(projectId): Promise info(projectId): Promise update(projectId, opts): Promise delete(projectId, mailboxId?): Promise ``` `MailboxRecord` includes default/readiness/footer-policy metadata when the gateway provides it: `is_default_outbound`, `is_auth_sender`, `can_send`, `send_blocked_reason`, `domain_kind`, `footer_policy`, `effective_footer_policy`, and `footer_policy_locked_reason`. `updateMailbox` PATCHes `/mailboxes/v1/:mailbox_id` for `footer_policy`; `none` requires hobby/team, while prototype projects are locked to `run402_transparency` and return the typed gateway error `FOOTER_POLICY_TIER_REQUIRED`. `MailboxListResult` and create/settings responses may include `mailbox_settings` and `next_actions`; the happy path is create → list → set missing defaults → optionally update footer policy → send. Templates: `project_invite`, `magic_link`, `notification`. Or pass `subject` + `html` for raw mode. Raw mode also accepts `attachments` (max 5, ≤ 7 MB total) — a multipart/mixed MIME is sent. Tier rate limits: prototype 10/day, hobby 50/day, team 500/day. ### `r.auth` ``` requestMagicLink(projectId, opts: | { email, delivery?: "link", redirectUrl, intent?, clientState? } | { email, delivery: "both", redirectUrl, intent?, clientState? } | { email, delivery: "code", redirectUrl?, intent?, clientState? } ): Promise // { message, warnings?, challengeId? } verifyMagicLink(projectId, token): Promise // { access_token, refresh_token, ... } verifyEmailCode(projectId, { challengeId, code }): Promise createUser(projectId, opts: { email, isAdmin?, sendInvite?, redirectUrl?, clientState? }): Promise inviteUser(projectId, opts: { email, isAdmin?, redirectUrl, clientState? }): Promise setUserPassword(projectId, opts: { accessToken, newPassword, currentPassword? }): Promise settings(projectId, opts: { allow_password_set?, preferred_sign_in_method?, public_signup?, require_passkey_for_project_admin? }): Promise createPasskeyRegistrationOptions(projectId, opts: { accessToken, appOrigin }): Promise verifyPasskeyRegistration(projectId, opts: { accessToken, challengeId, response, label? }): Promise createPasskeyLoginOptions(projectId, opts: { appOrigin, email? }): Promise verifyPasskeyLogin(projectId, opts: { challengeId, response }): Promise listPasskeys(projectId, opts: { accessToken }): Promise<{ passkeys: PasskeyRecord[] }> deletePasskey(projectId, opts: { accessToken, passkeyId }): Promise providers(projectId): Promise // magic_link.deliveryModes; absent wire field → ["link"] promote(projectId, email): Promise demote(projectId, email): Promise // CLI-style aliases: magicLink(projectId, opts): Promise verify(projectId, token): Promise setPassword(projectId, opts): Promise promoteUser(projectId, email): Promise demoteUser(projectId, email): Promise ``` Magic-link tokens are single-use, expire in 15 min, rate-limited 5/email/hour. Google OAuth is on for all projects with zero config. ### `r.apps` ``` browse(tags?: string[]): Promise getApp(versionId): Promise fork(opts: { versionId, name, subdomain? }): Promise publish(projectId, opts?: { description?, tags?, visibility?, fork_allowed? }): Promise listVersions(projectId): Promise updateVersion(projectId, versionId, opts: { description?, tags?, visibility?, fork_allowed? }): Promise deleteVersion(projectId, versionId): Promise ``` Forking clones schema + site + functions into a new project. If the source has a `bootstrap` function, it runs automatically with the variables you pass; result includes `bootstrap_result` or `bootstrap_error`. ### `r.tier` ``` set(tier: "prototype" | "hobby" | "team", opts?: { idempotencyKey? }): Promise // idempotencyKey → Idempotency-Key header (caller-supplied; not auto-derived) status(): Promise ``` Tier is per **organization**, not per project. `set` applies to every project on the organization; `status.pool_usage` (`projects`, `total_api_calls`, `total_storage_bytes`, `api_calls_limit`, `storage_bytes_limit`) sums across every non-terminal project on the organization — including every wallet linked to it via `billing.linkWallet` — not just the requesting wallet. Use the returned `pool_usage` as the authoritative quota- enforcement view; per-project `r.projects.getUsage(id)` reports the same organization-level caps alongside that project's slice of the pool. **v1.57:** `TierStatusResult` also surfaces two optional organization fields: - `organization_lifecycle_state?: "active" | "past_due" | "frozen" | "dormant" | "purged"` — mirror of the owning organization's lifecycle state. Identical to the per-project `organization_lifecycle_state` on every `list()` entry. - `lease_perpetual?: boolean` — operator escape hatch flag. When `true`, the organization never advances past `active`. Both are optional because older gateways do not return them at the top level. `set` auto-detects subscribe / renew / upgrade / downgrade based on current state. For tier pricing, call `r.projects.getQuote()` (the SDK does not expose a separate `tier.quote()` method). #### Quota-denial `scope` discriminator (v1.46+) Quota-related error envelopes carry `details.scope: "organization" | "project"` so consumers can distinguish organization-pooled denials from the orphan fallback (project whose billing organization row was purged but cascade has not yet run). The SDK lifts this onto every `Run402Error` subclass as `e.quotaScope`, and exports a `getQuotaScope(e)` helper for non-`Run402Error` `unknown` inputs. Absent for non-quota errors and for pre-v1.46 gateways. ### `r.billing` ``` createEmailOrganization(email): Promise linkWallet(organizationId, wallet): Promise // organizationId = UUID; POST /orgs/v1/:org_id/wallets createCheckout(organizationId, checkout: { product: "balance_topup", amountUsdMicros: number } | { product: "tier", tier: "prototype" | "hobby" | "team" } | { product: "email_pack" }): Promise setAutoRecharge(opts: { organizationId: string, enabled: boolean, threshold? }): Promise checkBalance(identifier): Promise // identifier = organization id (UUID) | wallet | email getOrganization(identifier): Promise lookupOrganization(identifier): Promise // resolve wallet/email → organization (incl. organization_id) balance(identifier): Promise // alias of checkBalance history(identifier, limit?: number): Promise getHistory(identifier, limit?: number): Promise // CLI-style aliases: createEmail(email): Promise autoRecharge(opts): Promise ``` Organizations are addressed by their canonical `organization_id` (UUID). `getOrganization` / `checkBalance` / `history` accept an organization id, wallet, or email: an organization id reads `GET /orgs/v1/:org_id/billing` directly, while a wallet/email is resolved through the `GET /orgs/v1/lookup?wallet=|?email=` lookup (also exposed as `lookupOrganization`). The detail shape includes `organization_id`. Organization reads require SIWX from a wallet **linked to** the organization (or matching the looked-up `?wallet`), or an admin key — email lookups are admin-only; `history` resolves to the organization id first, then reads `GET /orgs/v1/:org_id/billing/history`. `linkWallet` merges a wallet into an existing organization's pool. The response includes a `pool_implications` block (`tier`, `projects_in_pool_count`, `organization_api_calls_current`, `organization_storage_bytes_current`, `tier_limits.{api_calls,storage_bytes}`, `over_limit`) so callers can warn before linking a wallet whose existing usage would push the merged pool past the tier cap. ### `r.contracts` ``` provisionSigner(projectId, opts: { chain: "base-mainnet" | "base-sepolia", recoveryAddress? }): Promise getSigner(projectId, signerId): Promise listSigners(projectId): Promise setRecovery(projectId, signerId, recoveryAddress: string | null): Promise setLowBalanceAlert(projectId, signerId, thresholdWei: string): Promise call(projectId, opts: { signerId, chain, contractAddress?, to?, abiFragment?, abi?, functionName?, fn?, args, value?, idempotencyKey? }): Promise deploy(projectId, opts: { signerId, chain, bytecode, value?, idempotencyKey? }): Promise // bytecode = full creation calldata (creation bytecode + ABI-encoded ctor args concatenated by caller); ≤ 128 KB. Returns deterministic CREATE address synchronously in `contract_address`. read(opts: { chain, contractAddress?, to?, abiFragment?, abi?, functionName?, fn?, args }): Promise callStatus(projectId, callId): Promise drain(projectId, signerId, destinationAddress): Promise deleteSigner(projectId, signerId): Promise // refused if balance ≥ dust // CLI-style aliases: setAlert(projectId, signerId, thresholdWei): Promise status(projectId, callId): Promise delete(projectId, signerId): Promise ``` Private keys never leave AWS KMS. **$0.04/day rental + $0.000005/call.** Signer creation requires $1.20 cash credit. Non-custodial. The SDK exports typed metadata and call-result envelopes (`SignerSummary`, `ContractCallResult`, `ContractReadResult`, etc.); contract ABI results and receipts remain `unknown` inside those envelopes and should be narrowed at the call site. ### `r.ai` ``` translate(projectId, opts: { text, to, from?, context? }): Promise moderate(projectId, text): Promise usage(projectId): Promise generateImage(opts: { prompt, aspect? }): Promise // $0.03 via x402, no projectId ``` `GenerateImageResult` is `{ image, content_type, aspect, payment }`. `payment` is the settlement **observed** for that call, decoded from the response's `PAYMENT-RESPONSE` receipt: ``` payment: { success, network, transaction, payer } | null ``` `null` means the response carried no receipt — no payment was made on this request, NOT that one failed. **Surface `network` to whoever is watching.** `run402 init` faucet-funds Base Sepolia (`eip155:84532`), so the documented quickstart pays in test money; without the network a caller can watch a payment succeed with no way to know it was not real, and the claims wall will then refuse the very transaction they just made. Derive any "this was testnet" message from `payment.network`, never from local wallet configuration — a buyer holding mainnet funds makes a config-derived guess wrong. The same value rides `ResponseEnvelope.settlement` for any request that settles, so `requestWithResponse` callers get it too. The key is omitted entirely when nothing settled, so existing envelope shapes are unchanged. `r.image` is an alias of `r.ai`, so CLI readers can translate `run402 image generate ...` to `r.image.generateImage(...)`. ### `r.allowance` ``` status(): Promise create(): Promise export(): Promise // address only, never the private key faucet(address?: string): Promise ``` `faucet` defaults to the local allowance's address when no argument is passed. The Node entry's credentials provider also writes a `lastFaucet` marker after success — surfaced via `status().faucet_used`. ### `r.vouchers` ``` redeem(code: string): Promise ``` Redeems a promo code (e.g. `R402-K8F3-Q2W9`) into the authenticated wallet's organization as prepaid credit. That credit settles tier purchases and priced calls through the allowance rail — no on-chain payment. - **Order-independent.** Works as the very first authenticated call a new wallet makes (the organization is provisioned on demand) or long after `init`. - **Idempotent for the redeemer.** A repeat by the same organization returns the ORIGINAL result with `already_redeemed: true` and never credits twice, so a timed-out call is safe to re-issue. A different organization gets `VOUCHER_ALREADY_REDEEMED` (409). - **Send the code verbatim.** The gateway owns the grammar and is forgiving (case-insensitive, hyphens optional, Crockford confusables mapped); a client-side format check would only reject codes the server accepts. - Other failures: `VOUCHER_NOT_FOUND` (404 — unknown *or* malformed, the same answer on purpose), `VOUCHER_EXPIRED` (410), `PROMO_LIMIT_REACHED` (403). `RedeemVoucherResult` carries `amount_usd_micros`, the post-credit `balance_usd_micros`, `organization_id`, `redeemed_at`, `already_redeemed`, `promo_lifetime_ceiling_usd_micros`, and `next_actions[]` (usually the tier the new balance now covers, with a ready-to-run `cli` string). ### `r.service` ``` status(): Promise // 24h/7d/30d uptime per capability — no auth, no setup health(): Promise // per-dependency liveness — no auth, no setup ``` ### `r.wallet(address)` ``` r.wallet(address).getLabel(): Promise r.wallet(address).setLabel(label: string): Promise<{ ok: boolean }> ``` The signed server-side wallet label (gateway `/wallets/v1/:address/label`) that surfaces the human-readable named-wallet name in the operator console. Use the `r.wallet(address)` scope handle so the address isn't a swappable positional. The label is pushed automatically on `run402 wallets use` unless `RUN402_WALLET_LABEL_SYNC=0`. (`r.wallets.getLabel(address)` remains valid as a bare read. For org/grants control-plane identity, see "Org membership & project grants" below.) ### `r.cache` (gateway v1.52+, paired with `@run402/astro` v1.0+) SSR origin-cache inspection + invalidation for the Astro SSR Runtime. Capability `ssr-isr-cache`. ``` invalidate(url: string | URL): Promise invalidatePrefix({ host, prefix }): Promise invalidateAll({ host }): Promise invalidateMany(urls: Array): Promise inspect(url: string | URL, opts?: { locale?, releaseId? }): Promise ``` `CacheInvalidateResult`: ```ts interface CacheInvalidateResult { deleted: number; // post-increment per-(project, host) counter, as string for bigint safety generation: string; host: string; // populated on single-URL form path?: string; // populated on invalidateMany results?: Array<{ host: string; deleted: number; generation: string }>; } ``` `CacheInspectResult`: ```ts interface CacheInspectResult { // NEVER "BYPASS" — inspect doesn't issue a request status: "HIT" | "MISS"; url?: string; host?: string; path?: string; search?: string; method?: string; locale?: string; releaseId?: string; // ISO8601 cachedAt?: string; // ISO8601 expiresAt?: string; writtenUnderGeneration?: string; // hex contentSha256?: string; headers?: Record; } ``` Project-scoped. Cross-project absolute URLs throw `R402_CACHE_INVALIDATION_HOST_FORBIDDEN`. Path-string `invalidate('/path')` form requires active request context to resolve the host from ALS; outside a context, throws `R402_CACHE_INVALIDATION_HOST_REQUIRED`. **Inside an Astro `[slug].astro` admin save flow:** ```ts import { db, cache } from "@run402/functions"; declare const slug: string; declare const title: string; declare const html: string; await db().from("pages").insert({ slug, title, html }); await cache.invalidate(`/${slug}`); // sub-second freshness ``` **From an admin-side function in a different host:** ```ts import { cache } from "@run402/functions"; declare const slug: string; await cache.invalidate(new URL(`https://eagles.kychon.com/${slug}`)); ``` Tag-based invalidation deferred to v1.5. Client-side (browser) invalidation NOT in v1 — server-side function context only. ### `r.admin` Operator/admin endpoints. Most agents won't reach for these — they're for platform operators. ``` sendMessage(message: string): Promise setAgentContact({ name, email?, webhook? }): Promise getAgentContactStatus(): Promise verifyAgentContactEmail(): Promise startOperatorPasskeyEnrollment(): Promise getProjectFinance(id: string, opts?: { window?: "24h" | "7d" | "30d" | "90d", cookie?: string }): Promise // operator-only org + project actions — canonical via scope handles r.admin.org(orgId).pinLease() / .unpinLease(): Promise r.admin.project(projectId).archive(opts?: { reason?: string }): Promise r.admin.project(projectId).reactivate(): Promise r.admin.project(projectId).finance(opts?): Promise ``` `AgentContactResult` includes `email_verification_status`, `passkey_binding_status`, `assurance_level`, proof timestamps, and cooldown fields. Assurance labels are `wallet_only`, `email_pending`, `email_verified`, `passkey_pending`, and `operator_passkey`; they describe mailbox/passkey continuity, not a humanhood or uniqueness claim. `startOperatorPasskeyEnrollment()` requires `email_verified` and emails the token to the verified contact email instead of returning it. `getProjectFinance` reads the internal Finance-tab JSON for a project. It is platform-admin gated; a project `service_key` is not enough. In Node operator scripts, use an admin allowance wallet or pass `cookie: process.env.RUN402_ADMIN_COOKIE` for browser-session auth. **v1.57 — operator-only project + organization actions.** Gateway v1.57 moved the lifecycle state machine from `internal.projects` to `internal.organizations` and dropped the per-project `pin` / `unpin` endpoints. The replacements: - `r.admin.org(orgId).pinLease()` / `.unpinLease()` — toggle the organization-level escape hatch. When `lease_perpetual` is `true`, the organization never advances past `active` regardless of lease expiry; every project on the organization is pinned. Pinning a grace-state organization (`past_due` / `frozen` / `dormant`) reactivates inline — the response carries `reactivated: true`. This also replaces the v1.56 `projects.pin(id)` method (removed in v2.x SDK). - `archiveProject(projectId, { reason? })` — operator moderation. Sets `projects.archived_at = NOW()` on a single project; sibling projects on the same organization keep serving. No-op when already archived (returns `note: "already archived"`). - `r.admin.project(projectId).reactivate()` — un-archive a project (flips `archived_at` back to NULL). In v1.57 this was narrowed: it does NOT touch organization-level lifecycle. To reactivate a grace-state organization, either call `r.tier.set(tier)` (the tier flow runs the lifecycle advance inline) or `r.admin.org(org_id).pinLease()`. All three require platform-admin auth. Result envelopes: ``` SetLeasePerpetualResult: { status, organization_id, lease_perpetual, reactivated } ArchiveProjectResult: { status, project_id, archived_at?, reason?, note? } // note: "already archived" ReactivateProjectResult: { status, project_id, reactivated?: true, note? } // note: "not archived" ``` ### `r.admin.channels` + `r.admin.rules` (Telegram notification channel + routing rules) Self-serve Telegram push on top of the v1.55 operator-notifications substrate: connect a chat, then add filter rules so ONLY matching events page that chat. Two sub-namespaces on `r.admin`, same shape as `r.admin.transfers`. ``` r.admin.channels.connectTelegram(opts?: { label?: string }): Promise r.admin.channels.list(): Promise r.admin.channels.revokeTelegram(bindingId: string): Promise r.admin.rules.list(): Promise r.admin.rules.create(input: CreateRoutingRuleInput): Promise r.admin.rules.update(ruleId: string, patch: UpdateRoutingRulePatch): Promise r.admin.rules.delete(ruleId: string): Promise ``` `connectTelegram` returns two single-use, 15-minute deep links — `connect_url` (private chat) and `connect_group_url` (group chat) — plus a `pending` binding id. A human taps ONE of the links and starts the bot; poll `r.admin.channels.list()` until the matching entry in `telegram[]` shows `status: "active"` (or `code_expires_at` passes and it's swept back to `"revoked"`). Until the platform's dedicated bot is provisioned on this deployment, `connectTelegram` throws with `code: "TELEGRAM_CHANNEL_NOT_CONFIGURED"` (HTTP 503, with a `next_actions` entry); a caller with no verified operator email yet gets `code: "OPERATOR_EMAIL_NOT_VERIFIED"` (HTTP 412) — bindings are addressed to the verified email, the recipient grain every rule/binding keys on. `connectTelegram` / `revokeTelegram` require `operator_passkey` assurance (same ladder as `rotateWebhookSecret`); `list()` is a plain SIWX read. **Routing rules (design D4).** One rule always targets exactly one Telegram binding — "N destinations" is N rules. Every match dimension you set (`projectId`, `source`, `eventTypes`, `classes`) is ANDed; an OMITTED field is a wildcard (matches anything for that dimension); an explicit empty array (`eventTypes: []`) matches NOTHING (Postgres `TEXT[]` semantics — deliberately different from the "`[]` = unfiltered" convention some read-filter query params use elsewhere in this SDK). `source` is `"app"` (a deployed function's `events.emit(...)` calls) or `"platform"` (deploys, lifecycle, verification, ...); omit to match both. **No rules = no Telegram traffic** for that operator — the channel is opt-in per event, per rule, with no "send everything" default. Rules govern the Telegram channel ONLY in v1: the mandatory email floor (`security`/`recovery`/`billing_critical`/`destructive_lifecycle`/`verification` classes) is completely untouched and can never be silenced by a rule. `rules.update`'s `patch` uses PATCH semantics at the wire level: a field OMITTED from the object leaves the stored value unchanged; a field explicitly set to `null` CLEARS that dimension back to wildcard. There's no wire difference between "omitted" and "set to `undefined`" — both drop the key from the JSON body, so build the patch object by only assigning the keys you actually want to change. `rules.create`/`rules.update` reject an unusable or foreign `telegramBindingId` (revoked, not yours, or nonexistent) with the SAME 404 either way (authorize-before-reveal) — call `r.admin.channels.list()` first to confirm the binding is `"active"`. `admin.testNotification(opts?: { source?, eventType? })` (v1.55, extended) fires the sample event through the FULL pipeline — email/webhook AND Telegram — and its result carries `telegram: { destinations: [...] }`, one delivered/failed outcome per matched Telegram binding (empty when no rule matches — Faithful, not an error). Pass `opts.source` / `opts.eventType` to exercise a specific rule's filters precisely instead of the default sample event. ### `r.admin.transfers` (unified project transfer, owned-org recipient v1.96+) Project transfer is exposed as a sub-namespace at `r.admin.transfers` — one noun, three recipient shapes. A **wallet** recipient completes via `accept` (both sides sign SIWX); an **email** recipient completes via `claim` (the recipient claims into an org); an **owned org** recipient completes immediately at initiate time in the same-actor first release. `initiate` is body-discriminated (`toWallet` XOR `toEmail` XOR `toOrgId`); `preview` / `cancel` / `listIncoming` / `listOutgoing` are kind-agnostic for pending rows and tag each row with `recipient_kind`. (The pre-v1.93 `*Handoff` methods and `/handoffs` routes are gone.) ``` initiate({ projectId, toWallet, billingPolicy?, message?, kysignedRecordId? }) // wallet recipient : Promise // { transfer_id, expires_at, project_summary, your_unused_lease_days, lease_refundable: false, terms_sha256 } initiate({ projectId, toEmail, message?, retainCollaborator? }) // email recipient : Promise // { status: "ok", transfer_id, to_email, expires_at } initiate({ projectId, toOrgId, message? }) // owned-org recipient, same-actor only : Promise // { status: "accepted", project_id, to_organization_id, transfer_id?, completed_at?, anon_key, service_key, ... } // initiate({ toOrgId }) persists returned keys via saveProject + setActiveProject when supported. // Exactly one of toWallet / toEmail / toOrgId — multiple-or-none throws a local VALIDATION_ERROR before any request. // billingPolicy + kysignedRecordId are wallet-only; retainCollaborator is email-only. preview(transferId: string): Promise // { transfer_id, project_id, status, recipient_kind, from_wallet_display, to_wallet_display, to_email?, to_org_id?, // billing_policy, message, initiated_at, expires_at, terms_sha256, custom_domains[], subdomains[], // functions[], secret_names[] (NEVER values), mailbox_summary, ci_bindings_to_be_revoked[], signers[], // github_repo_note, billing_implications, retain_collaborator? } accept(transferId: string): Promise // WALLET completion // { project_id, from_wallet, to_wallet, new_organization_id, completed_at, // secrets_rotation_advised: true, secret_names_inherited[], secrets_count_inherited, github_repo_note, // anon_key, service_key } // #428: new owner's project keys. accept() persists them via // saveProject + setActiveProject (when the provider supports them), mirroring provision. claim(transferId, { organizationId?, acceptRetainedCollaborator? }): Promise // EMAIL completion // { status: "accepted", project_id, to_organization_id, created_new_org, retained_collaborator_principal_id, // anon_key, service_key } // project-transfer-claim-credentials: symmetric with accept. claim() persists // the keys via saveProject + setActiveProject (when the provider supports them). Claim auth is principal-based // (control-plane session OR verified-email SIWX) — don't assume a wallet is present. cancel(transferId: string, reason?: string): Promise // kind-agnostic // { transfer_id, status: "cancelled", cancelled_by, cancellation_reason, cancelled_at } listIncoming(opts?: { limit?, offset? }): Promise // pending rows, unioned (recipient_kind-tagged) listOutgoing(opts?: { limit?, offset? }): Promise // pending rows, unioned ``` `billingPolicy` defaults to `"migrate"` on wallet transfers (the only Phase 1A policy — the project moves into the recipient's organization). The `kysignedRecordId` field is wallet-only and stored verbatim in Phase 1A; Phase 1B will verify it against the canonical terms hash. Owned-org `toOrgId` moves are same-actor only in the first gateway release: caller must be an active owner of both source and destination orgs. Initiate authority is owner-OR-admin. **Email recipient — retain-collaborator (v1.91).** Pass `retainCollaborator: { role: "developer" }` on the email `initiate` to keep a `developer` membership in the recipient's org after the transfer (only `developer` is valid; the subject is always the initiating owner — gateway rejects with `INVALID_RETAIN_ROLE` / `RETAIN_SUBJECT_REQUIRED`). The recipient sees the offer as `ProjectTransferPreview.retain_collaborator` (a `RetainCollaboratorPreview` `{ principal_id, role, sender_label, scope, note, accept_field }`, or `null`) and accepts by passing `acceptRetainedCollaborator: true` to `claim`; the result then carries `retained_collaborator_principal_id` (or `null`). Omitting the accept (the default) is a full severance. While a transfer is `pending` (72h TTL), every owner-side mutation against the project throws `TransferFreezeError` (status 409, code `PROJECT_HAS_PENDING_TRANSFER`). The error carries `transferId`, `projectId`, `cancelPath`, and `previewPath` lifted from the gateway's `next_actions[]`, so agents can present an actionable resolution: ```ts import { run402 } from "@run402/sdk/node"; import { isTransferFreezeError } from "@run402/sdk"; const r = run402(); try { const p = await r.project(projectId); await p.apply({ secrets: { require: ["DB_URL"] } }); } catch (err) { if (isTransferFreezeError(err) && err.transferId) { // err.transferId, err.cancelPath, err.previewPath await r.admin.transfers.cancel(err.transferId); // …retry the mutation here } else { throw err; } } ``` Data-plane traffic (`/rest/v1/*`, `/storage/v1/*`, function invocation, mailbox send/receive) keeps serving during the freeze. Payment-path routes (`tier.set`, `/orgs/v1/:org_id/checkouts`, `/orgs/v1/:org_id/billing/auto-recharge`) keep working. `r.admin.transfers.cancel` is intentionally not blocked. After `accept`, the project carries a persistent `secrets_rotation_advised` advisory — visible on `r.tier.status()` as `projects[].secrets_rotation_advised: { advised_at, reason }`. Use `r.secrets.set(...)` to rotate every name in `secret_names_inherited`; the advisory clears once every previously-inherited name has been re-written. `r.tier.status()` also surfaces `incoming_transfers[]` at the top level (each entry includes `preview_path` and the full pending summary) so a single status call shows pending offers without a separate `listIncoming` fetch. What does NOT transfer: tier lease (stays with the original owner's organization; no Phase 1A proration), KMS signers (`r.contracts.*` — wallet-scoped), GitHub repo ownership (handle out of band), on-chain balance on any wallet. ## Org membership & project grants (`r.orgs`, `r.org(id)`, `r.grants` — v1.77+ org-owned control plane; first-class orgs v1.82) A wallet **authenticates** (SIWX → a control-plane *principal*); an **org** owns projects, and what a principal may do is decided by its org membership role (`owner > admin > developer > billing > viewer`) or a per-project grant — never `wallet_address == signer`. The collection + identity lives on `r.orgs`; per-org operations on the scoped sub-client **`r.org(id)`** (the org analog of `r.project(id)` — the id is bound once). Memberships carry `org_id` + `display_name`. - **`r.orgs.create({ displayName? })`** → `{ org_id, display_name, tier, lease_started_at, lease_expires_at }` (POST `/orgs/v1`). Creates an empty org on the prototype tier; you become owner. Accepts only `displayName` — no tier input. Step-up gated; may throw `ApiError code: "FREE_ORG_OWNER_LIMIT_EXCEEDED"` (429). - **`r.orgs.list()`** → orgs you are an active member of (`OrgMembership[]`, each `{ org_id, display_name, role, status }`). - **`r.orgs.whoami()`** → `{ principal, memberships[], authenticator_id }` (GET `/agent/v1/whoami`). The REMOTE, gateway-resolved identity — distinct from **`r.whoami()`** (local + network-free wallet/profile label, used by `run402 status`). - **`r.org(id).get()`** → `{ org_id, display_name, tier, lease_started_at, lease_expires_at, role }`. Any active member; a non-member (incl. a guessed id) gets the same non-revealing 403. - **`r.org(id).rename(displayName | null)`** → `{ org_id, display_name, tier, lease_started_at, lease_expires_at }`. Owner-only; set or clear the label (`null`/`""` clears). Step-up gated. - **`r.org(id).members.list()` / `.add({ wallet, role? })` / `.setRole(principalId, { role })` / `.revoke(principalId)`** — owner-gated; a new wallet is provisioned as a `human` principal, `role` defaults to `developer`. Removing/demoting the org's only active owner throws `ApiError code: "LAST_OWNER"` (409). - **`r.org(id).invites.list()` / `.create({ email, role, inviteTtlHours? })` / `.revoke(principalId)`** — email invites, claimed automatically at the invitee's first login. - **`r.org(id).audit({ limit?, before? })`** → control-plane audit trail (admin+), newest-first; page with `before`. - **`r.grants.create(projectId, { wallet, capability, policy?, expiresAt? })`** / **`r.grants.revoke(projectId, grantId)`** — per-project capability grants for agent/CI principals; requires owner of the project's org. Also project-scoped: **`r.project(id).grants.create({...})`** / `.revoke(grantId)`. `capability` examples: `"deploy"`, `"functions:write"`. Control-plane denials throw `NotAuthorizedError` (403 `NOT_AUTHORIZED`, carrying `requiredRole` / `requiredCapability` / `reason`). Bad input is `ApiError` `code: "VALIDATION_ERROR"` (400). Exported types: `OrgRole`, `Principal`, `OrgMembership`, `OrgMember`, `WhoAmIResult`, `OrgSummary`, `OrgDetail`, `CreateOrgInput`, `ProjectGrant`, plus input/result types. ## Resource limits | | 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 | Project rate limit: **100 req/sec** — exceeding throws `ApiError` with status 429 and `retry_after` in the body. ## Idempotent migrations `CREATE TABLE IF NOT EXISTS` only handles "already exists" — it won't add new columns. For evolving schemas, wrap `ALTER TABLE` in a `DO` block: ```sql CREATE TABLE IF NOT EXISTS items (id serial PRIMARY KEY, title text NOT NULL); DO $$ BEGIN ALTER TABLE items ADD COLUMN priority int DEFAULT 0; EXCEPTION WHEN duplicate_column THEN NULL; END $$; ``` Safe to re-run on every deploy. ## SQL guardrails The SQL endpoint blocks: `CREATE EXTENSION`, `COPY ... PROGRAM`, `ALTER SYSTEM`, `SET search_path`, `CREATE/DROP SCHEMA`, `GRANT/REVOKE`, `CREATE/DROP ROLE`. Use the expose manifest for access control. ## Stability This package is on the `3.x` line. The in-repo packages (`@run402/sdk`, `run402`, and `run402-mcp`) release in lockstep at the same version. Pin an exact version in production dependencies: ```json { "dependencies": { "@run402/sdk": "3.7.5" } } ``` OpenClaw skill packaging follows the CLI release train. `@run402/functions` and `@run402/astro` publish on their own cadences. ## Patterns & gotchas - Provision before authoring HTML. The `anon_key` is permanent and must be embedded in your frontend; provision first, then write the HTML. - Use the manifest for access control, never raw `GRANT/REVOKE`. - `user_owns_rows` is the default for user-scoped data. Reach for `public_read_write_UNRESTRICTED` only on intentionally-public tables. - Use immutable `cdnUrl` from `r.assets.put`. It's correct from the moment of upload — no `waitFresh` needed. - Don't bake unconditional `r.allowance.faucet()` into deploy scripts — the faucet rate-limits and breaks already-funded flows. - Per-project rate limit is 100 req/sec. On 429, back off using `retry_after`. - `r.service.status()` works without auth. Use it before evaluating Run402, or to distinguish platform issues from your own bugs. ## See also - Wayfinder: - CLI reference: - MCP reference: - HTTP API reference: - npm: - Source: