Platform updates and new features
Machine-readable: /updates.txt
@run402/functions ships with zero runtime dependencies — A supply-chain reduction pass removed jsonwebtoken, bs58, blurhash, and npm-package-arg from the gateway, dropping ~28 transitive packages. The published @run402/functions SDK went from one runtime dep to zero. Replacements are vendored inline (HS256 JWT on node:crypto; spec-fixed blurhash encode; strict registry-only --deps parser that rejects git+ssh://, file:, workspace:, npm: aliases at parse time). No public API change — routes, error envelopes, JWT wire format, and blurhash output are byte-identical; getUser(req) stays synchronous. Already-deployed user functions keep their bundled OLD helpers until redeploy.viem + a crypto or PDF lib + a small UI bundle easily cross 1.5 MB; the prior universal cap was an oversight that no tier upgrade could clear. Apply's validate phase now pre-checks source.size against the tier limit so over-budget specs fail at POST /apply/v1/plans (HTTP 402 TIER_LIMIT_EXCEEDED) instead of after the full CAS upload + commit. Activate phase enforces the same cap as defense-in-depth.spec.i18n gains unknownLocalePolicy ('reject' default | 'pass-through'). Under 'pass-through', a cookie or Accept-Language tag that doesn't match locales[] is returned verbatim (lowercased + trimmed) as ctx.locale instead of falling through to defaultLocale. Unblocks admin-driven "Add Language" flows where a portal enables a new locale at the app DB layer between deploys. Default behavior unchanged; canonical-return invariant preserved for locales[] hits. Routed-invocation logs gain locale_in_locales_list so operators can distinguish pass-through hits.assets.put accepts metadata (flat per-key JSON, 4 KB cap) and an exif_policy ('keep' default | 'strip'). assets.list and GET /storage/v1/blobs gain sort (key / createdAt asc/desc) and indexed filters (filter.uploaded_by, filter.tag, filter.format, filter.is_image, dimension ranges). Apps that previously maintained a shadow media_assets table for filename / uploader / tags / dimensions can drop it — internal.blobs is now the single source of truth. Every filter hits a partial index.exif_policy: 'strip' keeps only camera_make / camera_model / lens_model / exposure / iso / focal_length / datetime tags in image_exif; GPS, serial numbers, owner identifiers, maker-notes are dropped. The CAS bytes (with full EXIF) stay verbatim — apps that need full EXIF can parse the served bytes themselves. Default 'keep' stores the full EXIF, because the platform shouldn't silently lose user data.image_format (magic-byte detected), image_info (alpha, color_space, animated, frame_count, bit_depth, orientation), and image_exif are populated for any image MIME, ride alongside the v1.49 width_px / height_px / blurhash on the same sharp.metadata() pass — no extra latency.assets.list — @run402/functions gains an assets.list({ prefix?, sort?, filter? }) method backed by the same indexed query path. Client-side validators reject invalid metadata / exifPolicy / filter keys before any HTTP call.assets.put() on an image now returns the source URL plus three responsive WebP variants (320w / 800w / 1920w), display-oriented width_px/height_px, and a blurhash placeholder. AssetRef gets a new variants map keyed by thumb/medium/large. MediaPicker-style admin grids no longer have to download 5 MB originals for thumbnails. Encoder is sync, bounded by per-task concurrency/pixel/dim/timeout caps with friendly 413/422/429/504 responses. IMAGE_VARIANTS_ENABLED=false on the gateway task is a zero-redeploy kill switch.display_jpeg variant is generated alongside the three WebP variants. The new AssetRef.display_url field points at the JPEG for HEIC sources; for already-displayable formats it equals cdn_url. SDK helpers will default <img src> to display_url in the upcoming SDK release.Retry-After: 2. Four CloudWatch alarms on the Run402/Assets namespace fire to Telegram on encoder latency, failure rate, queue saturation, or timeouts.POST /assets/v1/admin/backfill-variants + the scripts/backfill-image-variants.ts wrapper let operators populate variants for image blobs that existed before v1.49 (e.g. an existing media library) without requiring re-upload.blob_url_refs.content_sha256 when checking whether a content_objects row was still referenced, meaning bytes referenced only by immutable URLs could be reaped during the grace window. v1.49 extends the ref union and the new helper internal.has_any_content_ref() covers every callable check. Variants benefit; so do every other immutable URL the platform serves.r.assets.put(projectId, key, newBytes) against an already-populated key returned HTTP 500 ACTIVATION_FAILED with a NOT NULL violation on blobs.size_bytes. The activation INSERT pulled size_bytes via a correlated subquery against internal.content_objects; on a second-upload sha that subquery sometimes resolved to NULL and tripped the constraint. The shared applyOneAssetPut primitive now reads size_bytes once up front and passes it as a literal parameter to both INSERTs — no snapshot race, and a clear error if CAS is genuinely missing the row. Affects both the wallet apply hero and the in-function service-key path; design D16 immutable-URL retention is unchanged.deployFunction threw HTTP 404 Project not found or has no wallet address when internal.projects.wallet_address was null, because the AWS resource-tag builder requires a non-empty run402:wallet_id. Demo projects are intentionally wallet-less, so the path that runs after apps publish crashed at the very first Lambda create. The path now uses a "platform" sentinel for the wallet tag when the column is null. The 404 still fires when the project row genuinely doesn't exist.apps publish no longer crashes for team-tier publishers — The demo project that gets auto-created after a public publish is always forked at hardcoded prototype tier (platform-owned, free), but it was copying the publisher's function timeout_seconds and memory_mb verbatim. A team-tier function defaulting to 60s tripped the demo fork's tier validator with "Timeout must be 1-10s for your tier.", returned HTTP 500, and the just-published version got soft-rolled-back to private. The demo path now clamps function timeout/memory down to prototype's ceiling before deploy. Paid forks still fail-fast on tier mismatch — only demos clamp.kysigned.fflonk_prove.v0_17_0 proof jobs that run on run402-owned EC2 capacity instead of request-time functions. Jobs support idempotent submit, compact polling, cancellation, CloudWatch-backed logs, durable project-storage artifacts, spot-first retry with capped on-demand fallback, and a single managed_job billing ledger entry keyed by job_id.GET /billing/v1/accounts/:id, GET /billing/v1/accounts/:id/history, POST /billing/v1/accounts/:id/link-wallet, and POST /billing/v1/email-packs/auto-recharge previously accepted unauthenticated callers. The reads leaked balance, tier, auto-recharge config, and the full ledger to anyone with a wallet address; link-wallet let any caller hijack an unlinked wallet into an attacker's billing pool; auto-recharge let any caller disable a victim's recharge or trigger off-session Stripe charges. All four now require x-admin-key or a SIGN-IN-WITH-X header signed by the matching wallet (and, for auto-recharge, that wallet must be linked to billing_account_id in internal.billing_account_wallets). Email-identifier reads currently require admin key; magic-link self-service for email accounts will land separately.POST /billing/v1/admin/accounts/:wallet/set-tier (admin-key only) now invalidates the wallet-tier and SIWX caches like the x402 path, accepts an optional idempotency_key body field so operator retries are safe (was: each retry double-extended the lease), and rejects downgrades whose account-pooled functions, scheduled functions, or secrets exceed the new tier limit (was: only storage_bytes was checked, so 800 functions could keep running on a prototype tier)./health minimized — GET /health now returns only {"status":"healthy"} (200) or {"status":"unhealthy"} (503) to unauthenticated callers. The previous response exposed gateway version and per-dependency check state to anyone. The detailed view moved to GET /admin/api/health-detail, gated by X-Admin-Key.domain field against the attacker-controllable req.hostname. The allowlist is built from PUBLIC_API_URL plus localhost/127.0.0.1 for dev; other values reject with 401. Closes the Host-header SIWX replay vector.apiBase sourced from PUBLIC_API_URL — Six routes that previously interpolated req.get("host") into apiBase (and passed it into deployed Lambdas as RUN402_API_BASE) now use the trusted constant. A spoofed Host header can no longer redirect the deployed function's callbacks to an attacker.X-Run402-Trace-Id now echoes either a client-supplied x-run402-trace-id or a fresh trc_<32-hex> value. The X-Amzn-Trace-Id fallback was removed, so the response no longer leaks ALB request timestamps and per-request identifiers.POST /deploy/v2/plans now rejects out-of-tier timeoutSeconds, memoryMb, or scheduled intervalMinutes with HTTP 402 TIER_LIMIT_EXCEEDED before any CAS upload or function build runs. Was: full plan ran and failed at the AWS Lambda call.value_wei, webhooks, secret-file deploys — parseOptionalNonNegativeWei rejects above 2**256-1. Mailbox webhook creation caps at 20 per mailbox with HTTP 429. Deploy v2 manifests reject paths matching .env, .env.*, node_modules/, .git/, .pem/.key/.pfx/.crt, SSH keys, .netrc/.npmrc/.aws/, or DB dumps with HTTP 400 BLOCKED_FILENAME.X-Frame-Options: DENY, Content-Security-Policy: frame-ancestors 'none', Referrer-Policy, and X-Content-Type-Options are now set on every admin HTML route. The JSON /admin/api/* surface is unchanged.subdomainMiddleware no longer short-circuits OPTIONS requests, so browser preflights from custom subdomains reach corsMiddleware and receive proper Access-Control-Allow-Origin headers.pg_try_advisory_xact_lock; the loser returns HTTP 202 resume_already_in_progress. The idempotency middleware awaits the cache INSERT before returning, so retries always see the cached row. The cron scheduler tags each registration with a generation counter so stale ticks from replaced expressions short-circuit before running side effects.UpdateFunctionCode succeeds but UpdateFunctionConfiguration fails mid-deploy, the gateway throws LAMBDA_DEPLOY_PARTIAL_FAILURE with stage: "config_after_code" instead of returning silent success. Retrying the same deploy converges.tierCache now enforces a hard cap of 10,000 entries with insertion-order LRU; cache hits re-bump to most-recent. Closes the slow memory-pinning vector under wallet churn.value_wei — The pre-fingerprint match path used to compare (contract_address, function_name, args) only. It now extracts and compares value_wei too, so a request stored with value_wei=100 can no longer short-circuit a new request with value_wei=0.DELETE /email/v1/domains/inbound/:domain and ?domain= now work alongside the body form, surviving ALB and SDK layers that strip DELETE request bodies.POST /contracts/v1/wallets now accepts an optional low_balance_threshold_wei on provisioning instead of forcing the 0.001 ETH default; the value goes through the same uint256-capped parser.createDemoProject or updateDemoVersion fails after a publish, the just-published version is soft-rolled-back to private + fork_allowed=false and the route returns 500. No more orphan public versions with no demo.JSON.stringify. Content-Disposition filenames are sanitized to [A-Za-z0-9._-] only, closing the header-injection vector.https://run402.com/schemas/release-spec.v1.json now resolves so deploy manifests with $schema get editor autocomplete.client_state, and OAuth signup/linking now rolls user and identity writes back together on failure.RUN402_SERVICE_KEY after a failed config read, and function PATCH/DELETE ordering now avoids schedule or DB/Lambda drift on downstream failures.X-Powered-By response header is no longer emitted.TREASURY_LOW refunds it. A single transient failure can no longer become a sustained outbound retry flood.Unexpected token errors from BOM-prefixed code copy-pasted from Windows editors or docs.POST /projects/v1/admin/:id/expose requires an object body, PATCH /v1/versions/:id type-checks fork_allowed as a boolean and validates visibility against the allowed enum, and POST /tiers/v1/:tier validates the payment-header wallet via viem.isAddress instead of startsWith("0x").provisionWallet with a self-reference recovery address fails before any rent debit, wallet-row insert, or allowance ledger entry, and that the orphaned KMS key is scheduled for deletion.extractMppWallet in the x402 middleware now uses viem.isAddress instead of a startsWith("0x") check, blocking malformed addresses from reaching req.walletAddress on the MPP payment path.ttl_seconds now rejects non-number types up-front; POST /content/v1/plans validates every entry size as a non-negative integer up to 10 GiB; POST /attribution/v1 adds a per-IP rate limit (30/min) and truncates User-Agent to 512 chars before the SQL VALUES; the admin auth middleware now rejects invalid SIWX outright instead of silently falling through to cookie auth; the OAuth validateRedirectUrl helper is now an explicit allowlist with a defensive isLoopback check; CI session tier-missing returns HTTP 403 with TIER_REQUIRED instead of 402 (no more infinite x402 retry loops); and issueAuthSession now wraps the last_sign_in_at bump and the refresh-token insert in a single transaction so token-mint failures no longer leave an orphan timestamp.X-Forwarded-For spoofing draining the testnet treasury). POST /mailboxes/v1/:id/messages enforces a per-mailbox sliding-window rate limit (60/min) before quota and SES. services/email-send.ts wraps the SES call in a bounded retry that distinguishes transient AWS errors from user errors so transient hiccups no longer waste user quota. POST /agent/v1/contact now DNS-resolves the webhook host and rejects URLs pointing at private addresses (loopback, RFC1918, AWS metadata, IPv6 ULA, etc.), closing an SSRF vector that could pull AWS task-role credentials.advanceLifecycleForWallet now wraps its work in a transaction with a per-wallet advisory lock so the hourly scheduler and inline tier-payment hook cannot race. The billing module exports two distinct numeric helpers (positive vs non-negative) with distinguishable error messages, and POST /billing/v1/email-packs/auto-recharge now rejects updates against non-active billing accounts with HTTP 410 ACCOUNT_ARCHIVED so cancelled accounts can no longer trigger phantom Stripe charges.pinned state plus was_pinned, so repeated agent calls are safe. Project lists and wallet tier status expose pin state, and deploy operation listing now supports cursor pagination, status/since filters, matching project_id, has_more, next_cursor, and opt-in exact totals.site_limit now rejects trailing junk, decimals, and exponent notation; direct blob reads validate byte ranges before S3 sees them and return clean 400/416 responses; blob list prefixes now treat SQL wildcard characters as literal key-prefix characters.POST /auth/v1/passkeys/register/verify now returns HTTP 429 when project/user/RP/IP limits are exceeded before WebAuthn verification runs.POST /deploy/v2/plans + POST /deploy/v2/plans/:id/commit, direct storage uploads plus GET /storage/v1/blob/:key or project CDN /_blob/:key, @run402/functions with db(req) / adminDb(), and POST /projects/v1/admin/:id/expose. A repo guard now fails if current guidance reintroduces removed route/helper surfaces outside explicit negative tests or historical archives.ReleaseSpec.site.public_paths can now publish clean public static URLs without exposing the backing asset filename. mode: "explicit" requires a complete table such as "/events": { "asset": "events.html" }, so /events serves events.html while /events.html stays private on stable hosts. Explicit mode carries forward across later site.patch deploys, mode: "implicit" restores filename-derived reachability with a widening warning, static aliases materialize as route-only manifest entries, and authenticated release/resolve diagnostics explain the public-path authority.405 ROUTE_METHOD_NOT_ALLOWED whenever a path matches a route but the HTTP method is not allowed, including safe GET and HEAD requests. Previously, a GET to a POST-only route could serve the static SPA fallback and hide route mistakes.functions.replace and functions.patch.set as declarative desired state. If a function already matches the planning base by source hash, runtime, timeout, memory, and schedule, Run402 preserves the release snapshot but skips Lambda staging and activation, so static-only redeploys no longer spend most of their time updating unchanged functions.route_manifest_sha256: null as an empty route table, so cleared routes no longer leave custom domains serving stale route-manifest failures while managed Run402 subdomains are healthy.service_key or a project_admin JWT in the apikey header. Browser-safe anon_key tokens can still read public blobs and complete internal CAS sessions opened by content plans, but they can no longer create normal blob upload sessions, list blobs, sign downloads, delete blobs, diagnose CDN state, or read private blobs.TooManyRequestsException responses during routed function invocation now return the structured routed-failure envelope with HTTP 503 instead of escaping as an unhandled gateway error.site.paths[*].size_bytes, static manifest sizes and hashes, and static_manifest_metadata.total_bytes consistent, including same-SHA static replacements whose base snapshot did not know the file size.ReleaseSpec.routes now accepts static targets shaped as { "type": "static", "file": "events.html" }, so clean public paths like /events can serve materialized static files without function compute. Static aliases are exact-only, require explicit GET or GET+HEAD methods, validate the target file against the final materialized site, preserve the browser URL, and fail closed with STATIC_ROUTE_TARGET_NOT_FOUND instead of falling through to SPA HTML. Same-path mixed-method route tables are supported only when effective methods are disjoint, enabling static GET /login plus dynamic POST /login. Plan warnings call these aliases, not rewrites, and cover shadowing, relative asset risk, duplicate canonical URLs, extensionless non-HTML targets, and the current temporary combined route-table limit.run402.static_manifest.v1 with cache classes, response metadata policy, SPA fallback target, CAS object sizes, and a stable manifest SHA. Deploy plans report a new static_assets diff bucket with changed/reused byte counts and immutable-cache diagnostics. Activation verifies the static manifest and every referenced CAS object before the active release pointer flips, and CAS GC preserves static manifests. Agents can call GET /deploy/v2/resolve?host=...&path=... for authenticated host/path diagnostics without exposing internal CAS URLs. Stable-host serving is gated by rollout flags and custom-domain serving modes during rollout.ROUTE_METHOD_NOT_ALLOWED response is preserved with request correlation.X-Run402-Request-Id: req_... on success, redirects, user-controlled errors, platform errors, and wrapper-generated errors. Function logs support real since filtering, request_id lookup, latest-tail semantics after filters, chronological results, and additive metadata (event_id, log_stream_name, ingestion_time, best-effort request_id). Operators can copy the browser request_id from a routed 500 and run run402 functions logs <project> <function> --request-id req_... to find the stack and route/deployment context. Browser error bodies remain sanitized; gateway trace_id remains the control-plane support handle.Request cookie header before invoking route-targeted Node functions. This restores the documented req.headers.get("cookie") contract for OAuth state checks and cookie-backed routed app sessions. Existing deployed functions keep their generated wrapper until redeployed; new deploys and redeploys pick up the fix.POST /projects/v1/expose/validate validates against an empty schema plus optional parsed migration_sql; POST /projects/v1/admin/:id/expose/validate merges the current project schema with optional parsed migration_sql. Both return the same { hasErrors, errors, warnings } envelope used by the bundle manifest CI gate. The gateway never executes the SQL, applies the manifest, writes deploy plans, updates internal.project_manifest, or reloads PostgREST on these validation routes.Request on both managed subdomains and local routed invocation. Exact dynamic routes such as /admin and /admin/ are resolved before static asset or SPA fallback, matched route failures continue to fail closed, route diffs ignore JSON key-order noise in identical targets, and custom-domain routed invoke secret wiring is synced from CDK with a distinct missing-Worker-secret error.route_scopes such as ["/admin","/admin/*"]. Bindings still have no route authority by default. When CI sends non-null spec.routes, Run402 compares the proposed replace-mode route table against the current base table and rejects HTTP 403 CI_ROUTE_SCOPE_DENIED if any added, removed, or changed route is outside the delegated scopes. Unchanged out-of-scope routes may be re-applied, so full route manifests can stay in GitOps workflows without allowing CI to widen public browser ingress by surprise.ReleaseSpec.routes table that maps same-origin browser paths to serverless functions. Exact paths such as /admin and final prefix wildcards such as /api/* route through a new run402.routed_http.v1 envelope that preserves public URL, query string, cookies, duplicate-safe headers, and base64 request bodies up to 6 MiB. Dynamic routes run before static assets and SPA fallback; unsafe method misses return 405; matched dynamic failures fail closed. Responses preserve redirects and multiple cookies, omit bodies on HEAD, default to private no-store only when the function sets no cache header, and do not get wildcard CORS. The route table appears in release snapshots, plan/release diffs, release inventory, warnings, managed-subdomain host lookup, and custom-domain Worker manifests. This phase intentionally does not add ReleaseSpec.web, framework adapters, edge runtime, streaming, WebSockets, ISR, image optimization, or route-level x402 policy.POST /agent/v1/contact returns email_verification_status, passkey_binding_status, and assurance_level; new or changed emails start a reply challenge and stay email_pending until the mailbox owner replies. After email_verified, POST /agent/v1/contact/passkey/enroll sends a short-lived browser enrollment link to the verified email, where the operator can bind a Run402-origin passkey. Labels are intentionally limited to wallet_only, email_pending, email_verified, passkey_pending, and operator_passkey: this records reachable operator mailbox control and passkey continuity, not a humanhood or uniqueness claim./auth/v1/passkeys/*. Passkey sessions issue normal Run402 auth tokens with assurance metadata (amr, auth_time, aal, passkey_id), and refresh tokens preserve that metadata. WebAuthn origins are pinned to exact project app origins: claimed Run402 subdomains, the project public-id host, active custom domains, or localhost when allowed. Auth settings now include preferred_sign_in_method, public_signup, and require_passkey_for_project_admin. When enforcement is on, password/OAuth/magic-link admin sessions are downgraded until the user signs in with an eligible passkey. New service-key POST /auth/v1/admin/users creates or updates users and can send trusted invite links for first admin passkey bootstrap./deploy/v2/plans rejects secrets.set and secrets.replace_all (HTTP 400 INVALID_SPEC); the spec only carries secrets.require[] (declare keys that must already exist) and secrets.delete[] (atomic with activate). Plans with missing required keys still return HTTP 201 but emit a MISSING_REQUIRED_SECRET entry in the new warnings field; commit-time gating hard-errors with HTTP 422. POST /projects/v1/admin/{id}/secrets with body { key, value } now encrypts every value via AWS KMS (one CMK per platform, EncryptionContext bound to { project_id, key }). Hard cap: 4 KiB UTF-8 per value (HTTP 413 SECRET_VALUE_TOO_LARGE over). Secret keys must match ^[A-Z_][A-Z0-9_]{0,127}$. GET .../secrets no longer returns value_hash. Bugsnag scrubber redacts body.value AND nested secrets.set.*.value paths defensively. Two paired migrations: v1.37 adds the new ciphertext columns and runs TS-level batched-commit redaction loops over historical internal.releases manifests (inline AND CAS-spilled, recomputing manifest_digest byte-for-byte against the runtime). v1.38 (gated by ALLOW_DROP_VALUE_ENCRYPTED=true AND a drain check) drops the legacy value_encrypted column. A background backfill worker re-encrypts existing rows out-of-band so the gateway boot path never blocks on KMS throttling. Caveat: secret values are still visible to AWS Lambda as environment variables — the residual exposure is the function's CloudWatch log group. The platform does NOT encrypt them inside Lambda's environment block./deploy/v2. GET /deploy/v2/releases/{id} returns the activation-time materialized state of a specific release (snapshot-backed, O(1)); GET /deploy/v2/releases/active returns the active release's CURRENT LIVE state (reads live tables, so a setSecret call between activation and now appears here); GET /deploy/v2/releases/diff?from=<id|empty|active>&to=<id|active> diffs two releases with the same envelope shape as the plan response. All three are always available and return JSON envelopes for errors — NEVER HTML 404 (that was the bug #106/#107 closed). New internal.release_state_snapshots table (v1.39) stores activation-time state; snapshot bytes are built from the planned post-activation state BEFORE the activate transaction begins, so the stale-read race against in-transaction mutations is impossible. CAS-GC's orphan-detection union extended to cover snapshot refs (without it, CAS bodies of spilled snapshots would be silently reaped). Patch-conflict validation now rejects ambiguous specs (same path in site.patch.put+site.patch.delete, same name in functions.patch.set+functions.patch.delete, same name in subdomains.add+subdomains.remove) as 400 INVALID_SPEC./ci/v1/bindings; the workflow exchanges its GitHub OIDC JWT at /ci/v1/token-exchange for a short-lived Run402 CI session and then calls the existing /deploy/v2 plan/commit routes. V1 CI sessions are deploy-scoped and limited to site, function, and database deploy specs with base: { release: "current" }; secrets, subdomains, routes, checks, and oversized manifest refs are rejected. Bindings can be listed and revoked, and revocation is checked on every CI gateway request... segments rejected at upload (closes #157) — r.blobs.put(projectId, "../../../etc/passwd", source) used to be accepted and stored verbatim. The blob then listed by r.blobs.ls but unfetchable (URL host normalized .. segments and walked UP to the site root) and undeletable through the SDK (the same normalization made r.blobs.rm 404). Result: orphaned row counting against the project's storage_bytes quota. The write-side validateKey regex ^[a-zA-Z0-9._/\-]+$ allowed . as a literal, so .. segments slipped past — while the read-side normalizeBlobKey was already rejecting them. The two validators now agree: write-side splits on / and rejects any segment equal to ... Sanity-positive cases (foo.bar.txt, path/with/dots.txt, ..foo, foo..) keep passing.Object.freeze(project) hardening on projectCache.set() (the run402#170 fix) caught a latent bug in POST /projects/v1/admin/:id/pin and POST /projects/v1/admin/:id/unpin — both handlers mutated project.pinned directly on the cached frozen object after the DB UPDATE, throwing TypeError: Cannot assign to read only property 'pinned' at runtime. Replaced the in-place mutation with projectCache.set(project.id, { ...project, pinned }) so the cache holds a fresh frozen replacement. Two regression tests pin/unpin a frozen project fixture and assert no TypeError.@x402/express payment challenges AND app-level denials like QUOTA_EXCEEDED, PROJECT_FROZEN, NO_ACTIVE_TIER, insufficient_balance_for_30_day_prepay, wallet_suspended_unpaid_rent, AI translation quota, and email-pack quota. The SDK's @x402/fetch wrapper intercepted ALL 402s and tried to parse them as x402 payment requirements; Run402 envelopes don't match that shape, so the parser threw "Failed to parse payment requirements" and masked the real error. Following the GPT-5.5 Pro consultation in docs/consultations/project-cache-mutable-fields-fix.md, all app-level 402 sites now return HTTP 403 with the same envelope (same code, same category, same next_actions). Real x402 challenges from @x402/express — on POST /tiers/v1/:tier, POST /generate-image/v1, POST /contracts/v1/wallets, POST /contracts/v1/call, POST /faucet/v1 — keep returning 402. Agents that branch on the structured code field keep working; agents that branched on status === 402 for application errors break (and were already broken because of the x402-fetch parse failure). Supersedes the band-aid in commit 6fa2c0d8.projectCache mutable counters removed (closes #170 properly) — Yesterday's band-aid (6fa2c0d8) refreshed the cache after retention paths but left two structural problems. Other write paths (blob delete, deployment_files cascade from non-retention sources) still drift the cache, and meteringMiddleware was reading project.apiCalls / project.storageBytes from the cached ProjectInfo object — which DB triggers and async work could change without app-side knowledge. The fields are now removed from ProjectInfo outright. A new getProjectUsage(projectId, { allowMemo }) helper reads internal.projects.api_calls / storage_bytes authoritatively from the DB, with a 1-second process-local memo for the metering hot path. The metering middleware uses a read-then-re-read pattern: memoized read first; if it shows over-quota, a fresh re-read with allowMemo: false runs immediately before denial — so a prune that just dropped storage_bytes microseconds ago can't produce a stale 403. Cached ProjectInfo objects are Object.freeze'd on insert so any future regression like project.apiCalls++ throws a TypeError at runtime. Quota checks at upload-session init / content-plan creation / deploy-plan creation now also use getProjectUsage({ allowMemo: false }) instead of reading from cache. The original >= quota comparison was off-by-one and is corrected to >.prune-superseded decremented storage_bytes via the AFTER DELETE trigger, the in-memory project cache held the pre-prune (inflated) value. The next deploy hit meteringMiddleware, saw cached storageBytes >= tier.storageBytes, and 402'd. The SDK's x402-wrapped fetch couldn't parse the Run402 error envelope as x402 payment requirements and surfaced "Failed to parse payment requirements" — masking the false-positive quota check. Both prune paths now collect RETURNING project_id and refresh the cache post-commit. Same drift exists on blob/deployment_file deletes from other paths; those land in a follow-up.GET /deploy/v2/operations/:id returns operation_id — The snapshot route emitted id, but the SDK's OperationSnapshot type and openapi.json have always specified operation_id. Harmless before today — successful commits returned ready synchronously and the SDK never polled past the commit response. The earlier #153 reorder made activation_pending a real intermediate state on Lambda failure, which kicked the SDK into its poll loop, which read snapshot.operation_id = undefined, which POSTed /operations/undefined → 404 — masking the actual FUNCTION_ACTIVATE_FAILED envelope. Both the snapshot route and the list route now emit operation_id plus the release_id, last_activate_attempt_at, and urls fields the spec already declared.activateStagedFunctions ran AFTER the release flip, so a Lambda failure left the release active while Lambda still served old code — and subsequent same-spec deploys then hit the noop short-circuit and skipped activation entirely. commitDeploy re-ran the full state machine on retry, minting a duplicate release row and overwriting the original error. Reorder: function activation FIRST (out-of-tx, idempotent), release flip SECOND. On Lambda failure the release stays staged, so the next deploy actually retries. commitDeploy now gates on operation status: terminal returns the cached snapshot, resumable redirects to resumeOperation, only initial states run the state machine.r.deploy.apply now actually replaces function code — The /deploy/v2 activate phase was a TODO stub for spec.functions — staged_function_versions rows were inserted but never promoted to Lambda, so a successful redeploy via r.deploy.apply would silently keep running the old code. The activate phase now fetches each staged source from CAS, calls UpdateFunctionCode on the underlying Lambda, upserts internal.functions, and applies the schedule tri-state (cron string registers, null cancels, undefined no-ops). spec.functions.patch.delete is honored. On Lambda failure the operation stays in activation_pending so the existing hourly auto-resume worker retries up to 10 attempts with 5-minute cooldown. Becomes critical now that the v1 admin functions route is gone — v2 is the only path.internal.deployment_files meant every redeploy added rows that each charged against tier quota, even though CAS dedup keeps S3 to one physical copy. Three demo projects on the prototype tier crossed quota this week despite a ~60 MB single-deploy footprint — storage tracked deploy count to within 2%. A new hourly retention sweep deletes superseded deployments older than RELEASE_RETENTION_DAYS (default 7) that aren't bound to a subdomain or referenced by an in-flight operation; CASCADE through deployment_files fires the existing trigger and decrements the project's storage_bytes. CAS GC reclaims the orphaned S3 bytes on its existing 30-day grace. New admin route POST /deploy/v2/admin/projects/:id/prune-superseded (admin-key, with ?dry_run) for incident response. Composite alarm fires if the sweep produces zero deletes for 24h while eligible rows exist.GET /projects/v1/admin/:id/usage returns lease_expires_at — The endpoint already documented the field in openapi.json, but the handler omitted it — SDK callers were forced into a second roundtrip to /tiers/v1/status just to read the lease expiry. Response now includes lease_expires_at: string | null (ISO timestamp when a lease is active; null when the project has no wallet or no billing account). Schema in openapi.json updated to nullable so codegen reflects reality.POST /deploy/v1, GET /deploy/v1, POST /deploy/v1/plan, POST /deploy/v1/commit, POST /deployments/v1, GET /deployments/v1, GET /deployments/v1/:id, and POST /projects/v1/admin/:id/rls now return 404 Not Found. Successors: POST /deploy/v2/plans + POST /deploy/v2/plans/:id/commit for deploys, POST /projects/v1/admin/:id/expose for the declarative auth manifest, and GET /deploy/v2/operations/:id for status polling. The SDK has been routing through v2 since the unified-deploy ship in v1.34, so SDK and CLI users see no change; pre-revenue, no paying users were affected.https://run402.com/llms.txt is now a short wayfinder that points an agent at the right reference for its integration surface. Three new files land alongside it: llms-sdk.txt (canonical @run402/sdk reference), llms-mcp.txt (canonical run402-mcp tool reference), and a rewritten llms-full.txt (canonical HTTP API reference, modern-only — removed /deploy/v1, /deployments/v1 POST, and /projects/v1/admin/:id/rls routes are omitted from the agent docs). The wayfinder + SDK/CLI/MCP files are owned by the public repo kychee-com/run402 and pulled at deploy time; llms-full.txt is owned here. Coding agents should prefer the SDK over raw HTTP.PATCH /auth/v1/settings accepts the CLI/MCP header combo — Handler now accepts service-role authorization from either apikey: <service_key> or Authorization: Bearer <service_key> (with apikey: <anon_key> alongside, the convention CLI and MCP ship). Previously the documented combo returned 403. The Bearer fallback enforces project-id match against the apikey's project so cross-project escalation is rejected; tampered Bearers fall through to 403.code, category, retryable, safe_to_retry, mutation_state, trace_id, details, and next_actions. Agents can branch on codes like PROJECT_FROZEN, RATE_LIMITED, MIGRATE_GATE_ACTIVE, and MIGRATION_FAILED without parsing English. PostgREST-native errors, user function invocation responses, and presigned upload target responses remain passthrough boundaries.GET /storage/v1/uploads/:id now enriches its response with the live S3 ListParts view. Agents can see exactly which parts have been received and resume a stalled multipart upload without re-uploading completed parts. Server is now authoritative./deploy/v2 hardening — Spec validator rejects unsupported routes/checks and multi-file functions at validate-time (clearer errors than letting them blow up mid-commit). Activate phase claims new subdomains from spec.subdomains.set/add so a single commit can ship code and claim a brand. CDN publish path wired up for sites under unified deploy. Concurrent deploys to the same project share CAS bytes correctly. is_noop commits short-circuit to the active release_id instead of creating duplicate release rows. The manifest_ref escape hatch (when an inline manifest exceeds the 5 MB body cap) is now documented and tested.POST /deploy/v2/plans now accepts the full v1 expose manifest with policy-bearing tables (objects with name, policy, owner_column, custom_sql), matching what the imperative /expose route already accepts. The string[] shorthand still works (default policy public_read_authenticated_write). The commit phase routes through the same applyManifest + NOTIFY pgrst sequence the imperative route uses. Unblocks r.deploy.apply and r.apps.bundleDeploy with policy-bearing manifests.resetSchemaSlot (the drop/recreate during project create and purge) now takes a per-slot pg_advisory_xact_lock so concurrent operations on the same recycled slot can't race on pg_catalog tuples. Eliminates the intermittent tuple concurrently deleted / tuple concurrently updated errors. No behavior change visible to callers — only error rates drop.POST /projects/v1/admin/:id/functions now returns the actual esbuild / dep-resolution error with the right status code (400 / 413 / 503) instead of a content-free 500. Unsupported package imports now surface as bundler diagnostics instead of being masked by Express./deploy/v2, v1.34) — Three deploy transports — apps.bundleDeploy, sites.deployDir, blobs.put — collapse onto a single primitive. Two-phase wire protocol: POST /deploy/v2/plans negotiates the diff and lists missing content, then POST /deploy/v2/plans/:id/commit drives a state machine (validate → stage → migrate-gate → migrate → schema-settle → activate → ready). Bytes always travel through CAS via the new generic POST /content/v1/plans route — no more inline base64. New internal.releases table is the immutable source of truth (one active per project, parent_id links the chain). Schema-settle gate fires a canary SELECT 1 retrying up to 12×500ms before activation, so PostgREST forward retry can never serve a stale schema after DDL. Migration registry keyed by (project_id, migration_id) with checksum: idempotent re-deploys are noops; checksum mismatches are hard errors. Auto-resume worker recovers stuck deploys after 5 minutes. Removed v1 deploy endpoints were superseded by POST /deploy/v2/plans and POST /deploy/v2/plans/:id/commit. The atomic-multi-resource shape (DB + RLS + secrets + functions + site + subdomain in one commit) is preserved end-to-end.--deps, no more Lambda layer — The shared run402-functions-runtime Lambda layer is gone. @run402/functions (the in-function helper) is now bundled into every function zip at deploy time, alongside any user-declared --deps. The previous --deps array was decorative; it actually works now. Resolved versions land in deps_resolved; the bundled @run402/functions version lands in runtime_version. Existing functions on the legacy layer keep running until redeployed; on the next redeploy they migrate to bundling. Hardened npm install (lifecycle scripts off, scrubbed env, public-registry-only with lockfile audit, native binaries rejected) plus an esbuild resolver plugin that enforces user-source isolation.<sub>.run402.com/_blob/<key>) moves from CloudFront → OAC → S3 to CloudFront → ALB → gateway origin reading the v1.32 CAS substrate. Upload completion responses now include both cdn_url (key-stable, mutable, auto-invalidates on mutation) and cdn_immutable_url (sha-stable, immutable, Cache-Control: public, max-age=31536000, immutable safe forever). New diagnose endpoint GET /storage/v1/blobs/diagnose?url=<blob-url> returns expected vs observed SHA, cache state, and any in-flight invalidation, so agents can debug a stale-CDN ticket from one call. Tracks a new Run402/BlobCDN CloudWatch namespace._cas/{sha[0:2]}/{rest}. Bytes upload direct-to-S3 via presigned URLs into a per-session staging key, then promote to CAS only after SHA-256 verification — never a partial commit. Per-reference billing: storage_bytes is now driven entirely by triggers on the four ref tables; same SHA referenced N times in a project contributes N × size. Three-phase CAS GC runs hourly with a 30-day grace window before any S3 delete; DB row delete only AFTER S3 delete succeeds. Durable copy-resume worker handles cases where the synchronous Stage 2 doesn't finish in 30 seconds. Cutover was DESTRUCTIVE; rollback path is forward-fix or RDS snapshot restore.manifest.json — what code, deps, and migrations are deployed) so agents and CI can verify deploys from outside without trusting their local copy. Newly created tables in user schemas are now dark-by-default — invisible to PostgREST until explicitly granted via the deploy spec — closing the gap where a forgotten table grant could leak internal data through /rest/v1/* just because it existed. Pairs with the v1.30 PUBLIC EXECUTE revoke the previous day.db(req) + adminDb()) — The @run402/functions runtime helper now exposes two distinct DB clients: db(req) runs in the caller's user context (RLS enforced, JWT forwarded) and adminDb() runs as service_role (RLS bypassed). Calling service_role directly via /rest/v1/* from outside a function is now rejected — service_role is function-only. Closes the footgun where a function might accidentally elevate to admin while serving an end-user request.PUBLIC EXECUTE on user functions (v1.30) — A Postgres event trigger fires on every CREATE FUNCTION in user schemas and revokes PUBLIC EXECUTE immediately. The anon role can no longer invoke arbitrary SQL functions just because they exist; callers must be granted explicitly via the deploy spec.GET /status availability report — New unauthenticated endpoint returns rolling availability data (uptime %, last incident, current state) for the gateway and all major substrate dependencies. Powers status.run402.com; safe to embed in shields/badges and dashboards.GET /projects/v1 is now SIWX-only; pricing moved to GET /tiers/v1 — Listing projects requires Sign-In-With-X (wallet auth) instead of being publicly readable — closes the enumeration leak where any wallet's project list was queryable. Tier pricing data moved to the new GET /tiers/v1 (no auth, public, designed for landing pages and CLI quotes). BREAKING for clients fetching projects unauthenticated.run402:project_id cost-allocation tag. Cost Explorer rollups by tag let the admin Finance dashboard show real per-project AWS spend, so unprofitable tenants are visible instead of averaged into the platform total.public_id + blob CDN routing — Storage uploads now use presigned PUT URLs — clients send bytes straight to S3 instead of through the gateway, removing the gateway request-body cap as an upload size ceiling. Each project gets a stable public_id used as the S3 prefix and CDN routing key. Blob CDN routing reads project from a CloudFront KeyValueStore at the edge instead of round-tripping to the gateway for every asset request — orders-of-magnitude lower edge latency.Cache-Control per blob kind — Mutable blobs ship a short Cache-Control with auto-invalidate-on-mutation (re-uploading new bytes for the same key triggers CloudFront CreateInvalidation automatically). Immutable blobs ship Cache-Control: public, max-age=31536000, immutable and never invalidate — the URL encodes the SHA, so mutation produces a new URL.Accept: text/markdown is sent — clean prose without HTML scaffolding, in roughly 1/4 the bytes. API catalog at /.well-known/api-catalog (RFC 9727) lets agents discover the gateway's full endpoint surface in one fetch. Content Signals in robots.txt publish machine-readable AI-training opt-in/out signals for well-behaved crawlers.status.run402.com) — Status page moved from gateway-served to S3 + CloudFront so it stays up even when the gateway is what's down. Adds a daily incident rollup, atom feed, shields/badges endpoint, embedded incident calendar, and a methodology page documenting how uptime is measured.POST /mailboxes/v1/:id/webhooks (already shipped) gains the rest: GET to list, GET /:webhookId to read one, PATCH to update URL/secret/active flag, DELETE to remove. Lets agents reconfigure inbound-email handlers (rotate the webhook secret, point at a new Lambda, disable temporarily) without dropping the mailbox and re-creating it.active → past_due → frozen → dormant → purged. The live site keeps serving end users throughout — only the project owner's control plane (deploys, secret rotation, subdomain claims, function upload) is gated. Three warning emails across grace: past_due at day 0, frozen at day 14, final warning 24 hours before deletion. Subdomain reservation: the name is held for the original owner's wallet throughout grace, so a missed renewal can't lose the brand to a squatter. Any tier renewal during grace instantly reactivates the project and clears the countdown. Scheduled (cron) functions pause at day 44 to stop charging absent owners for compute. Operator rescue endpoints for admin-initiated reactivation and subdomain release. Motivated by saas-factory products: one missed renewal used to silently destroy a live brand's data and subdomain with no recourse — now it takes ~104 days, three emails, and the name is held until the tail expires.service_key token format aligned with anon_key (no exp) — The gateway-issued service_key JWT no longer carries an exp claim — aligns with anon_key and removes "my function broke after 30 days" bugs. Existing functions on legacy Lambdas with stale env-var copies self-heal on next cold start. Rotate immediately if you've embedded a service_key outside run402 in a library that hard-requires an exp.Cache-Control: public, max-age=... on non-HTML assets (CSS, JS, images, fonts) so CF edges cache them. HTML still passes through uncached so fork badges, auth state, and dynamic redirects keep working. Cuts time-to-first-asset for cold-cache visitors.POST /email/v1/domains/inbound, add the MX record to your DNS, and replies to <slug>@yourdomain.com route through the same pipeline as @mail.run402.com. Opt-in, requires DKIM-verified domain. Removing the sender domain cascades to disable inbound. Enables kysigned reply-to-sign at branded addresses.GET /mailboxes/v1/:id/messages/:messageId/raw endpoint returns the exact RFC-822 bytes of an inbound message, fetched verbatim from S3 with no parsing or normalization. Inbound messages only, Content-Type: message/rfc822, 10MB cap. Use this for cryptographic verification — DKIM signature checks, zk-email proofs, archival-grade email storage. The existing JSON message endpoint still returns parsed body_text for display and threading. Apps doing signature verification over inbound mail (kysigned reply-to-sign, etc.) should always read raw bytes — any post-processing breaks the cryptographic chain./contracts/v1/* endpoints for AWS KMS-backed Ethereum wallets per project. Private keys never leave KMS. $0.04/day rental ($1.20/month, billed daily as kms_wallet_rental) plus $0.000005 per contract call (KMS sign fee, billed alongside chain gas at-cost). 30-day prepay required at creation. Provision via POST /contracts/v1/wallets; submit calls via POST /contracts/v1/call; non-custodial with optional drain endpoint and recovery address as safety nets. Wallets that stay suspended for 90 days are permanently deleted. Base mainnet first; chain registry config-only for adding more chains.POST /billing/v1/tiers/:tier/checkout. Buy email packs ($5 = 10,000 emails, never expire) at POST /billing/v1/email-packs/checkout — require a verified custom sender domain to protect mail.run402.com reputation. Auto-recharge optional. Balance/history endpoints now auto-detect wallet or email identifiers. Backward compat preserved for all existing wallet flows.POST /email/v1/domains. DKIM verification via SES, DNS records provided. Once verified, email sends from <slug>@<your-domain>. Wallet-scoped ownership for multi-project reuse.POST /auth/v1/magic-link, verify via grant_type=magic_link. Auto-creates users on first use. Includes password change/reset/set endpoint and project-level allow_password_set setting. Multi-method identity: users can have any combination of password, OAuth, and magic link.@run402/functions runtime helper into standalone TypeScript npm package with full type definitions, replacing the inlined heredoc that previously shipped via the function-runtime layer (see April 28 entry for the layer-drop follow-up)inherit: true flag on deploy requests to carry forward unchanged files from previous deployment via S3 server-side copyproject_admin Postgres role with BYPASSRLS, is_admin flag on users, admin JWT issuance, and promote/demote endpointson-* lifecycle hook convention; gateway auto-invokes on-signup function fire-and-forget after first user signupfrom_name) support, bumped team tier daily send limit to 500<slug>@mail.run402.com with template-based outbound, reply-only inbound, and SES integrationSQL type with sql() helper and libpg-query pre-flight validationbootstrap function auto-invoked after fork/deploy with caller-provided variablesgetUser(req) in functions runtime to retrieve authenticated user inside edge functions via JWT