The patterns
The patterns
Section titled “The patterns”Paste-and-go assets — content-addressed URLs with SRI
Section titled “Paste-and-go assets — content-addressed URLs with SRI”r.assets.put returns an AssetRef:
const logo = await (await r.project(projectId)).assets.put("logo.png", { bytes });
logo.cdnUrl // → "https://pr-<public_id>.run402.com/_blob/logo-3a7fc02e.png"logo.sri // → "sha256-…" for <script integrity="…">logo.etag // → strong "sha256-<hex>" ETaglogo.cacheKind // → "immutable" | "mutable" | "private"The URL is content-addressed and served through CloudFront. No cache-invalidation choreography needed. The browser refuses execution on byte mismatch via SRI.
immutable: true is the default. The SDK always computes and sends the object SHA-256 because upload sessions require it; pass false only when you specifically need mutable URL/cache semantics (the returned cdnUrl and sri are then null).
Binary files are bytes, never strings. In Node, call readFile(path)
without an encoding; in a browser, read File.arrayBuffer(). Do not use
readFile(path, "utf8"), Blob.text(), or another text decoder for PNG,
WASM, fonts, audio, video, archives, or other binary formats and then hash or
re-encode that string. CAS verifies the submitted bytes against their hash; it
cannot reconstruct bytes discarded by an earlier UTF-8 decode. String sources
for known binary keys/MIME types fail locally before network traffic with
BINARY_CONTENT_REQUIRES_BYTES.
import { readFile } from "node:fs/promises";
const logoBytes = await readFile("./logo.png"); // Buffer is a Uint8Arrayawait (await r.project(projectId)).assets.put("logo.png", { bytes: logoBytes });Raw /content/v1 clients have the same obligation: compute sha256 and
size from the original byte buffer, then PUT that exact buffer. The declared
content_type is metadata, not proof that the pre-hash bytes were decoded
correctly. Prefer assets.put, fileSetFromDir, dir, or assets.uploadDir
so the byte-safe path is automatic.
If you suspect cache staleness on a mutable URL, the SDK has helpers:
const blobUrl = "https://pr-….run402.com/_blob/avatar.png";const diag = await (await r.project(projectId)).assets.diagnoseUrl( blobUrl);// diag.expectedSha256 / observedSha256 / cache.* / invalidation.* / hint
const fresh = await (await r.project(projectId)).assets.waitFresh( { url: blobUrl, sha256: expectedSha, timeoutMs: 60_000,});// `fresh.fresh === false` on timeout — handle by switching to immutableDon’t call waitFresh on immutable URLs — they’re correct from upload time.
Unified apply — r.project(id).apply
Section titled “Unified apply — r.project(id).apply”The canonical primitive for any deploy (database + migrations + manifest + value-free secret declarations + functions + site + subdomain). Three layers:
// One-shot — most agents use this. Awaits to a terminal state.const result = await (await r.project(spec.project_id)).apply(spec);
// Long-running with progress events. Events are a discriminated union on `type`.const op = await (await r.project(spec.project_id)).apply.start(spec);for await (const ev of op.events()) { console.log(ev.type, ev);}const final = await op.result();
// Resume by id (e.g. after an interrupted process or a 5xx).const resumed = await (await r.project(projectId)).apply.resume(operationId);
// Lower-level steps for CLI debugging:const pScoped = await r.project(spec.project_id);const { plan: lowPlan, byteReaders } = await pScoped.apply.plan(spec);await pScoped.apply.upload(lowPlan, { byteReaders });if (!lowPlan.plan_id) throw new Error("Preview plans cannot be committed");const committed = await pScoped.apply.commit(lowPlan.plan_id);
// Gateway-reviewed plan: no bytes uploaded and no release committed, but// returns a require-able reviewed identity.const { plan: reviewedPlan } = await pScoped.apply.plan(spec, { mode: "reviewedPlan" });console.log(reviewedPlan.plan_id); // plan_...console.log(reviewedPlan.plan_fingerprint); // pfp_...
await pScoped.apply(spec, { requiredPlan: { planId: reviewedPlan.plan_id ?? "", planFingerprint: reviewedPlan.plan_fingerprint ?? undefined, },});
// Legacy low-level debug preview remains available but is not require-able.const { plan: debugPreview } = await pScoped.apply.plan(spec, { dryRun: true });console.log(debugPreview.plan_id); // nullconsole.log(debugPreview.operation_id); // nullRelease observability reads live on the scoped deploy namespace:
const p = await r.project(projectId);const release = await p.apply.getRelease("rel_...", { siteLimit: 5000 });const active = await p.apply.getActiveRelease();const diff = await p.apply.diff({ from: "empty", to: "active", limit: 1000 });getRelease / getActiveRelease return ReleaseInventory (kind: "release_inventory", state_kind: "current_live" | "effective" | "desired_manifest", site paths, static_public_paths, functions, secret keys, subdomains, materialized routes, applied migrations, release_generation, static_manifest_sha256, nullable static_manifest_metadata, embedding (the release’s framing opt-in as catalog keys, or null; absent on an older gateway), sign_in_path (the app’s own sign-in page for MCP authorization, or null for the hosted page; absent on an older gateway), noindex (true when every response of the host carries X-Robots-Tag: noindex, nofollow, nosnippet; absent on an older gateway), and inventory warnings when returned). site.paths is the release static asset inventory; static_public_paths[] is the browser reachability inventory with public_path, asset_path, reachability_authority, direct, cache_class, content_type, and optional route metadata. reachability_authority explains whether reachability came from implicit file-path mode, explicit site.public_paths, or a route-only static alias. static_manifest_metadata: null means unavailable, not zero; when present it includes file_count, total_bytes, cache_classes, cache_class_sources, and spa_fallback. diff returns ReleaseToReleaseDiff (kind: "release_diff") with migrations.applied_between_releases; secret and subdomain diffs have only added / removed, never changed; route diffs expose routes.added / removed / changed; static_assets exposes unchanged/changed/added/removed files, newly_uploaded_cas_bytes, reused_cas_bytes, deployment_copy_bytes_eliminated, legacy_immutable_warnings, previous_immutable_failures, and cas_authorization_failures.
ReleaseSpec shape
Section titled “ReleaseSpec shape”The full type is exported as ReleaseSpec from @run402/sdk and @run402/sdk/node. Sketch:
{ $schema?: "https://run402.com/schemas/release-spec.v1.json", // editor metadata; stripped before planning project_id: "prj_…", // the one spelling: manifest, typed spec, and wire base?: { release: "current" | "empty" } | { release_id: "rel_…" }, database?: { migrations?: MigrationSpec[], // each has exactly one of id or name, plus sql? | sql_ref?, checksum?, transaction? expose?: ExposeManifest, // dark-by-default authorization manifest zero_downtime?: boolean, }, secrets?: SecretsSpec, // { require?: string[], delete?: string[] } functions?: FunctionsSpec, // { replace? } | { patch?: { set?, delete? } } site?: SiteSpec, // replace/patch, public_paths, embedding: { frame_ancestors: EmbeddingKey[] } | null, sign_in_path: string | null, noindex: boolean | null subdomains?: SubdomainsSpec, // { set? } | { add? } | { remove? } routes?: ReleaseRoutesSpec, // null or { replace: RouteSpec[] } checks?: SmokeCheck[],}- Replace vs patch semantics per resource.
site.replace= “this is the whole site” (files absent are removed).site.patch.put/patch.delete= surgical updates.site.public_pathsis the browser reachability table and is separate from release asset paths:{ mode: "explicit", replace: { "/events": { asset: "events.html", cache_class: "html" } } }serves public/eventsfrom release assetevents.html; in explicit mode/events.htmlis not public unless separately declared.{ mode: "implicit" }restores filename-derived public reachability and can widen access. Public-path-only site specs are deployable. Known staticcache_classinputs arehtml,immutable_versioned, andrevalidating_asset; preserve unknown strings returned by observability APIs.functionshas the same replace/patch split. Secrets are declaration-only: user.project(id).secrets.set(key, { value })for values, thensecrets.require[]to assert keys exist andsecrets.delete[]to remove keys at activation.subdomainssupports exactly one mode per spec:setreplaces the release’s managed subdomain list,addappends without removing existing entries, andremovedeletes named entries. Todaysubdomains.setaccepts at most one subdomain per project; multi-subdomainsetis rejected locally withSUBDOMAIN_MULTI_NOT_SUPPORTED. Top-level absence = leave untouched. - FunctionSpec.
functions.replaceis the complete desired function map;functions.patch.setupdates only listed functions, andfunctions.patch.deleteremoves listed names. Each function acceptsruntime?: "node22", exactly one code source (sourcefor a single bundled module, orfilesplusentrypointfor multi-file functions), optionalconfig.timeoutSeconds, optionalconfig.memoryMb, optional required-idtriggers[], and the auth-gate fieldsrequireAuthandrequireRole. Schedule triggers use{ id, type: "schedule", cron, run };runcontains at leastevent_typeand may includepayload,retry, andexpires_after_seconds. Each scheduled tick creates a durable function run. Email triggers use{ id, type: "email", mailbox, events, run }, wheremailboxis a mailbox slug/id andeventsis any ofreply_received,delivery,bounced,complained,mailbox_suspended,sender_suspended; each matching email event creates a durable function run with the canonical event payload underpayload.event. Amailbox_suspendedtrigger fires when the mailbox is abuse-suspended (payload event carriessuspended_reason,suspended_at,evidence,recovery_actions) and executes independently of the suspended mailbox’s send capability — the app observes its own outage without polling or a public webhook URL. Asender_suspendedtrigger fires when one sending address of a catch-all mailbox is suspended (payload event carriesaddress,suspended_reason,suspended_at,evidence,recovery_actions). Areply_receivedpayload carriesdelivered_to. In typed deploy configs, preferscheduleTrigger(...)andemailTrigger(...).FunctionSpecalso acceptsdeps?: string[](npm specs) under capabilityapply-v1-function-deps; the gateway installs and bundles them.r.functions.deploy(..., { deps })builds a one-functionfunctions.patch.setand rides this same unified-apply path. - Function-level auth gates. Each
FunctionSpeccarries two optional declarative fields enforced by the gateway before invocation:requireAuth?: boolean— whentrue, gateway rejects callers without a valid project user JWT withRun402DeployError-shaped envelope or401at request time. No DB lookup. Independent fromrequireRole.requireRole?: RequireRoleSpec | null— gateway resolves the caller’s role from the project-schema table and rejects callers whose role is not inallowedwith403. Implies authentication (no JWT → 401). Passnullin patch mode to remove an existing gate.RequireRoleSpecis{ table: string; idColumn: string; roleColumn: string; allowed: string[]; cacheTtl?: number }. All identifiers are unqualified;cacheTtlis seconds (default 60, max 600, 0 disables caching for instant-revocation paths). When a gate passes, the gateway injectsx-run402-user-id(always when any gate ran) andx-run402-user-role(only whenrequireRoleran) into the request. In-function code reads them directly fromreq.headers.get("x-run402-user-id")/req.headers.get("x-run402-user-role"). The baregetUserId(req)/getRole(req)exports throwR402_AUTH_UNKNOWN_EXPORT. For the canonical cookie-session flow, use theauth.*namespace (see below). AllrequireRoleblocks in a single release must share the same(table, idColumn, roleColumn)triple; mixed-table specs are rejected at plan time withRun402DeployError.code === "INVALID_SPEC". The SDK does not validate gate shape — the gateway is authoritative. Missing table or column at activation throwsRun402DeployError.code === "DEPLOY_INVALID_ROLE_GATE"(HTTP 422) before flipping the live release.
- Routes.
ReleaseRoutesSpecisundefined | null | { replace: RouteSpec[] }. Omitted andnullcarry forward base routes;{ replace: [] }clears dynamic routes;{ replace: [...] }replaces the route table.RouteSpecis one entry:{ pattern: string, methods?: RouteHttpMethod[], target: RouteTarget }. Function targets are{ type: "function", name: string }; static route targets are{ type: "static", file: string }for exact method-aware static aliases, not ordinary clean URLs, rewrites, or redirects. Prefersite.public_pathsfor clean static URLs e.g./events -> events.html; use static routes for cases like staticGET /loginplus functionPOST /login. Static targets require exact patterns only, methods["GET"]or["GET","HEAD"], and a relative deployed asset path with no leading slash, wildcard, directory shorthand, query, or fragment.methodsomitted means all supported methods for function routes;methods: []is invalid. Supported methods are exported asROUTE_HTTP_METHODSandRouteHttpMethod(GET,HEAD,POST,PUT,PATCH,DELETE,OPTIONS). Function target names are materialized release function names. - Strict validation is SDK-owned.
apply.plan/start/applyreject unknown rawReleaseSpecfields before normalization can drop them; only top-level$schemametadata is tolerated and stripped before the plan request.project_id,subdomain,site.replcae,functions.replace.api.deps,functions.replace.api.config.schedule, and similar typos areRun402DeployError.code === "INVALID_SPEC"before any hash/upload/plan request. UseloadDeployManifest/normalizeDeployManifestfor CLI/MCP JSON that legitimately usesproject_id,{ path }, base64 file entries, or migrationsql_path. - No-op specs fail locally. A spec with only
project/base, or empty containers e.g.site: {},functions.patch.delete: [],secrets.require: [], orsubdomains.set: [], throwsRun402DeployError.code === "MANIFEST_EMPTY"before any network call. Delete-only patches with non-emptydeletearrays still count as deployable, and so does an explicitsite.replace: {}: it takes the site’s static files offline (the plan asks to confirmZERO_SITE_FILES_AFTER, except for a release whosessrfunction renders every path). - Tier preflight is local but gateway remains authoritative. After normalization and before manifest CAS upload or
/apply/v1/plans,apply.plan/start/applycheck literal functionconfig.timeoutSeconds,config.memoryMb, schedule-trigger cron minimum interval, and scheduled-trigger count when computable. Failures areRun402DeployError.code === "BAD_FIELD"withdetails.field,details.value,details.tier,tier_maxormin_interval_minutes, anddetails.limit_source(tier_statusorlocal_static_fallback). Current caps: prototype 10s / 128 MB / 1 scheduled trigger / 15 min, hobby 30s / 256 MB / 3 / 5 min, team 60s / 512 MB / 10 / 1 min; an SSR-class function (class: "ssr") is checked against its own memory cap, 1,769 MB on team (prototype 128 MB, hobby 256 MB). The caps are those of the tier of the organization that owns the project (read fromGET /projects/v1/:project_id), the tier the gateway judges the deploy by;tier.status()describes the wallet’s own organization, so itsfunction_limits/limits.functions(including current scheduled usage) count only when its tier is the project’s. - Plan warnings are structured.
PlanResponse.warningsandDeployResult.warningsareWarningEntry[];apply()emitsplan.warningsand aborts before upload/commit when a warning requires confirmation unless broadallowWarningsis set or every blocking warning code is listed inallowWarningCodes.MISSING_REQUIRED_SECRETmeans set the affected keys withr.secrets.set, then retry. ForWILDCARD_ROUTE_EXCLUDES_MUTATION_METHODS, add the mutation methods the function supports or omit methods for an API prefix; when the GET/HEAD final-wildcard function route is intentionally read-only, list the code inallowWarningCodesafter inspecting all affected entries.DESTRUCTIVE_SITE_BULK_REMOVALlists the counted removals inaffected, and the SDK addsdetails.removed_paths_summary(heuristic:pages,page_paths,content_hashed_build_assets,other,only_content_hashed_build_assets, plusby_top_level_dirunless the gateway sendscounted_removed_by_dir) so removals of pages are distinguishable from removed hashed bundles. The gateway already leaves out fingerprinted bundles the build replaces in the same directory (details.replaced_fingerprinted). - Deploy summaries are derived SDK helpers.
summarizeDeployResult(result: DeployResult): DeploySummaryis exported from@run402/sdkand@run402/sdk/node. It makes no gateway calls and summarizes only current reliableDeployResult.diff/DeployResult.warningsdata: site path counts, CAS new/reused bytes, functions, migrations, routes, secrets, subdomains, and warning counts. Missing buckets are omitted, not zero-filled. It intentionally has no timings, client-side duration estimates, server phase estimates, or function old/new code hash fields. - Safe release-race retries are automatic. For omitted/current-base specs,
apply()re-plans and retriesBASE_RELEASE_CONFLICTonly when the gateway marks the errorsafe_to_retry: true. Pinnedbase.release_idand{ release: "empty" }stay caller-owned. Transient platform conditions the gateway markssafe_to_retry: true(OPERATION_INTERRUPTEDwhen a gateway rollout replaced the task running the commit,IMAGE_ENCODE_TIMEOUT/TOO_MANY_ENCODES_QUEUEDunder encoder load,EXPORT_CONSISTENCY_UNAVAILABLEduring an archive-export write pause) are retried the same way for any base, with a longer backoff (2s, 4s, … capped at 15s); nothing was activated, and content is content-addressed, so the re-apply uploads nothing twice. Content plans and operation polls lost to a 502/503/504 or a dropped connection are repeated in place. Static activation/spec failures insideactivation_pendingthrow immediately with gateway metadata preserved instead of polling until timeout. PassmaxRetries: 0to opt out. - Plan envelopes are normalized. New gateways return
kind: "plan_response"withis_noop,summary,warnings,expected_events, and resource buckets at top level. The SDK preserves those fields and also folds them intoPlanResponse.difffor compatibility, includingpublic_paths(mode_old,mode_new,added,removed,changed: which browser URLs serve which asset) andsite_settings(effectiveandchangedfornoindex,embedding,sign_in_path) when the gateway returns them; a change to either alone is not a noop.summarizeDeployResultcountspublic_pathsand listssite_settings_changed. Dry-run plans returnplan_id: nullandoperation_id: nulland cannot be uploaded or committed. - Modern plan/diff types are split. Deploy plans may expose
PlanDiffEnvelopewith migration buckets{ new, noop }; release-to-release diffs expose{ applied_between_releases }. Migration checksum mismatch is a hard deploy error (Run402DeployError), not a normal successful diff bucket. Legacy flag-off plan arrays remain represented byDeployDifffor compatibility.
WarningEntry is a compatibility union. Legacy plan warnings used low/medium/high severity; deploy-observability warnings use info/warn/high severity with heuristic confidence. Shared fields:
{ code: string, severity: "low" | "medium" | "high" | "info" | "warn", requires_confirmation: boolean, message: string, affected?: string[], details?: Record<string, unknown>, confidence?: "low" | "medium" | "high" | "heuristic",}- All bytes ride through CAS. Plan request bodies never carry inline bytes — only
ContentRefobjects. When the spec exceeds 5 MB JSON, the SDK uploads the manifest itself as a CAS object and references it (manifest_refescape hatch — no body-size cliff). - Server-authoritative manifest digest. The gateway returns the canonical digest; the SDK does not require byte-for-byte canonicalize agreement.
The Node entry adds fileSetFromDir(path) for filesystem byte sources:
import { run402, fileSetFromDir } from "@run402/sdk/node";
const r = run402();const p = await r.project(projectId);const result = await p.apply({ site: { replace: await fileSetFromDir("./dist") }, subdomains: { set: ["my-app"] },});fileSetFromDir walks the directory and returns an FsFileSource-backed FileSet. The deploy normalizer hashes and uploads each file lazily during apply, so collection does not load the tree into memory. Skips .git/, node_modules/, .DS_Store, dotenv/npmrc files, and private-key-like filenames by default. Pass { includeSensitive: true } only when those files are intentional deploy artifacts. Symlinks throw.
The Node entry also owns the typed manifest adapter used by CLI/MCP:
import { loadDeployManifest, normalizeDeployManifest, run402 } from "@run402/sdk/node";
const r = run402();
const loaded = await loadDeployManifest("./run402.deploy.json");await (await r.project(loaded.spec.project_id)).apply(loaded.spec, { idempotencyKey: loaded.idempotencyKey });
const inMemory = await normalizeDeployManifest({ project_id: projectId, site: { patch: { put: { "index.html": { data: "<h1>hi</h1>" } } } },});await (await r.project(inMemory.spec.project_id)).apply(inMemory.spec, { idempotencyKey: inMemory.idempotencyKey });DeployManifestInput is the agent-facing JSON shape: project_id becomes SDK project, idempotency_key is returned separately for deploy options, { data, encoding: "base64", content_type? } decodes to bytes, { path, content_type? } becomes a lazy FsFileSource, and database.migrations[].sql_path / sql_file are read as UTF-8 SQL. Each migration declares exactly one of id or name: use id for immutable versioned migrations, and name for generated/idempotent SQL whose compiled id should track content changes. Snake manifest fields such as config.timeout_seconds, require_auth, require_role.id_column, and i18n.default_locale normalize to the SDK’s camelCase ReleaseSpec. loadDeployManifest(path) resolves relative paths against the manifest file’s directory; normalizeDeployManifest(input, { baseDir? }) defaults relative paths to process.cwd(). Manifest normalization is strict too: unknown fields are rejected instead of dropped, so a valid site deploy plus typoed subdomain cannot become a partial site-only deploy.
Route manifest example:
import { run402, type ReleaseSpec, type RouteSpec } from "@run402/sdk/node";
const r = run402();const routes: RouteSpec[] = [ { pattern: "/api/*", methods: ["GET", "POST", "OPTIONS"], target: { type: "function", name: "api" } }, { pattern: "/admin", target: { type: "function", name: "admin" } }, { pattern: "/admin/*", target: { type: "function", name: "admin" } }, { pattern: "/login", methods: ["POST"], target: { type: "function", name: "auth" } },];const specWithRoutes: ReleaseSpec = { project_id: projectId, site: { replace: { "index.html": "<!doctype html><main id='app'></main>", "events.html": "<!doctype html><h1>Events</h1>", }, public_paths: { mode: "explicit", replace: { "/events": { asset: "events.html", cache_class: "html" } } } }, functions: { replace: { api: { source: "export default async function handler(req) { const url = new URL(req.url); return Response.json({ ok: true, path: url.pathname }); }" }, admin: { source: "export default async () => new Response('admin')" }, auth: { source: "export default async () => new Response('login')" }, }, }, routes: { replace: routes },};await (await r.project(specWithRoutes.project_id)).apply(specWithRoutes);Route matching and routed HTTP contract:
- Release static asset paths and public browser paths are distinct. In the example,
events.htmlis a release asset and/eventsis the public static URL declared bysite.public_paths. In explicit mode,/events.htmlis not public unless separately declared.{ mode: "implicit" }restores filename-derived public reachability and can widen access. - Exact patterns look like
/admin; prefix wildcard patterns use a final/*, e.g./admin/*. /admin/*does not match/admin,/admin/,/admin.css, or/administrator; deploy both/adminand/admin/*for a dynamic section root./adminand/admin/are trailing-slash equivalents for exact matching.- Prefer
site.public_pathsfor ordinary clean static URLs e.g./events -> events.html. Static route targets are exact, method-aware route-table aliases e.g.{ pattern: "/events", methods: ["GET", "HEAD"], target: { type: "static", file: "events.html" } };target.fileis a release asset path, not a public path, URL, CAS hash, rewrite, or redirect. In explicit public path mode, a route-only static alias can serve a private asset without making/events.htmldirectly reachable. - Avoid routing ordinary static files, wildcard static targets, leading-slash files, directory shorthand, broad method lists by default, and one-static-route-target-per-page route-table exhaustion.
- Query strings are ignored for matching and preserved in the handler’s full public
req.url. - Exact routes beat prefix routes, longest prefix wins among prefixes, and method-compatible dynamic routes beat static assets.
- A method-specific
POST /loginroute can coexist with staticGET /loginHTML. Unsafe method mismatch returns405, not SPA HTML. - Matched dynamic routes fail closed: function/platform errors are returned and Run402 does not continue to static lookup.
- Routed browser ingress uses Node 22 Fetch Request -> Response. The handler receives
req.methodand full publicreq.urlon managed subdomains, hosts, and verified custom domains. The rawrun402.routed_http.v1envelope is internal; do not write browser route handlers against it. Direct/functions/v1/:nameremains API-key protected. - Request/response bodies are capped at 6 MiB. Run402 adds no wildcard CORS, does not store routed dynamic responses in a shared cache, and adds
Cache-Control: private, no-storeplusx-run402-cache: dynamic-bypasswhen the function sets no cache header. - The function owns application auth, CSRF for cookie-authenticated unsafe methods, CORS/
OPTIONS, cookies, redirects, and not trusting spoofable forwarding headers.
Recipe — static home page + SPA shell. A SPA site ships index.html as the shell serving every unmatched route (match spa_fallback), so by default GET / serves the shell too. To serve a real static home page at / — real bytes under curl and without JavaScript — while keeping the shell for app routes, ship home.html at the site root alongside index.html and add an exact root static route alias:
const spaWithStaticHome: ReleaseSpec = { project_id: projectId, site: { replace: { "index.html": "<!doctype html><main id='app'></main><script src='/app.js'></script>", "home.html": "<!doctype html><h1>Welcome</h1><a href='/dashboard'>Open the app</a>", "app.js": "/* SPA bootstrap */", } }, routes: { replace: [ { pattern: "/", target: { type: "static", file: "home.html" } }, ] },};await (await r.project(spaWithStaticHome.project_id)).apply(spaWithStaticHome);- Route matching runs before all static resolution — including the implicit
/->index.htmlroot mapping — and SPA-fallback derivation is independent of the route table. SoGET /serveshome.html(matchroute_static_alias), unmatched app routes e.g./dashboardstill serve theindex.htmlshell (matchspa_fallback), and named static pages keep serving unchanged (matchstatic_exact). - Root placement of
home.htmlkeeps its relative asset URLs resolving identically to the direct file and avoids theSTATIC_ALIAS_RELATIVE_ASSET_RISKwarning. - 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) andSTATIC_ALIAS_DUPLICATE_CANONICAL_URL(info —/home.htmlstays directly reachable in implicit public-path mode; add<link rel="canonical" href="https://<your-site>/">tohome.htmlif duplicate-content SEO matters). - Omitting
routeson later deploys carries the alias forward (informationalROUTE_TARGET_CARRIED_FORWARD); a pipeline that sendsroutes.replacemust include the alias every time because replace is total. - Verify with
p.apply.resolve({ url: "https://<your-site>/", method: "GET" })(below) and confirmmatch: "route_static_alias"withtarget_file: "home.html".
URL-first public diagnostics:
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, route_method_miss, deployment_host, and deployment_retired (a dpl-*.sites.run402.com host no release names, or a retired one, reported per host with path_evaluated: false; otherwise a deployment host’s path resolves like any host and adds deployment_status live / retained / retired, origin_available_until, routes_active, and path_evaluated: true); preserve unknown future strings. A matched function route reports authorized: true, since the host dispatches it, and lists in route_gates what stands before its response (require_auth, require_role, priced); the summary names them. 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, unauthorized_cas_object, and route_inactive (a replaced deployment’s route, which no longer runs). 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. | Informational (requires_confirmation: false): it never blocks apply and needs no allowWarningCodes entry. Review app auth, CSRF, CORS/OPTIONS, and cookies; direct /functions/v1/:name remains API-key protected. Only warnings with requires_confirmation: true need allowWarningCodes. |
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. |
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, when the GET/HEAD final-wildcard function route is intentionally read-only, acknowledge with allowWarningCodes: ["WILDCARD_ROUTE_EXCLUDES_MUTATION_METHODS"]; 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
Section titled “Node deploy convenience”r.sites.deployDir(...)— Node-only thin wrapper that usesfileSetFromDir(dir), hands off toapply, and emits unifiedDeployEventshapes.
GitHub Actions OIDC — CI credentials + the same deploy primitive
Section titled “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.
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: await 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-exchangewithout 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
apikeyheaders
import { githubActionsCredentials, run402, type ReleaseSpec } from "@run402/sdk/node";
const r = run402({ credentials: githubActionsCredentials({ projectId }), disablePaidFetch: true,});
const ciSpec: ReleaseSpec = { project_id: projectId, base: { release: "current" }, site: { patch: { put: { "index.html": "<h1>ship</h1>" } } },};
await (await r.project(ciSpec.project_id)).apply(ciSpec);CI deploy restrictions are part of the client contract: allowed top-level fields are only project_id, 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
Section titled “Dark-by-default tables + the expose manifest”Tables you create are not readable by anonymous callers 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.
Anonymous REST access requires manifest exposure. Authenticated callers have separate table privileges and remain subject to RLS: an enabled table with no applicable SELECT policy returns 200 []. Omitting a table from the manifest is not a universal server-only boundary for authenticated users. Enable RLS and define the intended policies for private data; never infer privacy solely from an empty query result.
The manifest itself is a JSON object:
{ "$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_append_only (anyone reads and inserts; update/delete denied), 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 <table>_set_owner backed by <table>_set_owner_fn. The generated shape is:
CREATE OR REPLACE FUNCTION "<table>_set_owner_fn"() RETURNS triggerLANGUAGE plpgsql AS $body$BEGIN IF NEW."<owner_column>" IS NULL THEN NEW."<owner_column>" := auth.uid(); END IF; RETURN NEW;END;$body$;
CREATE TRIGGER "<table>_set_owner" BEFORE INSERT ON "<table>" FOR EACH ROW EXECUTE FUNCTION "<table>_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 r.projects.applyExpose. 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
Section titled “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 https://www.npmjs.com/package/@run402/functions for full details.
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/projects/v1/:project_id/rest/*(andadminDb().sql()to/projects/v1/:project_id/sql).adminDb().sql(query, params?)— raw parameterized SQL.auth.user()/auth.requireUser()— read the verified actor from the SSR runtime context.auth.user()returnsActor | null;auth.requireUser()returnsActorand throws (303 redirect for HTML / 401 envelope for JSON, decided by the gateway from theAcceptheader).Actorhasid,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 fromauth.requireUser()— the platform decides response shape. BaregetUser/getUserId/getRole/getSession/currentUser/getCurrentUser/getServerSessionexports throwR402_AUTH_UNKNOWN_EXPORTat runtime AND failrun402 doctorsource scan at deploy.- For per-user gating in functions OUTSIDE the cookie-session flow (a
requireAuth/requireRoledeploy-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 inboundx-run402-*headers before injection, so the values are trustworthy. Returnsnullwhen no corresponding gate ran (function has no gate, onlyrequireAuthdeclared withoutrequireRole, or local-invoke outside the gateway). getRoutedPaymentContext(req)(@run402/functions3.7+) — confirmed x402 payment context for priced routed function requests. Returns{ scheme, paymentId, amountUsdMicros, amountMsat?, payer, network, asset, payTo, transaction, settledAt }ornullfor unpriced/direct/malformed calls. Usepayment.paymentIdfor app-side idempotency. A Lightning payment (networklnbtc:…,@run402/functions4.10+) carriesamountMsat, the invoice amount in millisatoshis, and anullpayer.ai.generateImage({ prompt, aspect? })— project-billed runtime image generation for deployed functions.aspectis"square" | "landscape" | "portrait"; result is{ image, content_type, aspect }with base64 image bytes. UsesRUN402_SERVICE_KEYagainst/ai/v1/generate-image, not the wallet/x402/generate-image/v1endpoint. 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. UsesRUN402_SERVICE_KEY, shares the deploy-time CAS/activation substrate, and returns an SDK-compatibleAssetRef.
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.